Split monolithic script into focused modules
Refactored the single 1200-line es24n_conf.py into six modules plus a slim entry point, in preparation for the upcoming network-based config workflow. Each file has a clear, single responsibility: ui.py — ANSI colours, display primitives, input prompts serial_port.py — SerialPort class (termios/fcntl/select) models.py — IOMConfig and ShelfConfig dataclasses redfish.py — Redfish API client (shared by all workflows) workflow_serial.py — Serial-based IOM network configuration workflow workflow_firmware.py — IOM and Fabric Card firmware update workflow es24n_conf.py — Entry point and main menu only No functional changes. All imports verified. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1167
es24n_conf.py
1167
es24n_conf.py
File diff suppressed because it is too large
Load Diff
23
models.py
Normal file
23
models.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
models.py — Shared data classes for ES24N IOM configuration.
|
||||
Used by both the serial and network configuration workflows.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class IOMConfig:
|
||||
iom: str
|
||||
dhcp: bool = True
|
||||
ip: str = ""
|
||||
gateway: str = ""
|
||||
netmask: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ShelfConfig:
|
||||
device: str = ""
|
||||
password: str = ""
|
||||
iom1: IOMConfig = field(default_factory=lambda: IOMConfig("IOM1"))
|
||||
iom2: IOMConfig = field(default_factory=lambda: IOMConfig("IOM2"))
|
||||
230
redfish.py
Normal file
230
redfish.py
Normal file
@@ -0,0 +1,230 @@
|
||||
"""
|
||||
redfish.py — Redfish API client functions shared across all ES24N workflows.
|
||||
Handles GET/PATCH requests, firmware upload, task polling, and version queries.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import ssl
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from base64 import b64encode
|
||||
from typing import Optional
|
||||
|
||||
from ui import _c, C, info, draw_table
|
||||
|
||||
|
||||
def _redfish_request(password: str, method: str, path: str,
|
||||
payload: Optional[dict] = None,
|
||||
host: str = "127.0.0.1") -> tuple:
|
||||
"""
|
||||
Issue a Redfish HTTP request. host defaults to the serial loopback (127.0.0.1)
|
||||
but can be set to an IOM's network IP for firmware update operations.
|
||||
Returns (success: bool, data: dict|str).
|
||||
"""
|
||||
url = f"https://{host}{path}"
|
||||
username = "root" if host == "127.0.0.1" else "Admin"
|
||||
credentials = b64encode(f"{username}:{password}".encode()).decode()
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
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}"
|
||||
|
||||
|
||||
def _redfish_upload_firmware(password: str, host: str, fw_path: str) -> tuple:
|
||||
"""
|
||||
Upload a firmware file to /redfish/v1/UpdateService using multipart/form-data.
|
||||
Equivalent to: curl -k -u Admin:<PW> https://<IP>/redfish/v1/UpdateService
|
||||
-X POST -F "software=@<file>"
|
||||
"""
|
||||
try:
|
||||
with open(fw_path, "rb") as f:
|
||||
file_data = f.read()
|
||||
except OSError as e:
|
||||
return False, f"Cannot read file: {e}"
|
||||
|
||||
filename = os.path.basename(fw_path)
|
||||
boundary = f"FormBoundary{int(time.time() * 1000)}"
|
||||
|
||||
body = (
|
||||
f"--{boundary}\r\n"
|
||||
f'Content-Disposition: form-data; name="software"; filename="{filename}"\r\n'
|
||||
"Content-Type: application/octet-stream\r\n"
|
||||
"\r\n"
|
||||
).encode() + file_data + f"\r\n--{boundary}--\r\n".encode()
|
||||
|
||||
url = f"https://{host}/redfish/v1/UpdateService"
|
||||
credentials = b64encode(f"Admin:{password}".encode()).decode() # always network
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
req = urllib.request.Request(
|
||||
url, data=body, method="POST",
|
||||
headers={
|
||||
"Authorization": f"Basic {credentials}",
|
||||
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, context=ctx, timeout=120) 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}"
|
||||
|
||||
|
||||
def _redfish_trigger_update(password: str, host: str, target: str) -> tuple:
|
||||
"""
|
||||
Trigger a Redfish SimpleUpdate for the given target resource path.
|
||||
target: e.g. "/redfish/v1/Managers/IOM1"
|
||||
or "/redfish/v1/Chassis/IOM1/NetworkAdapters/1"
|
||||
"""
|
||||
return _redfish_request(
|
||||
password, "POST",
|
||||
"/redfish/v1/UpdateService/Actions/SimpleUpdate",
|
||||
payload={
|
||||
"ImageURI": "/redfish/v1/UpdateService/software",
|
||||
"Targets": [target],
|
||||
},
|
||||
host=host,
|
||||
)
|
||||
|
||||
|
||||
def _redfish_poll_tasks(password: str, host: str, timeout: int = 600) -> tuple:
|
||||
"""
|
||||
Poll /redfish/v1/TaskService/Tasks/ until all tasks reach a terminal state
|
||||
or timeout is exceeded. Returns (success: bool, message: str).
|
||||
"""
|
||||
TERMINAL = {"Completed", "Killed", "Exception"}
|
||||
deadline = time.monotonic() + timeout
|
||||
elapsed = 0
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
ok_flag, data = _redfish_request(
|
||||
password, "GET", "/redfish/v1/TaskService/Tasks/", host=host,
|
||||
)
|
||||
if not ok_flag:
|
||||
return False, f"Task service error: {data}"
|
||||
|
||||
members = data.get("Members", [])
|
||||
if not members:
|
||||
return True, "No pending tasks."
|
||||
|
||||
running = []
|
||||
for member in members:
|
||||
state = member.get("TaskState")
|
||||
if state is None:
|
||||
task_path = member.get("@odata.id", "")
|
||||
if task_path:
|
||||
t_ok, t_data = _redfish_request(
|
||||
password, "GET", task_path, host=host,
|
||||
)
|
||||
state = (t_data.get("TaskState")
|
||||
if t_ok and isinstance(t_data, dict) else "Running")
|
||||
else:
|
||||
state = "Running"
|
||||
if state not in TERMINAL:
|
||||
running.append(state)
|
||||
|
||||
if not running:
|
||||
return True, "All tasks completed."
|
||||
|
||||
info(f" Tasks running ({', '.join(running)})... [{elapsed}s elapsed]")
|
||||
time.sleep(10)
|
||||
elapsed += 10
|
||||
|
||||
return False, f"Timeout after {timeout}s waiting for tasks."
|
||||
|
||||
|
||||
def _redfish_restart_iom(password: str, host: str, iom: str) -> tuple:
|
||||
return _redfish_request(
|
||||
password, "POST",
|
||||
f"/redfish/v1/Managers/{iom}/Actions/Manager.Reset",
|
||||
payload={"ResetType": "GracefulRestart"},
|
||||
host=host,
|
||||
)
|
||||
|
||||
|
||||
def _redfish_reset_fabric(password: str, host: str, iom: str) -> tuple:
|
||||
return _redfish_request(
|
||||
password, "POST",
|
||||
f"/redfish/v1/Chassis/{iom}/NetworkAdapters/1/Actions/NetworkAdapter.Reset",
|
||||
payload={"ResetType": "GracefulRestart"},
|
||||
host=host,
|
||||
)
|
||||
|
||||
|
||||
def _get_iom_fw_version(password: str, host: str, iom: str) -> str:
|
||||
ok_flag, data = _redfish_request(
|
||||
password, "GET", f"/redfish/v1/Managers/{iom}", host=host,
|
||||
)
|
||||
if ok_flag and isinstance(data, dict):
|
||||
return data.get("FirmwareVersion", "Unknown")
|
||||
return _c(C.RED, "Unreachable")
|
||||
|
||||
|
||||
def _get_fabric_fw_version(password: str, host: str, iom: str) -> str:
|
||||
ok_flag, data = _redfish_request(
|
||||
password, "GET",
|
||||
f"/redfish/v1/Chassis/{iom}/NetworkAdapters/1",
|
||||
host=host,
|
||||
)
|
||||
if ok_flag and isinstance(data, dict):
|
||||
version = (data.get("Oem", {})
|
||||
.get("Version", {})
|
||||
.get("ActiveFirmwareVersion"))
|
||||
return version or "Unknown"
|
||||
return _c(C.RED, "Unreachable")
|
||||
|
||||
|
||||
def _show_fw_versions(password: str, ioms: list):
|
||||
info("Querying firmware versions...")
|
||||
rows = []
|
||||
for iom, ip in ioms:
|
||||
iom_ver = _get_iom_fw_version(password, ip, iom)
|
||||
fabric_ver = _get_fabric_fw_version(password, ip, iom)
|
||||
rows.append([iom, ip, iom_ver, fabric_ver])
|
||||
print()
|
||||
draw_table(
|
||||
["IOM", "IP Address", "IOM Firmware", "Fabric Firmware"],
|
||||
rows,
|
||||
[5, 16, 32, 20],
|
||||
)
|
||||
print()
|
||||
124
serial_port.py
Normal file
124
serial_port.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""
|
||||
serial_port.py — Minimal 8N1 serial port using only the Python standard library.
|
||||
Replaces pyserial for TrueNAS environments where pip is unavailable.
|
||||
"""
|
||||
|
||||
import fcntl
|
||||
import os
|
||||
import select
|
||||
import termios
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class SerialPort:
|
||||
BAUD_MAP = {
|
||||
9600: termios.B9600,
|
||||
19200: termios.B19200,
|
||||
38400: termios.B38400,
|
||||
57600: termios.B57600,
|
||||
115200: termios.B115200,
|
||||
}
|
||||
|
||||
def __init__(self, port: str, baudrate: int = 115200, timeout: float = 5.0):
|
||||
self.port = port
|
||||
self.baudrate = baudrate
|
||||
self.timeout = timeout
|
||||
self._fd: Optional[int] = None
|
||||
self._saved_attrs = None
|
||||
|
||||
# ── Open / close ──────────────────────────────────────────────────────────
|
||||
def open(self):
|
||||
try:
|
||||
self._fd = os.open(self.port, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
|
||||
except OSError as e:
|
||||
raise OSError(f"Cannot open {self.port}: {e}") from e
|
||||
|
||||
# Switch back to blocking mode now that O_NOCTTY is set
|
||||
flags = fcntl.fcntl(self._fd, fcntl.F_GETFL)
|
||||
fcntl.fcntl(self._fd, fcntl.F_SETFL, flags & ~os.O_NONBLOCK)
|
||||
|
||||
self._saved_attrs = termios.tcgetattr(self._fd)
|
||||
|
||||
# Raw 8N1: iflag, oflag, cflag, lflag, ispeed, ospeed, cc
|
||||
attrs = list(termios.tcgetattr(self._fd))
|
||||
baud = self.BAUD_MAP.get(self.baudrate, termios.B115200)
|
||||
|
||||
attrs[0] = termios.IGNBRK # iflag
|
||||
attrs[1] = 0 # oflag
|
||||
attrs[2] = termios.CS8 | termios.CREAD | termios.CLOCAL # cflag
|
||||
attrs[3] = 0 # lflag (raw)
|
||||
attrs[4] = baud # ispeed
|
||||
attrs[5] = baud # ospeed
|
||||
attrs[6][termios.VMIN] = 0
|
||||
attrs[6][termios.VTIME] = min(int(self.timeout * 10), 255)
|
||||
|
||||
termios.tcsetattr(self._fd, termios.TCSANOW, attrs)
|
||||
termios.tcflush(self._fd, termios.TCIOFLUSH)
|
||||
|
||||
def close(self):
|
||||
if self._fd is not None:
|
||||
try:
|
||||
if self._saved_attrs:
|
||||
termios.tcsetattr(self._fd, termios.TCSADRAIN, self._saved_attrs)
|
||||
os.close(self._fd)
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
self._fd = None
|
||||
|
||||
@property
|
||||
def is_open(self) -> bool:
|
||||
return self._fd is not None
|
||||
|
||||
# ── Read / write ──────────────────────────────────────────────────────────
|
||||
def write(self, data: bytes):
|
||||
if self._fd is None:
|
||||
raise OSError("Port is not open")
|
||||
os.write(self._fd, data)
|
||||
|
||||
def read_chunk(self, size: int = 4096) -> bytes:
|
||||
if self._fd is None:
|
||||
raise OSError("Port is not open")
|
||||
try:
|
||||
return os.read(self._fd, size)
|
||||
except OSError:
|
||||
return b""
|
||||
|
||||
def read_until_quiet(self, quiet_period: float = 0.5) -> str:
|
||||
"""
|
||||
Read until no new bytes arrive for `quiet_period` seconds,
|
||||
or until `self.timeout` total seconds have elapsed.
|
||||
"""
|
||||
output = b""
|
||||
deadline = time.monotonic() + self.timeout
|
||||
last_rx = time.monotonic()
|
||||
|
||||
while True:
|
||||
now = time.monotonic()
|
||||
if now >= deadline:
|
||||
break
|
||||
if output and (now - last_rx) >= quiet_period:
|
||||
break
|
||||
|
||||
wait = min(deadline - now, quiet_period)
|
||||
ready, _, _ = select.select([self._fd], [], [], wait)
|
||||
if ready:
|
||||
chunk = self.read_chunk()
|
||||
if chunk:
|
||||
output += chunk
|
||||
last_rx = time.monotonic()
|
||||
|
||||
return output.decode("utf-8", errors="replace")
|
||||
|
||||
def send_line(self, cmd: str = "", delay: float = 0.3):
|
||||
self.write((cmd + "\r\n").encode("utf-8"))
|
||||
time.sleep(delay)
|
||||
|
||||
# ── Context manager ───────────────────────────────────────────────────────
|
||||
def __enter__(self):
|
||||
self.open()
|
||||
return self
|
||||
|
||||
def __exit__(self, *_):
|
||||
self.close()
|
||||
120
ui.py
Normal file
120
ui.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""
|
||||
ui.py — ANSI colour helpers, display primitives, and input prompts.
|
||||
Shared by all ES24N workflows.
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import sys
|
||||
|
||||
|
||||
# ── ANSI colour helpers ───────────────────────────────────────────────────────
|
||||
class C:
|
||||
RED = "\033[0;31m"
|
||||
GRN = "\033[0;32m"
|
||||
YEL = "\033[1;33m"
|
||||
CYN = "\033[0;36m"
|
||||
WHT = "\033[1;37m"
|
||||
DIM = "\033[2m"
|
||||
BOLD = "\033[1m"
|
||||
RESET = "\033[0m"
|
||||
CLEAR = "\033[2J\033[H"
|
||||
|
||||
|
||||
def _c(colour: str, text: str) -> str:
|
||||
return f"{colour}{text}{C.RESET}"
|
||||
|
||||
|
||||
def info(msg: str): print(f" {_c(C.CYN, 'i')} {msg}")
|
||||
def ok(msg: str): print(f" {_c(C.GRN, 'OK')} {msg}")
|
||||
def warn(msg: str): print(f" {_c(C.YEL, '!')} {msg}")
|
||||
def error(msg: str): print(f" {_c(C.RED, 'X')} {msg}")
|
||||
|
||||
|
||||
def banner():
|
||||
print(C.CLEAR, end="")
|
||||
w = 60
|
||||
print(_c(C.CYN, " +" + "-" * w + "+"))
|
||||
print(_c(C.CYN, " |") + _c(C.WHT + C.BOLD,
|
||||
" TrueNAS ES24N IOM Configuration Tool ") + _c(C.CYN, " |"))
|
||||
print(_c(C.CYN, " |") + _c(C.DIM,
|
||||
" Serial Config & Firmware Updates (stdlib only) ") + _c(C.CYN, " |"))
|
||||
print(_c(C.CYN, " +" + "-" * w + "+"))
|
||||
print()
|
||||
|
||||
|
||||
def rule(title: str = ""):
|
||||
width = 60
|
||||
if title:
|
||||
pad = max(0, width - len(title) - 2)
|
||||
left = pad // 2
|
||||
right = pad - left
|
||||
line = f"{'-' * left} {title} {'-' * right}"
|
||||
else:
|
||||
line = "-" * width
|
||||
print(f"\n {_c(C.YEL, line)}\n")
|
||||
|
||||
|
||||
def draw_table(headers: list, rows: list, col_widths: list):
|
||||
sep = " +-" + "-+-".join("-" * w for w in col_widths) + "-+"
|
||||
|
||||
def fmt_row(cells):
|
||||
return " | " + " | ".join(
|
||||
str(c).ljust(w) for c, w in zip(cells, col_widths)
|
||||
) + " |"
|
||||
|
||||
print(_c(C.DIM, sep))
|
||||
print(_c(C.BOLD, fmt_row(headers)))
|
||||
print(_c(C.DIM, sep))
|
||||
for row in rows:
|
||||
print(fmt_row(row))
|
||||
print(_c(C.DIM, sep))
|
||||
|
||||
|
||||
def draw_box(lines: list, colour: str = C.CYN):
|
||||
width = max(len(l) for l in lines) + 4
|
||||
print(f" {_c(colour, '+' + '-' * width + '+')}")
|
||||
for line in lines:
|
||||
pad = width - len(line) - 2
|
||||
print(f" {_c(colour, '|')} {line}{' ' * pad} {_c(colour, '|')}")
|
||||
print(f" {_c(colour, '+' + '-' * width + '+')}")
|
||||
|
||||
|
||||
# ── Input helpers ─────────────────────────────────────────────────────────────
|
||||
def prompt(label: str, default: str = "") -> str:
|
||||
display = f" {_c(C.CYN, label)}"
|
||||
if default:
|
||||
display += f" {_c(C.DIM, f'[{default}]')}"
|
||||
display += ": "
|
||||
|
||||
sys.stdout.write(display)
|
||||
sys.stdout.flush()
|
||||
val = sys.stdin.readline().strip()
|
||||
return val if val else default
|
||||
|
||||
|
||||
def prompt_ip(label: str) -> str:
|
||||
while True:
|
||||
val = prompt(label)
|
||||
try:
|
||||
ipaddress.IPv4Address(val)
|
||||
return val
|
||||
except ValueError:
|
||||
warn(f"'{val}' is not a valid IPv4 address — please try again.")
|
||||
|
||||
|
||||
def prompt_yn(label: str, default: bool = True) -> bool:
|
||||
hint = "Y/n" if default else "y/N"
|
||||
val = prompt(f"{label} [{hint}]").strip().lower()
|
||||
if not val:
|
||||
return default
|
||||
return val in ("y", "yes")
|
||||
|
||||
|
||||
def prompt_password() -> str:
|
||||
while True:
|
||||
val = prompt(
|
||||
"Admin password (BMC/chassis serial, e.g. MXE3000043CHA007)",
|
||||
)
|
||||
if val:
|
||||
return val
|
||||
warn("Password cannot be empty.")
|
||||
258
workflow_firmware.py
Normal file
258
workflow_firmware.py
Normal file
@@ -0,0 +1,258 @@
|
||||
"""
|
||||
workflow_firmware.py — IOM and Fabric Card firmware update workflow.
|
||||
Connects to each IOM via its network IP address using the Redfish API.
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
from redfish import (
|
||||
_redfish_upload_firmware,
|
||||
_redfish_trigger_update,
|
||||
_redfish_poll_tasks,
|
||||
_redfish_restart_iom,
|
||||
_redfish_reset_fabric,
|
||||
_show_fw_versions,
|
||||
)
|
||||
from ui import (
|
||||
_c, C,
|
||||
banner, rule, draw_box,
|
||||
info, ok, warn, error,
|
||||
prompt, prompt_ip, prompt_yn, prompt_password,
|
||||
)
|
||||
|
||||
|
||||
# ── Firmware file selection helper ────────────────────────────────────────────
|
||||
def _prompt_fw_file(label: str) -> str:
|
||||
"""
|
||||
Scan the current working directory for firmware files and let the user
|
||||
pick one by number, or enter a custom path as the last option.
|
||||
Files are sorted most-recently-modified first.
|
||||
"""
|
||||
cwd = os.getcwd()
|
||||
FW_EXTS = {".bin", ".img", ".fw", ".hex", ".zip", ".tar", ".tgz", ".gz"}
|
||||
|
||||
try:
|
||||
candidates = sorted(
|
||||
[f for f in os.listdir(cwd)
|
||||
if not f.startswith(".")
|
||||
and os.path.isfile(os.path.join(cwd, f))
|
||||
and os.path.splitext(f)[1].lower() in FW_EXTS],
|
||||
key=lambda f: os.path.getmtime(os.path.join(cwd, f)),
|
||||
reverse=True,
|
||||
)
|
||||
except OSError:
|
||||
candidates = []
|
||||
|
||||
print()
|
||||
if candidates:
|
||||
info(f"Firmware files found in {cwd}:")
|
||||
for i, fname in enumerate(candidates, 1):
|
||||
sz = os.path.getsize(os.path.join(cwd, fname))
|
||||
print(f" {_c(C.BOLD, str(i))} {fname} {_c(C.DIM, f'({sz // 1024} KB)')}")
|
||||
custom_idx = len(candidates) + 1
|
||||
print(f" {_c(C.BOLD, str(custom_idx))} Enter a custom file path")
|
||||
print()
|
||||
|
||||
while True:
|
||||
choice = prompt(f"Select {label} [1-{custom_idx}]")
|
||||
if choice.isdigit():
|
||||
idx = int(choice)
|
||||
if 1 <= idx <= len(candidates):
|
||||
path = os.path.join(cwd, candidates[idx - 1])
|
||||
sz = os.path.getsize(path)
|
||||
ok(f"Selected: {candidates[idx - 1]} ({sz // 1024} KB)")
|
||||
return path
|
||||
if idx == custom_idx:
|
||||
break
|
||||
warn(f"Please enter a number between 1 and {custom_idx}.")
|
||||
else:
|
||||
info(f"No firmware files found in {cwd}.")
|
||||
|
||||
# Manual path entry
|
||||
while True:
|
||||
path = prompt(f"Path to {label}")
|
||||
if os.path.isfile(path):
|
||||
sz = os.path.getsize(path)
|
||||
ok(f"File: {path} ({sz // 1024} KB)")
|
||||
return path
|
||||
warn(f"File not found: {path}")
|
||||
|
||||
|
||||
# ── Per-IOM update helpers ────────────────────────────────────────────────────
|
||||
def _update_iom_fw(password: str, ip: str, iom: str, fw_path: str) -> bool:
|
||||
"""Upload and apply IOM firmware for one IOM, then restart it."""
|
||||
sz = os.path.getsize(fw_path)
|
||||
info(f"Uploading IOM firmware ({sz // 1024} KB) to {iom} at {ip}...")
|
||||
ok_flag, data = _redfish_upload_firmware(password, ip, fw_path)
|
||||
if not ok_flag:
|
||||
error(f"Upload failed: {data}")
|
||||
return False
|
||||
ok("Firmware file uploaded.")
|
||||
|
||||
info(f"Triggering {iom} firmware update...")
|
||||
ok_flag, data = _redfish_trigger_update(
|
||||
password, ip, f"/redfish/v1/Managers/{iom}",
|
||||
)
|
||||
if not ok_flag:
|
||||
error(f"Update trigger failed: {data}")
|
||||
return False
|
||||
ok("Update triggered.")
|
||||
|
||||
info("Monitoring update progress (this may take several minutes)...")
|
||||
ok_flag, msg = _redfish_poll_tasks(password, ip)
|
||||
if not ok_flag:
|
||||
warn(f"Task monitoring ended: {msg}")
|
||||
else:
|
||||
ok(msg)
|
||||
|
||||
info(f"Restarting {iom}...")
|
||||
_redfish_restart_iom(password, ip, iom) # connection drop on restart is normal
|
||||
ok(f"{iom} restart initiated. Waiting 30s for reboot...")
|
||||
time.sleep(30)
|
||||
return True
|
||||
|
||||
|
||||
def _update_fabric_fw(password: str, ip: str, iom: str, fw_path: str) -> bool:
|
||||
"""
|
||||
Upload and apply Fabric Card firmware for one IOM.
|
||||
Per the service guide, the firmware file must be re-uploaded even if it was
|
||||
already uploaded during the IOM firmware step.
|
||||
After the update: restart fabric card, then restart IOM.
|
||||
"""
|
||||
sz = os.path.getsize(fw_path)
|
||||
info(f"Uploading Fabric Card firmware ({sz // 1024} KB) to {iom} at {ip}...")
|
||||
ok_flag, data = _redfish_upload_firmware(password, ip, fw_path)
|
||||
if not ok_flag:
|
||||
error(f"Upload failed: {data}")
|
||||
return False
|
||||
ok("Firmware file uploaded.")
|
||||
|
||||
info(f"Triggering {iom} Fabric Card firmware update...")
|
||||
ok_flag, data = _redfish_trigger_update(
|
||||
password, ip, f"/redfish/v1/Chassis/{iom}/NetworkAdapters/1",
|
||||
)
|
||||
if not ok_flag:
|
||||
error(f"Update trigger failed: {data}")
|
||||
return False
|
||||
ok("Update triggered.")
|
||||
|
||||
info("Monitoring update progress...")
|
||||
ok_flag, msg = _redfish_poll_tasks(password, ip)
|
||||
if not ok_flag:
|
||||
warn(f"Task monitoring ended: {msg}")
|
||||
else:
|
||||
ok(msg)
|
||||
|
||||
info(f"Restarting {iom} Fabric Card...")
|
||||
_redfish_reset_fabric(password, ip, iom)
|
||||
ok("Fabric Card restart initiated. Waiting 15s...")
|
||||
time.sleep(15)
|
||||
|
||||
info(f"Restarting {iom} after Fabric Card update...")
|
||||
_redfish_restart_iom(password, ip, iom)
|
||||
ok(f"{iom} restart initiated. Waiting 30s for reboot...")
|
||||
time.sleep(30)
|
||||
return True
|
||||
|
||||
|
||||
# ── Firmware Update Workflow ──────────────────────────────────────────────────
|
||||
def firmware_update_workflow():
|
||||
"""
|
||||
Standalone firmware update for IOM and Fabric Card firmware.
|
||||
Connects to each IOM via its network IP (not serial loopback) — uploading
|
||||
firmware over 115200-baud serial would be impractically slow.
|
||||
"""
|
||||
banner()
|
||||
rule("IOM & Fabric Card Firmware Update")
|
||||
info("This procedure connects to each IOM via its network IP address.")
|
||||
info("Ensure this system has network access to the IOM management interface.")
|
||||
print()
|
||||
|
||||
password = prompt_password()
|
||||
print()
|
||||
|
||||
print(" Which IOM(s) would you like to update?")
|
||||
print(f" {_c(C.BOLD, '1')} IOM1 only")
|
||||
print(f" {_c(C.BOLD, '2')} IOM2 only")
|
||||
print(f" {_c(C.BOLD, '3')} Both IOM1 and IOM2")
|
||||
print()
|
||||
|
||||
while True:
|
||||
iom_choice = prompt("Select option [1-3]")
|
||||
if iom_choice in ("1", "2", "3"):
|
||||
break
|
||||
warn("Please enter 1, 2, or 3.")
|
||||
print()
|
||||
|
||||
info("Enter the management IP address for each IOM to update.")
|
||||
iom1_ip = prompt_ip(" IOM1 IP address") if iom_choice in ("1", "3") else ""
|
||||
iom2_ip = prompt_ip(" IOM2 IP address") if iom_choice in ("2", "3") else ""
|
||||
print()
|
||||
|
||||
ioms = []
|
||||
if iom_choice in ("1", "3"):
|
||||
ioms.append(("IOM1", iom1_ip))
|
||||
if iom_choice in ("2", "3"):
|
||||
ioms.append(("IOM2", iom2_ip))
|
||||
|
||||
rule("Current Firmware Versions")
|
||||
_show_fw_versions(password, ioms)
|
||||
|
||||
print(" What would you like to update?")
|
||||
print(f" {_c(C.BOLD, '1')} IOM Firmware only")
|
||||
print(f" {_c(C.BOLD, '2')} Fabric Card Firmware only")
|
||||
print(f" {_c(C.BOLD, '3')} Both IOM and Fabric Card Firmware")
|
||||
print(f" {_c(C.BOLD, '4')} Cancel")
|
||||
print()
|
||||
|
||||
while True:
|
||||
choice = prompt("Select option [1-4]")
|
||||
if choice in ("1", "2", "3", "4"):
|
||||
break
|
||||
warn("Please enter 1, 2, 3, or 4.")
|
||||
|
||||
if choice == "4":
|
||||
info("Firmware update cancelled.")
|
||||
return
|
||||
|
||||
update_iom = choice in ("1", "3")
|
||||
update_fabric = choice in ("2", "3")
|
||||
iom_fw_path = ""
|
||||
fabric_fw_path = ""
|
||||
|
||||
if update_iom:
|
||||
iom_fw_path = _prompt_fw_file("IOM firmware file")
|
||||
|
||||
if update_fabric:
|
||||
fabric_fw_path = _prompt_fw_file("Fabric Card firmware file")
|
||||
|
||||
print()
|
||||
warn("For HA systems: update the passive IOM first.")
|
||||
if len(ioms) > 1:
|
||||
warn("IOM1 will be updated first — adjust order if IOM2 is passive.")
|
||||
print()
|
||||
|
||||
if not prompt_yn("Proceed with firmware update?", default=True):
|
||||
info("Firmware update cancelled.")
|
||||
return
|
||||
|
||||
for iom, ip in ioms:
|
||||
rule(f"{iom} ({ip})")
|
||||
if update_iom:
|
||||
_update_iom_fw(password, ip, iom, iom_fw_path)
|
||||
if update_fabric:
|
||||
_update_fabric_fw(password, ip, iom, fabric_fw_path)
|
||||
|
||||
rule("Post-Update Firmware Validation")
|
||||
_show_fw_versions(password, ioms)
|
||||
|
||||
print()
|
||||
draw_box([
|
||||
f"{_c(C.YEL, 'IMPORTANT -- For HA (Dual-Controller) Systems:')}",
|
||||
"",
|
||||
"After updating this controller's IOMs:",
|
||||
" 1. Log into TrueNAS and initiate a failover.",
|
||||
" 2. Re-run this tool to update the other controller.",
|
||||
], colour=C.YEL)
|
||||
print()
|
||||
459
workflow_serial.py
Normal file
459
workflow_serial.py
Normal file
@@ -0,0 +1,459 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user