Limit serial workflows to IOM1 only — IOM2 unreachable over serial

The serial cable connects to IOM1's console port. IOM2 cannot be queried
or configured via the serial connection. Updated all serial-path code to
reflect this:

workflow_serial.py:
- fetch_current_config: query IOM1 only, single-row table
- collect_network_config: prompt for IOM1 settings only, drop IOM2 section
- apply_configuration: apply to IOM1 only, single-row result table
- print_summary: IOM1-only table, updated warning text

workflow_check.py (_check_via_serial):
- Query IOM1 only for network settings, IOM firmware, and Fabric Card firmware

CLAUDE.md updated to document the IOM1-only serial limitation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-16 10:47:12 -04:00
parent c587e64f9e
commit 76ee347d91
3 changed files with 117 additions and 156 deletions

View File

@@ -39,12 +39,12 @@ modules/
## Workflows ## Workflows
### 1 — Serial Network Configuration (`workflow_serial.py`) ### 1 — Serial Network Configuration (`workflow_serial.py`)
5-step workflow using the USB serial cable: 5-step workflow using the USB serial cable. **Serial access is limited to IOM1 only** — the cable connects to IOM1's console port; IOM2 cannot be queried or configured this way.
1. Detect USB serial device (`/dev/ttyUSB*`, `/dev/ttyACM*`, `/dev/ttyU*`) 1. Detect USB serial device (`/dev/ttyUSB*`, `/dev/ttyACM*`, `/dev/ttyU*`)
2. Open 115200-baud 8N1 connection and wake the IOM console 2. Open 115200-baud 8N1 connection and wake the IOM console
3. Query current network settings via Redfish (`GET` over `127.0.0.1`) 3. Query IOM1 current network settings via Redfish (`GET` over `127.0.0.1`)
4. Collect new settings from user (Static IP or DHCP) 4. Collect new settings from user (Static IP or DHCP) for IOM1
5. Apply via Redfish `PATCH` over `127.0.0.1` 5. Apply via Redfish `PATCH` over `127.0.0.1` to IOM1
### 2 — Firmware Update (`workflow_firmware.py`) ### 2 — Firmware Update (`workflow_firmware.py`)
Updates IOM firmware and/or Fabric Card firmware over the network: Updates IOM firmware and/or Fabric Card firmware over the network:
@@ -58,7 +58,7 @@ Updates IOM firmware and/or Fabric Card firmware over the network:
### 3 — System Check (`workflow_check.py`) ### 3 — System Check (`workflow_check.py`)
Read-only diagnostic workflow — queries current network settings and firmware versions, makes no changes: Read-only diagnostic workflow — queries current network settings and firmware versions, makes no changes:
- **Serial:** logs in via serial console, queries both IOMs using `_serial_redfish_request()` (curl over the serial session); covers network settings, IOM firmware, and Fabric Card firmware - **Serial:** logs in via serial console, queries IOM1 only using `_serial_redfish_request()` (curl over the serial session); covers network settings, IOM firmware, and Fabric Card firmware. IOM2 is not reachable over serial.
- **Network:** prompts for management IP(s), queries via direct HTTPS using `Admin` credentials; reuses `_redfish_request()`, `_get_iom_fw_version()`, `_get_fabric_fw_version()` from `redfish.py` - **Network:** prompts for management IP(s), queries via direct HTTPS using `Admin` credentials; reuses `_redfish_request()`, `_get_iom_fw_version()`, `_get_fabric_fw_version()` from `redfish.py`
- Displays results in two tables: network configuration and firmware versions - Displays results in two tables: network configuration and firmware versions
- User selects Serial, Network, or Cancel at the sub-menu prompt - User selects Serial, Network, or Cancel at the sub-menu prompt

View File

