Compare commits

..

3 Commits

Author SHA1 Message Date
e3f1ffc839 Add README with usage, workflow, and feature overview
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 13:15:43 -05:00
aa85c956c9 Merge claude/gifted-curie: Add IOM and Fabric Card firmware update workflow
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 13:09:37 -05:00
42f8349757 Add IOM and Fabric Card firmware update workflow
- Add option 2 to main menu: "Update IOM / Fabric Card Firmware"
- Add firmware_update_workflow() connecting to IOMs via network IP
- Add _redfish_upload_firmware() for multipart/form-data firmware upload
- Add _redfish_trigger_update() for Redfish SimpleUpdate action
- Add _redfish_poll_tasks() to monitor TaskService until completion
- Add _redfish_restart_iom() and _redfish_reset_fabric() for graceful restarts
- Add _get_iom_fw_version() and _get_fabric_fw_version() for validation
- Add host param to _redfish_request() (default 127.0.0.1, backward-compatible)
- Implements §12 of ES24N Product Service Guide v.26011

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 13:06:18 -05:00
2 changed files with 427 additions and 10 deletions

66
README.md Normal file
View File

@@ -0,0 +1,66 @@
# ES24N IOM Configuration Tool
A single-file interactive CLI tool for configuring network settings and updating firmware on TrueNAS ES24N expansion shelf IOM (I/O Module) controllers.
## Requirements
- Python 3 (standard library only — no external dependencies)
- A USB serial cable connected from the ES24N IOM1 port to the active TrueNAS controller
- Root or serial device group permissions
## Usage
```bash
python3 es24n_conf.py
```
If the serial device is inaccessible, re-run with `sudo`:
```bash
sudo python3 es24n_conf.py
```
## Features
### Network Configuration
Connects to the IOM over a USB serial connection and configures each IOM's management network interface via the Redfish API.
- Supports both **Static IP** and **DHCP** configuration
- Configures IOM1 and IOM2 independently in a single session
- Displays current network settings before making any changes
### Firmware Updates
Connects to each IOM directly over the network (not serial) and updates IOM and/or Fabric Card firmware via the Redfish API.
- Update IOM firmware, Fabric Card firmware, or both
- Displays current firmware versions before and after the update
- Polls update task progress automatically
## Workflow
### Configure a Shelf
1. Connect the serial cable from the ES24N IOM1 port to the controller's USB port
2. Run the tool and select **Configure a new ES24N shelf**
3. The tool detects the serial device, opens the connection, and prompts for the BMC admin password
4. Current network settings for IOM1 and IOM2 are displayed
5. Choose to apply a new Static IP or DHCP configuration, or leave settings unchanged
6. Changes are applied via Redfish PATCH requests over the serial loopback (`127.0.0.1`)
### Update Firmware
1. Ensure this system has network access to the IOM management interfaces
2. Run the tool and select **Update IOM / Fabric Card Firmware**
3. Enter the admin password and IP addresses for IOM1 and IOM2
4. Select what to update and provide the firmware file path(s)
5. The tool uploads, applies, and monitors each update, then restarts the affected components
> **HA Systems:** Update the passive IOM first. After updating both IOMs on one controller, initiate a TrueNAS failover and re-run the tool for the other controller.
## Notes
- Setting a static IP requires two sequential Redfish PATCH requests due to a known ES24N firmware bug. The tool handles this automatically.
- Firmware uploads are performed over the network — uploading over the 115200-baud serial connection would be impractically slow.
- After applying network changes, verify each expander appears in TrueNAS under **System Settings > Enclosure > NVMe-oF Expansion Shelves** before disconnecting the serial cable.

View File

