diff --git a/es24n_conf.py b/es24n_conf.py index eba9eba..ec22dd3 100644 --- a/es24n_conf.py +++ b/es24n_conf.py @@ -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) - # 5 — Summary & reminder - print_summary(cfg) + # 4 — Ask user: change or leave alone? + apply_changes = collect_network_config(cfg) - # 6 — Close serial port + # 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() @@ -670,4 +770,4 @@ if __name__ == "__main__": except KeyboardInterrupt: print() warn("Interrupted. Exiting.") - sys.exit(0) + sys.exit(0) \ No newline at end of file