@@ -97,54 +97,49 @@ def _check_via_serial():
time.sleep(2) time.sleep(2)
return return
rule("Querying IOM Status") rule("Querying IOM1 Status")
info("Querying network settings and firmware versions via serial console...") info("Querying IOM1 network settings and firmware versions via serial console...")
info("Note: only IOM1 is reachable over the serial connection.")
print() print()
net_rows = [] # ── Network settings ───────────────────────────────────────────────────────
fw_rows = [] net_ok, net_data = _serial_redfish_request(
ser, password, "GET",
"/redfish/v1/Managers/IOM1/EthernetInterfaces/1",
)
if net_ok and isinstance(net_data, dict):
dhcp, ip, gw, nm, origin = _parse_network_data(net_data)
mode = _c(C.CYN, "DHCP") if dhcp else _c(C.GRN, "Static")
net_rows = [["IOM1", mode, origin, ip, gw, nm]]
else:
net_rows = [["IOM1", _c(C.RED, "No response"), "--", "--", "--", "--"]]
error(f"IOM1 network query failed: {net_data}")
for iom in ("IOM1", "IOM2"): # ── IOM firmware version ───────────────────────────────────────────────────
# ── Network settings ─────────────────────────────────────────────────── iom_ok, iom_data = _serial_redfish_request(
net_ok, net_data = _serial_redfish_request( ser, password, "GET",
ser, password, "GET", "/redfish/v1/Managers/IOM1",
f"/redfish/v1/Managers/{iom}/EthernetInterfaces/1", )
) iom_ver = (
if net_ok and isinstance(net_data, dict): iom_data.get("FirmwareVersion", "Unknown")
dhcp, ip, gw, nm, origin = _parse_network_data(net_data) if (iom_ok and isinstance(iom_data, dict))
mode = _c(C.CYN, "DHCP") if dhcp else _c(C.GRN, "Static") else _c(C.RED, "Unreachable")
net_rows.append([iom, mode, origin, ip, gw, nm]) )
else:
net_rows.append([iom, _c(C.RED, "No response"), "--", "--", "--", "--"])
error(f"{iom} network query failed: {net_data}")
# ── IOM firmware version ─────────────────────────────────────────────── # ── Fabric card firmware version ───────────────────────────────────────────
iom_ok, iom_data = _serial_redfish_request( fab_ok, fab_data = _serial_redfish_request(
ser, password, "GET", ser, password, "GET",
f"/redfish/v1/Managers/{iom}", "/redfish/v1/Chassis/IOM1/NetworkAdapters/1",
) )
iom_ver = ( fab_ver = (
iom_data.get("FirmwareVersion", "Unknown") (fab_data.get("Oem", {})
if (iom_ok and isinstance(iom_data, dict)) .get("Version", {})
else _c(C.RED, "Unreachable") .get("ActiveFirmwareVersion", "Unknown"))
) if (fab_ok and isinstance(fab_data, dict))
else _c(C.RED, "Unreachable")
)
# ── Fabric card firmware version ─────────────────────────────────────── _print_results(net_rows, [["IOM1", iom_ver, fab_ver]])
fab_ok, fab_data = _serial_redfish_request(
ser, password, "GET",
f"/redfish/v1/Chassis/{iom}/NetworkAdapters/1",
)
fab_ver = (
(fab_data.get("Oem", {})
.get("Version", {})
.get("ActiveFirmwareVersion", "Unknown"))
if (fab_ok and isinstance(fab_data, dict))
else _c(C.RED, "Unreachable")
)
fw_rows.append([iom, iom_ver, fab_ver])
_print_results(net_rows, fw_rows)
close_serial_connection(ser, device) close_serial_connection(ser, device)

View File

