Files
es24n-conf/modules/workflow_serial.py
scott d80dac2222 Fix serial workflow: login to IOM console and run curl for Redfish
The Redfish API at 127.0.0.1 is only accessible from within the IOM's
own shell, not directly from the host over the serial cable. The previous
approach of making urllib HTTPS requests from the host to 127.0.0.1 was
fundamentally incorrect.

Changes:
- serial_port.py: add optional per-call timeout override to
  read_until_quiet() so curl responses have enough time to arrive
- workflow_serial.py:
  - add _login_serial_console() — sends username/password over serial
    and waits for a shell prompt before proceeding
  - add _serial_redfish_request() — builds and sends a curl command
    over the serial session, parses HTTP status and JSON from the output
  - fetch_current_config(), apply_configuration(), _apply_iom() now
    accept and use a SerialPort instance via _serial_redfish_request()
  - configure_shelf() calls _login_serial_console() after collecting
    the password, before making any Redfish calls
  - remove unused _redfish_request import (HTTP transport no longer
    used in the serial workflow)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 21:55:01 -04:00

570 lines
20 KiB
Python

"""
workflow_serial.py — Serial-based ES24N IOM network configuration workflow.
Connects via USB serial cable, logs in to the IOM console, and configures
network settings by running curl commands through the shell session.
The IOM's Redfish API is only accessible locally at https://127.0.0.1 from
within the IOM's own shell — not directly from the host over the serial cable.
All Redfish operations are therefore performed by issuing curl commands over
the serial connection and parsing the JSON responses.
"""
import glob
import json
import os
import re
import subprocess
import time
from typing import Optional
from models import IOMConfig, ShelfConfig
from serial_port import SerialPort
from ui import (
_c, C,
banner, rule, draw_table, draw_box,
info, ok, warn, error,
prompt, prompt_ip, prompt_yn, prompt_password,
)
# Strip ANSI escape sequences from serial terminal output
_ANSI_RE = re.compile(r'\x1b\[[0-9;]*[mGKHF]')
# ── Serial Redfish transport ───────────────────────────────────────────────────
def _login_serial_console(ser: SerialPort, password: str) -> bool:
"""
Perform the root login sequence on the IOM serial console.
Handles both the case where a login prompt is showing and where
a shell session is already active.
Returns True if a shell prompt is reached, False on failure.
"""
info("Logging in to IOM serial console...")
# Send a newline to get the current state of the console
ser.send_line("", delay=0.5)
response = _ANSI_RE.sub("", ser.read_until_quiet(quiet_period=0.5))
low = response.lower()
# Already at a shell prompt — no login needed
if ("#" in response or "$" in response) and "login" not in low:
ok("Already at shell prompt.")
return True
# Send username if we see a login prompt (or an empty/unknown response)
if "login" in low or not response.strip():
ser.send_line("root", delay=0.5)
response = _ANSI_RE.sub("", ser.read_until_quiet(quiet_period=0.5))
# Send password
if "password" in response.lower():
ser.send_line(password, delay=0.5)
response = _ANSI_RE.sub("", ser.read_until_quiet(quiet_period=2.0))
if "#" in response or "$" in response:
ok("Logged in to IOM console.")
return True
error(f"Login failed. Console response: {response.strip()[:120]}")
return False
def _serial_redfish_request(ser: SerialPort, password: str, method: str,
path: str, payload: Optional[dict] = None) -> tuple:
"""
Issue a Redfish request by running curl on the IOM's serial console.
The Redfish API is available at https://127.0.0.1 from within the IOM shell.
Appends -w '\\nHTTP_CODE:%{http_code}' to every curl call so the HTTP
status can be parsed independently of the response body.
Returns (success: bool, data: dict | str).
"""
url = f"https://127.0.0.1{path}"
cmd = f"curl -sk -u root:{password} -X {method}"
if payload:
body = json.dumps(payload, separators=(",", ":"))
cmd += f" -H 'Content-Type: application/json' -d '{body}'"
cmd += f" -w '\\nHTTP_CODE:%{{http_code}}' '{url}'"
ser.send_line(cmd, delay=0.3)
raw = _ANSI_RE.sub("", ser.read_until_quiet(quiet_period=1.5, timeout=30.0))
# ── Parse HTTP status code ─────────────────────────────────────────────────
http_code = 0
if "HTTP_CODE:" in raw:
try:
http_code = int(raw.split("HTTP_CODE:")[-1].strip()[:3])
except ValueError:
pass
# ── Extract JSON body (outermost { ... }) ──────────────────────────────────
data: dict = {}
json_start = raw.find("{")
json_end = raw.rfind("}") + 1
if json_start >= 0 and json_end > json_start:
try:
data = json.loads(raw[json_start:json_end])
except json.JSONDecodeError:
pass
# ── Determine success ──────────────────────────────────────────────────────
if http_code >= 400:
msg = (data.get("error", {}).get("message", "")
if isinstance(data, dict) else "")
return False, f"HTTP {http_code}: {msg or raw[json_start:json_start+120]}"
if http_code >= 200 or data:
return True, data
# No HTTP code and no JSON — check for curl-level error
if "curl:" in raw.lower():
return False, f"curl error: {raw.strip()[:120]}"
return True, data
# ── Step 1: Detect serial device ──────────────────────────────────────────────
def detect_serial_device() -> Optional[str]:
rule("Step 1 of 5 -- Serial Cable & Device Detection")
print(" Connect the serial cable from the ES24N IOM1 port")
print(" to the active F-Series controller USB port.")
print()
prompt("Press Enter when the cable is connected")
for attempt in range(1, 4):
info(f"Scanning for USB serial devices (attempt {attempt}/3)...")
time.sleep(1)
# FreeBSD: /dev/ttyU* Linux: /dev/ttyUSB*, /dev/ttyACM*
patterns = ["/dev/ttyUSB*", "/dev/ttyACM*", "/dev/ttyU*"]
ports = sorted({p for pat in patterns for p in glob.glob(pat)})
if ports:
break
if attempt < 3:
warn("No device found yet — retrying in 2 seconds...")
time.sleep(2)
if not ports:
error("No USB serial device detected after 3 attempts.")
print()
print(" Troubleshooting:")
print(" - Ensure the serial cable is fully seated at both ends.")
print(" - Try a different USB port on the controller.")
print(" - Confirm the ES24N is powered on.")
return None
if len(ports) == 1:
ok(f"Device found: {_c(C.BOLD, ports[0])}")
_fix_permissions(ports[0])
return ports[0]
# Multiple devices — let the user choose
print()
draw_table(
["#", "Device"],
[[str(i), p] for i, p in enumerate(ports, 1)],
[4, 24],
)
print()
while True:
val = prompt(f"Select device number [1-{len(ports)}]")
if val.isdigit() and 1 <= int(val) <= len(ports):
selected = ports[int(val) - 1]
ok(f"Selected: {_c(C.BOLD, selected)}")
_fix_permissions(selected)
return selected
warn(f"Please enter a number between 1 and {len(ports)}.")
def _fix_permissions(device: str):
try:
result = subprocess.run(
["sudo", "chown", ":wheel", device],
capture_output=True, timeout=5,
)
if result.returncode == 0:
ok(f"Permissions updated on {device}")
return
except Exception:
pass
try:
os.chmod(device, 0o666)
ok(f"Permissions updated on {device}")
except PermissionError:
warn("Could not update device permissions automatically.")
warn("If the connection fails, re-run this script with sudo.")
# ── Step 2: Open serial connection & wake IOM console ─────────────────────────
def open_serial_connection(device: str) -> Optional[SerialPort]:
rule("Step 2 of 5 -- Opening Serial Connection")
info(f"Opening {device} at 115200 baud (8N1)...")
ser = SerialPort(device, baudrate=115200, timeout=5.0)
try:
ser.open()
except OSError as e:
error(str(e))
return None
ok(f"Port opened: {device}")
info("Sending wake signal to IOM console...")
ser.send_line("", delay=0.5)
ser.send_line("", delay=0.5)
response = _ANSI_RE.sub("", ser.read_until_quiet(quiet_period=0.5))
print()
if response.strip():
print(f" {_c(C.DIM, '+-- IOM Console Response ' + '-' * 31)}")
for line in response.strip().splitlines():
print(f" {_c(C.DIM, '|')} {line}")
print(f" {_c(C.DIM, '+' + '-' * 56)}")
print()
low = response.lower()
if any(kw in low for kw in ("login", "$", "#", "password")):
ok("IOM console is responsive.")
else:
warn("Unexpected response — the IOM may still be booting.")
warn("You can continue; login will be attempted next.")
else:
warn("No response received from IOM console.")
warn("Login will be attempted after the password is entered.")
print()
return ser
# ── Step 3: Fetch & display current IOM network settings ─────────────────────
def fetch_current_config(cfg: ShelfConfig, ser: SerialPort) -> bool:
"""
Query Redfish for the current network config of both IOMs via curl
over the serial console session.
Populates cfg.iom1 / cfg.iom2 with live data.
Returns True if at least one IOM responded.
"""
rule("Step 3 of 5 -- Current IOM Network Settings")
info("Querying Redfish API via serial console...")
print()
any_ok = False
rows = []
errors = []
for iom in ("IOM1", "IOM2"):
path = f"/redfish/v1/Managers/{iom}/EthernetInterfaces/1"
ok_flag, data = _serial_redfish_request(ser, cfg.password, "GET", path)
if ok_flag and isinstance(data, dict):
any_ok = True
# Determine mode
dhcp_enabled = (
data.get("DHCPv4", {}).get("DHCPEnabled", False) or
data.get("DHCPv6", {}).get("DHCPEnabled", False)
)
# 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:
rows.append([iom, _c(C.RED, "No response"), "--", "--", "--", "--"])
errors.append((iom, str(data)))
draw_table(
["IOM", "Mode", "Origin", "IP Address", "Gateway", "Subnet Mask"],
rows,
[5, 10, 8, 16, 16, 16],
)
print()
if errors:
for iom, err in errors:
error(f"{iom} query failed: {err}")
print()
if not any_ok:
error("Neither IOM responded to the Redfish query.")
error("Check that the serial cable is connected and the IOM is booted.")
return any_ok
# ── Step 4: Prompt user — change config or exit ───────────────────────────────
def collect_network_config(cfg: ShelfConfig) -> bool:
"""
Show current settings, ask user what to do.
Returns True to proceed with applying changes, False to skip.
"""
rule("Step 4 of 5 -- Change Configuration?")
print(f" {_c(C.BOLD, '1')} Change network configuration")
print(f" {_c(C.BOLD, '2')} Leave settings as-is and disconnect")
print()
while True:
choice = prompt("Select option [1/2]")
if choice in ("1", "2"):
break
warn("Please enter 1 or 2.")
if choice == "2":
info("No changes requested.")
return False
# ── User wants to change settings ─────────────────────────────────────────
print()
print(" How should the IOMs be configured?")
print(f" {_c(C.BOLD, '1')} Static IP addresses")
print(f" {_c(C.BOLD, '2')} DHCP")
print()
while True:
mode = prompt("Select mode [1/2]")
if mode in ("1", "2"):
break
warn("Please enter 1 or 2.")
use_dhcp = (mode == "2")
print()
if use_dhcp:
cfg.iom1 = IOMConfig("IOM1", dhcp=True)
cfg.iom2 = IOMConfig("IOM2", dhcp=True)
ok("Both IOMs will be set to DHCP.")
return True
# Static — IOM1
info(f"Static network details for {_c(C.BOLD, 'IOM1')}:")
iom1_ip = prompt_ip(" IOM1 IP address ")
iom1_gw = prompt_ip(" IOM1 Gateway ")
iom1_nm = prompt_ip(" IOM1 Subnet Mask")
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
# ── Step 5a: Apply configuration via Redfish ──────────────────────────────────
def apply_configuration(cfg: ShelfConfig, ser: SerialPort) -> bool:
rule("Step 5 of 5 -- Applying Configuration via Redfish API")
info("Sending Redfish PATCH requests via serial console curl...")
print()
results = []
all_ok = True
for iom_cfg in [cfg.iom1, cfg.iom2]:
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()
return all_ok
def _apply_iom(password: str, iom_cfg: IOMConfig, ser: SerialPort) -> tuple:
"""
Apply network config to a single IOM via curl over the serial console.
DHCP: single PATCH enabling DHCPv4.
Static: two sequential PATCHes to work around a firmware bug in the
current ES24N release that prevents disabling DHCP and setting a static
address in the same request.
Pass 1 -- set the static IP/gateway/netmask (DHCP still on)
Pass 2 -- disable DHCP (address is already committed)
"""
path = f"/redfish/v1/Managers/{iom_cfg.iom}/EthernetInterfaces/1"
if iom_cfg.dhcp:
ok_flag, data = _serial_redfish_request(
ser, password, "PATCH", path,
{"DHCPv4": {"DHCPEnabled": True}},
)
if ok_flag:
return True, "Configured: DHCP"
return False, str(data)[:80]
# Static -- Pass 1: set address while DHCP is still enabled
info(f" {iom_cfg.iom} pass 1/2 -- setting static address {iom_cfg.ip}...")
ok_flag, data = _serial_redfish_request(
ser, password, "PATCH", path,
{
"IPv4StaticAddresses": [{
"Address": iom_cfg.ip,
"Gateway": iom_cfg.gateway,
"SubnetMask": iom_cfg.netmask,
}]
},
)
if not ok_flag:
return False, f"Pass 1 failed: {str(data)[:70]}"
# Brief pause to allow the IOM to commit the address before the next call
time.sleep(1)
# Static -- Pass 2: disable DHCP now that the static address is committed
info(f" {iom_cfg.iom} pass 2/2 -- disabling DHCP...")
ok_flag, data = _serial_redfish_request(
ser, password, "PATCH", path,
{"DHCPv4": {"DHCPEnabled": False}},
)
if not ok_flag:
return False, f"Pass 2 failed: {str(data)[:70]}"
return True, f"Configured: Static {iom_cfg.ip}"
# ── Step 5b: Print applied-settings summary ───────────────────────────────────
def print_summary(cfg: ShelfConfig, changed: bool):
rule("Summary")
def val(iom: IOMConfig, field: str) -> str:
dhcp_map = {"mode": "DHCP", "ip": "-- (DHCP)", "gateway": "-- (DHCP)", "netmask": "-- (DHCP)"}
stat_map = {"mode": "Static", "ip": iom.ip, "gateway": iom.gateway, "netmask": iom.netmask}
return (dhcp_map if iom.dhcp else stat_map).get(field, "")
draw_table(
["Setting", "IOM1", "IOM2"],
[
["Mode", val(cfg.iom1, "mode"), val(cfg.iom2, "mode")],
["IP Address", val(cfg.iom1, "ip"), val(cfg.iom2, "ip")],
["Gateway", val(cfg.iom1, "gateway"), val(cfg.iom2, "gateway")],
["Subnet Mask", val(cfg.iom1, "netmask"), val(cfg.iom2, "netmask")],
["Serial Port", cfg.device, cfg.device],
["Changes", "Yes" if changed else "None", ""],
],
[12, 22, 22],
)
if changed:
print()
draw_box([
f"{_c(C.YEL, 'IMPORTANT -- Per the ES24N Service Guide:')}",
"",
"Remove the serial cable ONLY after verifying each",
"expander appears in TrueNAS with matching drives.",
"",
"TrueNAS > System Settings > Enclosure >",
"NVMe-oF Expansion Shelves",
], colour=C.YEL)
print()
# ── Disconnect ────────────────────────────────────────────────────────────────
def close_serial_connection(ser: SerialPort, device: str):
if ser and ser.is_open:
ser.close()
ok(f"Serial port {device} closed.")
print()
prompt("Disconnect the serial cable, then press Enter to continue")
ok("Serial cable disconnected. Shelf complete.")
# ── Full shelf configuration cycle ────────────────────────────────────────────
def configure_shelf() -> bool:
"""Run one complete shelf cycle. Returns True if user wants another shelf."""
banner()
cfg = ShelfConfig()
# 1 — Detect device
device = detect_serial_device()
if not device:
error("Could not detect a serial device. Returning to main menu.")
time.sleep(2)
return True
cfg.device = device
# 2 — Open serial port & wake IOM console
ser = open_serial_connection(device)
if not ser:
error("Could not open serial port. Returning to main menu.")
time.sleep(2)
return True
# Password needed before login
print()
cfg.password = prompt_password()
# Log in to the IOM console (required before any Redfish curl calls)
if not _login_serial_console(ser, cfg.password):
error("Could not log in to IOM console. Returning to main menu.")
close_serial_connection(ser, device)
time.sleep(2)
return True
# 3 — Fetch & display current settings
fetch_current_config(cfg, ser)
# 4 — Ask user: change or leave alone?
apply_changes = collect_network_config(cfg)
# 5 — Apply if requested
changed = False
if apply_changes:
print()
rule("Ready to Apply")
info("Redfish PATCH requests will be sent via curl on the IOM console.")
print()
if prompt_yn("Apply configuration now?", default=True):
apply_configuration(cfg, ser)
changed = True
else:
warn("Configuration skipped — no changes were made.")
# Summary
print_summary(cfg, changed)
# Disconnect
close_serial_connection(ser, device)
print()
return prompt_yn("Configure another ES24N shelf?", default=False)