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>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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.")
|
||||
|
||||
Reference in New Issue
Block a user