Update sys.path reference in es24n_conf.py and all documentation to reflect the new folder name. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
460 lines
16 KiB
Python
460 lines
16 KiB
Python
"""
|
|
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).
|
|
"""
|
|
|
|
import glob
|
|
import os
|
|
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,
|
|
banner, rule, draw_table, draw_box,
|
|
info, ok, warn, error,
|
|
prompt, prompt_ip, prompt_yn, prompt_password,
|
|
)
|
|
|
|
|
|
# ── 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 = 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; the Redfish API operates independently.")
|
|
else:
|
|
warn("No response received from IOM console.")
|
|
warn("The Redfish API may still be reachable. Continuing...")
|
|
|
|
print()
|
|
return ser
|
|
|
|
|
|
# ── Step 3: Fetch & display current IOM network settings ─────────────────────
|
|
def fetch_current_config(cfg: ShelfConfig) -> bool:
|
|
"""
|
|
Query Redfish for the current network config of both IOMs.
|
|
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...")
|
|
print()
|
|
|
|
any_ok = False
|
|
rows = []
|
|
errors = []
|
|
|
|
for iom in ("IOM1", "IOM2"):
|
|
path = f"/redfish/v1/Managers/{iom}/EthernetInterfaces/1"
|
|
ok_flag, data = _redfish_request(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) -> bool:
|
|
rule("Step 5 of 5 -- Applying Configuration via Redfish API")
|
|
|
|
info("Sending Redfish PATCH requests over serial loopback (127.0.0.1)...")
|
|
print()
|
|
|
|
results = []
|
|
all_ok = True
|
|
for iom_cfg in [cfg.iom1, cfg.iom2]:
|
|
success, detail = _apply_iom(cfg.password, iom_cfg)
|
|
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) -> tuple:
|
|
"""
|
|
Apply network config to a single IOM.
|
|
|
|
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 = _redfish_request(
|
|
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 = _redfish_request(
|
|
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 = _redfish_request(
|
|
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 any Redfish calls
|
|
print()
|
|
cfg.password = prompt_password()
|
|
|
|
# 3 — Fetch & display current settings
|
|
fetch_current_config(cfg)
|
|
|
|
# 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 to each IOM via 127.0.0.1")
|
|
print()
|
|
if prompt_yn("Apply configuration now?", default=True):
|
|
apply_configuration(cfg)
|
|
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)
|