@@ -53,7 +53,7 @@ def banner():
print(_c(C.CYN, " |") + _c(C.WHT + C.BOLD, print(_c(C.CYN, " |") + _c(C.WHT + C.BOLD,
" TrueNAS ES24N IOM Configuration Tool ") + _c(C.CYN, " |")) " TrueNAS ES24N IOM Configuration Tool ") + _c(C.CYN, " |"))
print(_c(C.CYN, " |") + _c(C.DIM, print(_c(C.CYN, " |") + _c(C.DIM,
" Serial Network Setup v2.0 (stdlib only) ") + _c(C.CYN, " |")) " Serial Config & Firmware Updates (stdlib only) ") + _c(C.CYN, " |"))
print(_c(C.CYN, " +" + "-" * w + "+")) print(_c(C.CYN, " +" + "-" * w + "+"))
print() print()
@@ -418,12 +418,14 @@ def open_serial_connection(device: str) -> Optional[SerialPort]:
# ── Redfish helpers (GET and PATCH) ────────────────────────────────────────── # ── Redfish helpers (GET and PATCH) ──────────────────────────────────────────
def _redfish_request(password: str, method: str, path: str, def _redfish_request(password: str, method: str, path: str,
payload: Optional[dict] = None) -> tuple: payload: Optional[dict] = None,
host: str = "127.0.0.1") -> tuple:
""" """
Issue a Redfish HTTP request over the loopback (127.0.0.1). 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). Returns (success: bool, data: dict|str).
""" """
url = f"https://127.0.0.1{path}" url = f"https://{host}{path}"
credentials = b64encode(f"Admin:{password}".encode()).decode() credentials = b64encode(f"Admin:{password}".encode()).decode()
ctx = ssl.create_default_context() ctx = ssl.create_default_context()
ctx.check_hostname = False ctx.check_hostname = False
@@ -456,6 +458,353 @@ def _redfish_request(password: str, method: str, path: str,
return False, f"Connection error: {e}" return False, f"Connection error: {e}"
# ── Redfish helpers (firmware upload & update) ────────────────────────────────
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()
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:
# Resolve individual task link
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, iom1_ip: str, iom2_ip: str):
info("Querying firmware versions...")
rows = []
for iom, ip in [("IOM1", iom1_ip), ("IOM2", iom2_ip)]:
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()
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()
info("Enter the management IP address for each IOM.")
iom1_ip = prompt_ip(" IOM1 IP address")
iom2_ip = prompt_ip(" IOM2 IP address")
print()
rule("Current Firmware Versions")
_show_fw_versions(password, iom1_ip, iom2_ip)
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:
print()
while True:
iom_fw_path = prompt("Path to IOM firmware file")
if os.path.isfile(iom_fw_path):
sz = os.path.getsize(iom_fw_path)
ok(f"File: {iom_fw_path} ({sz // 1024} KB)")
break
warn(f"File not found: {iom_fw_path}")
if update_fabric:
print()
while True:
fabric_fw_path = prompt("Path to Fabric Card firmware file")
if os.path.isfile(fabric_fw_path):
sz = os.path.getsize(fabric_fw_path)
ok(f"File: {fabric_fw_path} ({sz // 1024} KB)")
break
warn(f"File not found: {fabric_fw_path}")
print()
warn("For HA systems: update the passive IOM first.")
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 [("IOM1", iom1_ip), ("IOM2", iom2_ip)]:
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, iom1_ip, iom2_ip)
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()
# ── Step 3: Fetch & display current IOM network settings ───────────────────── # ── Step 3: Fetch & display current IOM network settings ─────────────────────
def fetch_current_config(cfg: ShelfConfig) -> bool: def fetch_current_config(cfg: ShelfConfig) -> bool:
""" """
@@ -773,28 +1122,30 @@ def configure_shelf() -> bool:
# ── Entry point ─────────────────────────────────────────────────────────────── # ── Entry point ───────────────────────────────────────────────────────────────
def main(): def main():
banner() banner()
print(f" {_c(C.DIM, 'Automates ES24N IOM network configuration over a direct serial')}") print(f" {_c(C.DIM, 'Automates ES24N IOM network configuration and firmware updates')}")
print(f" {_c(C.DIM, 'connection using the Redfish API (loopback).')}") print(f" {_c(C.DIM, 'using the Redfish API. No external dependencies.')}")
print(f" {_c(C.DIM, 'No external dependencies -- Python 3 standard library only.')}")
print() print()
while True: while True:
banner() banner()
draw_box([ draw_box([
f" {_c(C.BOLD, '1')} Configure a new ES24N shelf", f" {_c(C.BOLD, '1')} Configure a new ES24N shelf",
f" {_c(C.BOLD, '2')} Exit", f" {_c(C.BOLD, '2')} Update IOM / Fabric Card Firmware",
f" {_c(C.BOLD, '3')} Exit",
]) ])
print() print()
choice = prompt("Select [1/2]") choice = prompt("Select [1/2/3]")
if choice == "1": if choice == "1":
another = configure_shelf() another = configure_shelf()
if not another: if not another:
break break
elif choice == "2": elif choice == "2":
firmware_update_workflow()
elif choice == "3":
break break
else: else:
warn("Please enter 1 or 2.") warn("Please enter 1, 2, or 3.")
time.sleep(1) time.sleep(1)
print() print()