diff --git a/modules/serial_port.py b/modules/serial_port.py index f3b071d..57db5c5 100644 --- a/modules/serial_port.py +++ b/modules/serial_port.py @@ -85,13 +85,15 @@ class SerialPort: except OSError: return b"" - def read_until_quiet(self, quiet_period: float = 0.5) -> str: + def read_until_quiet(self, quiet_period: float = 0.5, + timeout: Optional[float] = None) -> str: """ Read until no new bytes arrive for `quiet_period` seconds, - or until `self.timeout` total seconds have elapsed. + or until `timeout` (default: self.timeout) seconds have elapsed. + Pass a longer timeout for operations like curl that take more time. """ output = b"" - deadline = time.monotonic() + self.timeout + deadline = time.monotonic() + (timeout if timeout is not None else self.timeout) last_rx = time.monotonic() while True: diff --git a/modules/workflow_serial.py b/modules/workflow_serial.py index 45af02f..534fd82 100644 --- a/modules/workflow_serial.py +++ b/modules/workflow_serial.py @@ -1,17 +1,23 @@ """ workflow_serial.py — Serial-based ES24N IOM network configuration workflow. -Connects via USB serial cable, wakes the IOM console, and configures -network settings through the Redfish API over the serial loopback (127.0.0.1). +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 redfish import _redfish_request from serial_port import SerialPort from ui import ( _c, C, @@ -20,6 +26,102 @@ from ui import ( 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]: @@ -115,7 +217,7 @@ def open_serial_connection(device: str) -> Optional[SerialPort]: ser.send_line("", delay=0.5) ser.send_line("", delay=0.5) - response = ser.read_until_quiet(quiet_period=0.5) + response = _ANSI_RE.sub("", ser.read_until_quiet(quiet_period=0.5)) print() if response.strip(): @@ -130,24 +232,25 @@ def open_serial_connection(device: str) -> Optional[SerialPort]: ok("IOM console is responsive.") else: warn("Unexpected response — the IOM may still be booting.") - warn("You can continue; the Redfish API operates independently.") + warn("You can continue; login will be attempted next.") else: warn("No response received from IOM console.") - warn("The Redfish API may still be reachable. Continuing...") + 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) -> bool: +def fetch_current_config(cfg: ShelfConfig, ser: SerialPort) -> bool: """ - Query Redfish for the current network config of both IOMs. + 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 for current network configuration...") + info("Querying Redfish API via serial console...") print() any_ok = False @@ -156,7 +259,7 @@ def fetch_current_config(cfg: ShelfConfig) -> bool: for iom in ("IOM1", "IOM2"): path = f"/redfish/v1/Managers/{iom}/EthernetInterfaces/1" - ok_flag, data = _redfish_request(cfg.password, "GET", path) + ok_flag, data = _serial_redfish_request(ser, cfg.password, "GET", path) if ok_flag and isinstance(data, dict): any_ok = True @@ -285,16 +388,16 @@ def collect_network_config(cfg: ShelfConfig) -> bool: # ── Step 5a: Apply configuration via Redfish ────────────────────────────────── -def apply_configuration(cfg: ShelfConfig) -> bool: +def apply_configuration(cfg: ShelfConfig, ser: SerialPort) -> bool: rule("Step 5 of 5 -- Applying Configuration via Redfish API") - info("Sending Redfish PATCH requests over serial loopback (127.0.0.1)...") + 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) + 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: @@ -305,9 +408,9 @@ def apply_configuration(cfg: ShelfConfig) -> bool: return all_ok -def _apply_iom(password: str, iom_cfg: IOMConfig) -> tuple: +def _apply_iom(password: str, iom_cfg: IOMConfig, ser: SerialPort) -> tuple: """ - Apply network config to a single IOM. + Apply network config to a single IOM via curl over the serial console. DHCP: single PATCH enabling DHCPv4. @@ -320,8 +423,8 @@ def _apply_iom(password: str, iom_cfg: IOMConfig) -> tuple: path = f"/redfish/v1/Managers/{iom_cfg.iom}/EthernetInterfaces/1" if iom_cfg.dhcp: - ok_flag, data = _redfish_request( - password, "PATCH", path, + ok_flag, data = _serial_redfish_request( + ser, password, "PATCH", path, {"DHCPv4": {"DHCPEnabled": True}}, ) if ok_flag: @@ -330,8 +433,8 @@ def _apply_iom(password: str, iom_cfg: IOMConfig) -> tuple: # 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 = _redfish_request( - password, "PATCH", path, + ok_flag, data = _serial_redfish_request( + ser, password, "PATCH", path, { "IPv4StaticAddresses": [{ "Address": iom_cfg.ip, @@ -348,8 +451,8 @@ def _apply_iom(password: str, iom_cfg: IOMConfig) -> tuple: # 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 = _redfish_request( - password, "PATCH", path, + ok_flag, data = _serial_redfish_request( + ser, password, "PATCH", path, {"DHCPv4": {"DHCPEnabled": False}}, ) if not ok_flag: @@ -426,12 +529,19 @@ def configure_shelf() -> bool: time.sleep(2) return True - # Password needed before any Redfish calls + # 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) + fetch_current_config(cfg, ser) # 4 — Ask user: change or leave alone? apply_changes = collect_network_config(cfg) @@ -441,10 +551,10 @@ def configure_shelf() -> bool: if apply_changes: print() rule("Ready to Apply") - info("Redfish PATCH requests will be sent to each IOM via 127.0.0.1") + 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) + apply_configuration(cfg, ser) changed = True else: warn("Configuration skipped — no changes were made.")