@@ -339,80 +339,67 @@ def open_serial_connection(device: str) -> Optional[SerialPort]:
# ── Step 3: Fetch & display current IOM network settings ───────────────────── # ── Step 3: Fetch & display current IOM network settings ─────────────────────
def fetch_current_config(cfg: ShelfConfig, ser: SerialPort) -> bool: def fetch_current_config(cfg: ShelfConfig, ser: SerialPort) -> bool:
""" """
Query Redfish for the current network config of both IOMs via curl Query Redfish for the current network config of IOM1 via curl over the
over the serial console session. serial console session. Only IOM1 is reachable — the serial cable
Populates cfg.iom1 / cfg.iom2 with live data. connects to IOM1's console port; IOM2 cannot be queried this way.
Returns True if at least one IOM responded. Populates cfg.iom1 with live data.
Returns True if IOM1 responded successfully.
""" """
rule("Step 3 of 5 -- Current IOM Network Settings") rule("Step 3 of 5 -- Current IOM1 Network Settings")
info("Querying Redfish API via serial console...") info("Querying IOM1 Redfish API via serial console...")
info("Note: only IOM1 is reachable over the serial connection.")
print() print()
any_ok = False path = "/redfish/v1/Managers/IOM1/EthernetInterfaces/1"
rows = [] ok_flag, data = _serial_redfish_request(ser, cfg.password, "GET", path)
errors = []
for iom in ("IOM1", "IOM2"): if ok_flag and isinstance(data, dict):
path = f"/redfish/v1/Managers/{iom}/EthernetInterfaces/1" # Determine mode
ok_flag, data = _serial_redfish_request(ser, cfg.password, "GET", path) dhcp_enabled = (
data.get("DHCPv4", {}).get("DHCPEnabled", False) or
data.get("DHCPv6", {}).get("DHCPEnabled", False)
)
if ok_flag and isinstance(data, dict): # Pull address info — prefer StaticAddresses, fall back to IPv4Addresses
any_ok = True addrs = data.get("IPv4StaticAddresses") or data.get("IPv4Addresses", [])
if addrs:
# Determine mode addr_rec = addrs[0]
dhcp_enabled = ( ip = addr_rec.get("Address", "--")
data.get("DHCPv4", {}).get("DHCPEnabled", False) or gateway = addr_rec.get("Gateway", "--")
data.get("DHCPv6", {}).get("DHCPEnabled", False) netmask = addr_rec.get("SubnetMask", "--")
)
# Pull address info — prefer StaticAddresses, fall back to IPv4Addresses
addrs = data.get("IPv4StaticAddresses") or data.get("IPv4Addresses", [])
if addrs:
addr_rec = addrs[0]
ip = addr_rec.get("Address", "--")
gateway = addr_rec.get("Gateway", "--")
netmask = addr_rec.get("SubnetMask", "--")
else:
ip = gateway = netmask = "--"
origin = data.get("IPv4Addresses", [{}])[0].get("AddressOrigin", "Unknown") \
if data.get("IPv4Addresses") else ("DHCP" if dhcp_enabled else "Static")
iom_cfg = IOMConfig(
iom = iom,
dhcp = dhcp_enabled,
ip = ip if ip != "--" else "",
gateway = gateway if gateway != "--" else "",
netmask = netmask if netmask != "--" else "",
)
if iom == "IOM1":
cfg.iom1 = iom_cfg
else:
cfg.iom2 = iom_cfg
mode_str = f"{_c(C.CYN, 'DHCP')}" if dhcp_enabled else f"{_c(C.GRN, 'Static')}"
rows.append([iom, mode_str, origin, ip, gateway, netmask])
else: else:
rows.append([iom, _c(C.RED, "No response"), "--", "--", "--", "--"]) ip = gateway = netmask = "--"
errors.append((iom, str(data)))
draw_table( origin = data.get("IPv4Addresses", [{}])[0].get("AddressOrigin", "Unknown") \
["IOM", "Mode", "Origin", "IP Address", "Gateway", "Subnet Mask"], if data.get("IPv4Addresses") else ("DHCP" if dhcp_enabled else "Static")
rows,
[5, 10, 8, 16, 16, 16],
)
print()
if errors: cfg.iom1 = IOMConfig(
for iom, err in errors: iom = "IOM1",
error(f"{iom} query failed: {err}") dhcp = dhcp_enabled,
ip = ip if ip != "--" else "",
gateway = gateway if gateway != "--" else "",
netmask = netmask if netmask != "--" else "",
)
mode_str = f"{_c(C.CYN, 'DHCP')}" if dhcp_enabled else f"{_c(C.GRN, 'Static')}"
draw_table(
["IOM", "Mode", "Origin", "IP Address", "Gateway", "Subnet Mask"],
[["IOM1", mode_str, origin, ip, gateway, netmask]],
[5, 10, 8, 16, 16, 16],
)
print() print()
return True
if not any_ok: else:
error("Neither IOM responded to the Redfish query.") draw_table(
error("Check that the serial cable is connected and the IOM is booted.") ["IOM", "Mode", "Origin", "IP Address", "Gateway", "Subnet Mask"],
[["IOM1", _c(C.RED, "No response"), "--", "--", "--", "--"]],
return any_ok [5, 10, 8, 16, 16, 16],
)
print()
error(f"IOM1 query failed: {data}")
error("Check that the serial cable is connected and IOM1 is booted.")
print()
return False
# ── Step 4: Prompt user — change config or exit ─────────────────────────────── # ── Step 4: Prompt user — change config or exit ───────────────────────────────
@@ -439,8 +426,8 @@ def collect_network_config(cfg: ShelfConfig) -> bool:
# ── User wants to change settings ───────────────────────────────────────── # ── User wants to change settings ─────────────────────────────────────────
print() print()
print(" How should the IOMs be configured?") print(" How should IOM1 be configured?")
print(f" {_c(C.BOLD, '1')} Static IP addresses") print(f" {_c(C.BOLD, '1')} Static IP address")
print(f" {_c(C.BOLD, '2')} DHCP") print(f" {_c(C.BOLD, '2')} DHCP")
print() print()
@@ -455,8 +442,7 @@ def collect_network_config(cfg: ShelfConfig) -> bool:
if use_dhcp: if use_dhcp:
cfg.iom1 = IOMConfig("IOM1", dhcp=True) cfg.iom1 = IOMConfig("IOM1", dhcp=True)
cfg.iom2 = IOMConfig("IOM2", dhcp=True) ok("IOM1 will be set to DHCP.")
ok("Both IOMs will be set to DHCP.")
return True return True
# Static — IOM1 # Static — IOM1
@@ -465,20 +451,6 @@ def collect_network_config(cfg: ShelfConfig) -> bool:
iom1_gw = prompt_ip(" IOM1 Gateway ") iom1_gw = prompt_ip(" IOM1 Gateway ")
iom1_nm = prompt_ip(" IOM1 Subnet Mask") iom1_nm = prompt_ip(" IOM1 Subnet Mask")
cfg.iom1 = IOMConfig("IOM1", dhcp=False, ip=iom1_ip, gateway=iom1_gw, netmask=iom1_nm) cfg.iom1 = IOMConfig("IOM1", dhcp=False, ip=iom1_ip, gateway=iom1_gw, netmask=iom1_nm)
# Static — IOM2
print()
info(f"Static network details for {_c(C.BOLD, 'IOM2')}:")
iom2_ip = prompt_ip(" IOM2 IP address ")
same = prompt_yn(" Same gateway and subnet mask as IOM1?", default=True)
if same:
iom2_gw, iom2_nm = iom1_gw, iom1_nm
else:
iom2_gw = prompt_ip(" IOM2 Gateway ")
iom2_nm = prompt_ip(" IOM2 Subnet Mask")
cfg.iom2 = IOMConfig("IOM2", dhcp=False, ip=iom2_ip, gateway=iom2_gw, netmask=iom2_nm)
return True return True
@@ -486,21 +458,14 @@ def collect_network_config(cfg: ShelfConfig) -> bool:
def apply_configuration(cfg: ShelfConfig, ser: SerialPort) -> bool: def apply_configuration(cfg: ShelfConfig, ser: SerialPort) -> bool:
rule("Step 5 of 5 -- Applying Configuration via Redfish API") rule("Step 5 of 5 -- Applying Configuration via Redfish API")
info("Sending Redfish PATCH requests via serial console curl...") info("Sending Redfish PATCH request to IOM1 via serial console curl...")
print() print()
results = [] success, detail = _apply_iom(cfg.password, cfg.iom1, ser)
all_ok = True status = _c(C.GRN, "OK") if success else _c(C.RED, "FAIL")
for iom_cfg in [cfg.iom1, cfg.iom2]: draw_table(["IOM", "Result", "Detail"], [["IOM1", status, detail]], [6, 8, 50])
success, detail = _apply_iom(cfg.password, iom_cfg, ser)
status = _c(C.GRN, "OK") if success else _c(C.RED, "FAIL")
results.append([iom_cfg.iom, status, detail])
if not success:
all_ok = False
draw_table(["IOM", "Result", "Detail"], results, [6, 8, 50])
print() print()
return all_ok return success
def _apply_iom(password: str, iom_cfg: IOMConfig, ser: SerialPort) -> tuple: def _apply_iom(password: str, iom_cfg: IOMConfig, ser: SerialPort) -> tuple:
@@ -560,22 +525,23 @@ def _apply_iom(password: str, iom_cfg: IOMConfig, ser: SerialPort) -> tuple:
def print_summary(cfg: ShelfConfig, changed: bool): def print_summary(cfg: ShelfConfig, changed: bool):
rule("Summary") rule("Summary")
def val(iom: IOMConfig, field: str) -> str: iom = cfg.iom1
dhcp_map = {"mode": "DHCP", "ip": "-- (DHCP)", "gateway": "-- (DHCP)", "netmask": "-- (DHCP)"} if iom.dhcp:
stat_map = {"mode": "Static", "ip": iom.ip, "gateway": iom.gateway, "netmask": iom.netmask} mode, ip, gateway, netmask = "DHCP", "-- (DHCP)", "-- (DHCP)", "-- (DHCP)"
return (dhcp_map if iom.dhcp else stat_map).get(field, "") else:
mode, ip, gateway, netmask = "Static", iom.ip, iom.gateway, iom.netmask
draw_table( draw_table(
["Setting", "IOM1", "IOM2"], ["Setting", "IOM1"],
[ [
["Mode", val(cfg.iom1, "mode"), val(cfg.iom2, "mode")], ["Mode", mode],
["IP Address", val(cfg.iom1, "ip"), val(cfg.iom2, "ip")], ["IP Address", ip],
["Gateway", val(cfg.iom1, "gateway"), val(cfg.iom2, "gateway")], ["Gateway", gateway],
["Subnet Mask", val(cfg.iom1, "netmask"), val(cfg.iom2, "netmask")], ["Subnet Mask", netmask],
["Serial Port", cfg.device, cfg.device], ["Serial Port", cfg.device],
["Changes", "Yes" if changed else "None", ""], ["Changes", "Yes" if changed else "None"],
], ],
[12, 22, 22], [12, 28],
) )
if changed: if changed:
@@ -583,7 +549,7 @@ def print_summary(cfg: ShelfConfig, changed: bool):
draw_box([ draw_box([
f"{_c(C.YEL, 'IMPORTANT -- Per the ES24N Service Guide:')}", f"{_c(C.YEL, 'IMPORTANT -- Per the ES24N Service Guide:')}",
"", "",
"Remove the serial cable ONLY after verifying each", "Remove the serial cable ONLY after verifying the",
"expander appears in TrueNAS with matching drives.", "expander appears in TrueNAS with matching drives.",
"", "",
"TrueNAS > System Settings > Enclosure >", "TrueNAS > System Settings > Enclosure >",