Pull Shelf info upon serial connect

This commit is contained in:
2026-03-04 07:59:44 -05:00
parent 4b3c7abfda
commit a6ba8ebe4e

View File

@@ -300,7 +300,7 @@ class SerialPort:
# ── Step 1: Detect serial device ────────────────────────────────────────────── # ── Step 1: Detect serial device ──────────────────────────────────────────────
def detect_serial_device() -> Optional[str]: def detect_serial_device() -> Optional[str]:
rule("Step 1 of 6 -- Serial Cable & Device Detection") rule("Step 1 of 5 -- Serial Cable & Device Detection")
print(" Connect the serial cable from the ES24N IOM1 port") print(" Connect the serial cable from the ES24N IOM1 port")
print(" to the active F-Series controller USB port.") print(" to the active F-Series controller USB port.")
@@ -376,7 +376,7 @@ def _fix_permissions(device: str):
# ── Step 2: Open serial connection & wake IOM console ───────────────────────── # ── Step 2: Open serial connection & wake IOM console ─────────────────────────
def open_serial_connection(device: str) -> Optional[SerialPort]: def open_serial_connection(device: str) -> Optional[SerialPort]:
rule("Step 2 of 6 -- Opening Serial Connection") rule("Step 2 of 5 -- Opening Serial Connection")
info(f"Opening {device} at 115200 baud (8N1)...") info(f"Opening {device} at 115200 baud (8N1)...")
ser = SerialPort(device, baudrate=115200, timeout=5.0) ser = SerialPort(device, baudrate=115200, timeout=5.0)
@@ -416,13 +416,127 @@ def open_serial_connection(device: str) -> Optional[SerialPort]:
return ser return ser
# ── Step 3: Collect network configuration ───────────────────────────────────── # ── Redfish helpers (GET and PATCH) ──────────────────────────────────────────
def collect_network_config(cfg: ShelfConfig): def _redfish_request(password: str, method: str, path: str,
rule("Step 3 of 6 -- IOM Network Configuration") payload: Optional[dict] = None) -> tuple:
"""
Issue a Redfish HTTP request over the loopback (127.0.0.1).
Returns (success: bool, data: dict|str).
"""
url = f"https://127.0.0.1{path}"
credentials = b64encode(f"Admin:{password}".encode()).decode()
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
print(" How should the IOMs be configured?") body = json.dumps(payload).encode("utf-8") if payload else None
print(f" {_c(C.BOLD, '1')} Static IP addresses") headers = {"Authorization": f"Basic {credentials}"}
print(f" {_c(C.BOLD, '2')} DHCP") if body:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=body, method=method, headers=headers)
try:
with urllib.request.urlopen(req, context=ctx, timeout=10) as resp:
raw = resp.read().decode("utf-8", errors="replace")
try:
return True, json.loads(raw) if raw.strip() else {}
except json.JSONDecodeError:
return True, {}
except urllib.error.HTTPError as e:
raw = e.read().decode("utf-8", errors="replace")
try:
msg = json.loads(raw).get("error", {}).get("message", raw)
except json.JSONDecodeError:
msg = raw
return False, f"HTTP {e.code}: {msg[:120]}"
except OSError as e:
return False, f"Connection error: {e}"
# ── 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 = []
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"), "--", "--", "--", "--"])
draw_table(
["IOM", "Mode", "Origin", "IP Address", "Gateway", "Subnet Mask"],
rows,
[5, 10, 8, 16, 16, 16],
)
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() print()
while True: while True:
@@ -431,20 +545,33 @@ def collect_network_config(cfg: ShelfConfig):
break break
warn("Please enter 1 or 2.") warn("Please enter 1 or 2.")
use_dhcp = (choice == "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() print()
cfg.password = prompt_password() 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: if use_dhcp:
cfg.iom1 = IOMConfig("IOM1", dhcp=True) cfg.iom1 = IOMConfig("IOM1", dhcp=True)
cfg.iom2 = IOMConfig("IOM2", dhcp=True) cfg.iom2 = IOMConfig("IOM2", dhcp=True)
print()
ok("Both IOMs will be set to DHCP.") ok("Both IOMs will be set to DHCP.")
return return True
# Static — IOM1 # Static — IOM1
print()
info(f"Static network details for {_c(C.BOLD, 'IOM1')}:") info(f"Static network details for {_c(C.BOLD, 'IOM1')}:")
iom1_ip = prompt_ip(" IOM1 IP address ") iom1_ip = prompt_ip(" IOM1 IP address ")
iom1_gw = prompt_ip(" IOM1 Gateway ") iom1_gw = prompt_ip(" IOM1 Gateway ")
@@ -464,84 +591,49 @@ def collect_network_config(cfg: ShelfConfig):
iom2_nm = prompt_ip(" IOM2 Subnet Mask") iom2_nm = prompt_ip(" IOM2 Subnet Mask")
cfg.iom2 = IOMConfig("IOM2", dhcp=False, ip=iom2_ip, gateway=iom2_gw, netmask=iom2_nm) cfg.iom2 = IOMConfig("IOM2", dhcp=False, ip=iom2_ip, gateway=iom2_gw, netmask=iom2_nm)
return True
# ── Step 4: Apply configuration via Redfish ────────────────────────────────── # ── Step 5a: Apply configuration via Redfish ──────────────────────────────────
def apply_configuration(cfg: ShelfConfig) -> bool: def apply_configuration(cfg: ShelfConfig) -> bool:
rule("Step 4 of 6 -- Applying Configuration via Redfish API") 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 over serial loopback (127.0.0.1)...")
print() print()
results = [] results = []
all_ok = True all_ok = True
for iom_cfg in [cfg.iom1, cfg.iom2]: for iom_cfg in [cfg.iom1, cfg.iom2]:
success, message = _patch_iom(cfg.password, iom_cfg) if iom_cfg.dhcp:
results.append([iom_cfg.iom, payload = {"DHCPv4": {"DHCPEnabled": True}}
f"{_c(C.GRN, 'OK')}" if success else f"{_c(C.RED, 'FAIL')}", else:
message]) payload = {
if not success: "DHCPv4": {"DHCPEnabled": False},
"IPv4StaticAddresses": [{
"Address": iom_cfg.ip,
"Gateway": iom_cfg.gateway,
"SubnetMask": iom_cfg.netmask,
}],
}
path = f"/redfish/v1/Managers/{iom_cfg.iom}/EthernetInterfaces/1"
success, data = _redfish_request(cfg.password, "PATCH", path, payload)
if success:
mode = "DHCP" if iom_cfg.dhcp else f"Static {iom_cfg.ip}"
results.append([iom_cfg.iom, _c(C.GRN, "OK"), f"Configured: {mode}"])
else:
results.append([iom_cfg.iom, _c(C.RED, "FAIL"), str(data)[:60]])
all_ok = False all_ok = False
draw_table(["IOM", "Result", "Detail"], results, [6, 8, 44]) draw_table(["IOM", "Result", "Detail"], results, [6, 8, 50])
print() print()
return all_ok return all_ok
def _patch_iom(password: str, iom: IOMConfig) -> tuple: # ── Step 5b: Print applied-settings summary ───────────────────────────────────
url = f"https://127.0.0.1/redfish/v1/Managers/{iom.iom}/EthernetInterfaces/1" def print_summary(cfg: ShelfConfig, changed: bool):
rule("Summary")
if iom.dhcp:
payload = {"DHCPv4": {"DHCPEnabled": True}}
else:
payload = {
"DHCPv4": {"DHCPEnabled": False},
"IPv4StaticAddresses": [{
"Address": iom.ip,
"Gateway": iom.gateway,
"SubnetMask": iom.netmask,
}],
}
data = json.dumps(payload).encode("utf-8")
credentials = b64encode(f"Admin:{password}".encode()).decode()
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
req = urllib.request.Request(
url,
data=data,
method="PATCH",
headers={
"Content-Type": "application/json",
"Authorization": f"Basic {credentials}",
},
)
try:
with urllib.request.urlopen(req, context=ctx, timeout=10) as resp:
if resp.status in (200, 204):
mode = "DHCP" if iom.dhcp else f"static {iom.ip}"
return True, f"Configured: {mode}"
body = resp.read().decode("utf-8", errors="replace")
return False, f"HTTP {resp.status}: {body[:80]}"
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", errors="replace")
try:
msg = json.loads(body).get("error", {}).get("message", body)
except json.JSONDecodeError:
msg = body
return False, f"HTTP {e.code}: {msg[:80]}"
except OSError as e:
return False, f"Connection error: {e}"
# ── Step 5: Print configuration summary ───────────────────────────────────────
def print_summary(cfg: ShelfConfig):
rule("Step 5 of 6 -- Configuration Summary")
def val(iom: IOMConfig, field: str) -> str: def val(iom: IOMConfig, field: str) -> str:
dhcp_map = {"mode": "DHCP", "ip": "-- (DHCP)", "gateway": "-- (DHCP)", "netmask": "-- (DHCP)"} dhcp_map = {"mode": "DHCP", "ip": "-- (DHCP)", "gateway": "-- (DHCP)", "netmask": "-- (DHCP)"}
@@ -556,39 +648,39 @@ def print_summary(cfg: ShelfConfig):
["Gateway", val(cfg.iom1, "gateway"), val(cfg.iom2, "gateway")], ["Gateway", val(cfg.iom1, "gateway"), val(cfg.iom2, "gateway")],
["Subnet Mask", val(cfg.iom1, "netmask"), val(cfg.iom2, "netmask")], ["Subnet Mask", val(cfg.iom1, "netmask"), val(cfg.iom2, "netmask")],
["Serial Port", cfg.device, cfg.device], ["Serial Port", cfg.device, cfg.device],
["Changes", "Yes" if changed else "None", ""],
], ],
[12, 22, 22], [12, 22, 22],
) )
print() if changed:
draw_box([ print()
f"{_c(C.YEL, 'IMPORTANT -- Per the ES24N Service Guide:')}", 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.", "Remove the serial cable ONLY after verifying each",
"", "expander appears in TrueNAS with matching drives.",
"TrueNAS > System Settings > Enclosure >", "",
"NVMe-oF Expansion Shelves", "TrueNAS > System Settings > Enclosure >",
], colour=C.YEL) "NVMe-oF Expansion Shelves",
], colour=C.YEL)
print() print()
# ── Step 6: Close serial connection ─────────────────────────────────────────── # ── Disconnect ────────────────────────────────────────────────────────────────
def close_serial_connection(ser: SerialPort, device: str): def close_serial_connection(ser: SerialPort, device: str):
rule("Step 6 of 6 -- Close Serial Connection")
if ser and ser.is_open: if ser and ser.is_open:
ser.close() ser.close()
ok(f"Serial port {device} closed.") ok(f"Serial port {device} closed.")
print() print()
prompt("Disconnect the serial cable, then press Enter to continue") prompt("Disconnect the serial cable, then press Enter to continue")
ok("Serial cable disconnected. Shelf configuration complete.") ok("Serial cable disconnected. Shelf complete.")
# ── Full shelf configuration cycle ──────────────────────────────────────────── # ── Full shelf configuration cycle ────────────────────────────────────────────
def configure_shelf() -> bool: def configure_shelf() -> bool:
"""Run one complete shelf cycle. Returns True if user wants another.""" """Run one complete shelf cycle. Returns True if user wants another shelf."""
banner() banner()
cfg = ShelfConfig() cfg = ShelfConfig()
@@ -600,32 +692,40 @@ def configure_shelf() -> bool:
return True return True
cfg.device = device cfg.device = device
# 2 — Open serial port # 2 — Open serial port & wake IOM console
ser = open_serial_connection(device) ser = open_serial_connection(device)
if not ser: if not ser:
error("Could not open serial port. Returning to main menu.") error("Could not open serial port. Returning to main menu.")
time.sleep(2) time.sleep(2)
return True return True
# 3 — Collect settings # Password needed before any Redfish calls
collect_network_config(cfg)
# 4 — Confirm & apply
print()
rule("Ready to Apply")
info("Redfish PATCH requests will be sent to each IOM via 127.0.0.1")
info("using the active serial session as the communication path.")
print() print()
cfg.password = prompt_password()
if prompt_yn("Apply configuration now?", default=True): # 3 — Fetch & display current settings
apply_configuration(cfg) fetch_current_config(cfg)
else:
warn("Configuration skipped — no changes were made.")
# 5Summary & reminder # 4Ask user: change or leave alone?
print_summary(cfg) apply_changes = collect_network_config(cfg)
# 6Close serial port # 5Apply 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) close_serial_connection(ser, device)
print() print()
@@ -670,4 +770,4 @@ if __name__ == "__main__":
except KeyboardInterrupt: except KeyboardInterrupt:
print() print()
warn("Interrupted. Exiting.") warn("Interrupted. Exiting.")
sys.exit(0) sys.exit(0)