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 ──────────────────────────────────────────────
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(" 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 ─────────────────────────
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)...")
ser = SerialPort(device, baudrate=115200, timeout=5.0)
@@ -416,13 +416,127 @@ def open_serial_connection(device: str) -> Optional[SerialPort]:
return ser
# ── Step 3: Collect network configuration ─────────────────────────────────────
def collect_network_config(cfg: ShelfConfig):
rule("Step 3 of 6 -- IOM Network Configuration")
# ── Redfish helpers (GET and PATCH) ──────────────────────────────────────────
def _redfish_request(password: str, method: str, path: str,
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?")
print(f" {_c(C.BOLD, '1')} Static IP addresses")
print(f" {_c(C.BOLD, '2')} DHCP")
body = json.dumps(payload).encode("utf-8") if payload else None
headers = {"Authorization": f"Basic {credentials}"}
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()
while True:
@@ -431,20 +545,33 @@ def collect_network_config(cfg: ShelfConfig):
break
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()
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:
cfg.iom1 = IOMConfig("IOM1", dhcp=True)
cfg.iom2 = IOMConfig("IOM2", dhcp=True)
print()
ok("Both IOMs will be set to DHCP.")
return
return True
# Static — IOM1
print()
info(f"Static network details for {_c(C.BOLD, 'IOM1')}:")
iom1_ip = prompt_ip(" IOM1 IP address ")
iom1_gw = prompt_ip(" IOM1 Gateway ")
@@ -464,84 +591,49 @@ def collect_network_config(cfg: ShelfConfig):
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 4: Apply configuration via Redfish ──────────────────────────────────
# ── Step 5a: Apply configuration via Redfish ──────────────────────────────────
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)...")
print()
results = []
all_ok = True
results = []
all_ok = True
for iom_cfg in [cfg.iom1, cfg.iom2]:
success, message = _patch_iom(cfg.password, iom_cfg)
results.append([iom_cfg.iom,
f"{_c(C.GRN, 'OK')}" if success else f"{_c(C.RED, 'FAIL')}",
message])
if not success:
if iom_cfg.dhcp:
payload = {"DHCPv4": {"DHCPEnabled": True}}
else:
payload = {
"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
draw_table(["IOM", "Result", "Detail"], results, [6, 8, 44])
draw_table(["IOM", "Result", "Detail"], results, [6, 8, 50])
print()
return all_ok
def _patch_iom(password: str, iom: IOMConfig) -> tuple:
url = f"https://127.0.0.1/redfish/v1/Managers/{iom.iom}/EthernetInterfaces/1"
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")
# ── 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)"}
@@ -556,39 +648,39 @@ def print_summary(cfg: ShelfConfig):
["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],
)
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)
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()
# ── Step 6: Close serial connection ───────────────────────────────────────────
# ── Disconnect ────────────────────────────────────────────────────────────────
def close_serial_connection(ser: SerialPort, device: str):
rule("Step 6 of 6 -- Close Serial Connection")
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 configuration complete.")
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."""
"""Run one complete shelf cycle. Returns True if user wants another shelf."""
banner()
cfg = ShelfConfig()
@@ -600,32 +692,40 @@ def configure_shelf() -> bool:
return True
cfg.device = device
# 2 — Open serial port
# 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
# 3 — Collect settings
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.")
# Password needed before any Redfish calls
print()
cfg.password = prompt_password()
if prompt_yn("Apply configuration now?", default=True):
apply_configuration(cfg)
else:
warn("Configuration skipped — no changes were made.")
# 3 — Fetch & display current settings
fetch_current_config(cfg)
# 5Summary & reminder
print_summary(cfg)
# 4Ask user: change or leave alone?
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)
print()
@@ -670,4 +770,4 @@ if __name__ == "__main__":
except KeyboardInterrupt:
print()
warn("Interrupted. Exiting.")
sys.exit(0)
sys.exit(0)