Compare commits

19 Commits

Author SHA1 Message Date
2b55617db2 Associate password with each shelf for multi-shelf firmware updates
Each ES24N shelf has a unique Admin password (its BMC serial number), so a
single shared password is incorrect for multi-shelf runs. The password prompt
now appears once per shelf inside _collect_shelves(), stored as the first
element of each (password, [(iom, ip), ...]) shelf tuple. _make_targets()
threads the password into each (label, iom, ip, password) target entry, and
_show_fw_versions() uses the per-target password instead of a global one.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 08:24:09 -04:00
10354794f8 Add multi-shelf support to firmware update workflow
The firmware update workflow now supports updating any number of shelves in a
single run. After entering IPs for the first shelf, the user is prompted to
add another; this repeats until done. Firmware file and update-type choices
are made once and applied to all shelves sequentially.

_show_fw_versions() updated to accept (label, iom, ip) tuples so the display
label and Redfish path name can differ for multi-shelf tables (e.g. "S1 / IOM1"
vs "IOM1"). Pre- and post-update version tables include the shelf number when
more than one shelf is being updated.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 08:20:48 -04:00
47157ba502 Rename main menu option 3 to "Query Current Configuration"
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 22:34:16 -04:00
db831a7a31 Rename main menu option 1 to "Configure Network Settings (serial)"
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 22:32:24 -04:00
3361fdd1a6 Add System Check workflow with serial and network connection options
Adds workflow_check.py: a read-only diagnostic that queries current network
settings and firmware versions (IOM + Fabric Card) from both IOMs. Accessible
via a new main menu option (3 — System Check); Exit moves to option 4.
Supports both serial console (curl over the serial session) and direct network
(HTTPS to management IP) connection methods.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 22:25:16 -04:00
d80dac2222 Fix serial workflow: login to IOM console and run curl for Redfish
The Redfish API at 127.0.0.1 is only accessible from within the IOM's
own shell, not directly from the host over the serial cable. The previous
approach of making urllib HTTPS requests from the host to 127.0.0.1 was
fundamentally incorrect.

Changes:
- serial_port.py: add optional per-call timeout override to
  read_until_quiet() so curl responses have enough time to arrive
- workflow_serial.py:
  - add _login_serial_console() — sends username/password over serial
    and waits for a shell prompt before proceeding
  - add _serial_redfish_request() — builds and sends a curl command
    over the serial session, parses HTTP status and JSON from the output
  - fetch_current_config(), apply_configuration(), _apply_iom() now
    accept and use a SerialPort instance via _serial_redfish_request()
  - configure_shelf() calls _login_serial_console() after collecting
    the password, before making any Redfish calls
  - remove unused _redfish_request import (HTTP transport no longer
    used in the serial workflow)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 21:55:01 -04:00
cb1c23480e Rename es24n/ to modules/
Update sys.path reference in es24n_conf.py and all documentation
to reflect the new folder name.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 19:12:29 -04:00
a5f69ae8b0 Update README to reflect current architecture and features
- Remove single-file description; document the es24n/ module structure
- Add serial cable requirement only where relevant (serial workflow)
- Document IOM selection option in firmware update workflow
- Add firmware file auto-detection to firmware update steps
- Add file structure section
- Add note about firmware file re-upload requirement

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 17:56:53 -04:00
0a49cbe516 Update CLAUDE.md to reflect current multi-file architecture
Document the new file structure, both connection methods (serial/network),
both workflows (serial config and firmware update), key design notes
(auth credentials, firmware bug workaround, API paths), and the planned
network-based config workflow.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 17:53:36 -04:00
df379eb509 Fix stale docstring and add .gitignore
Update module path reference in es24n_conf.py docstring to reflect
that modules now live in es24n/ rather than the root directory.
Add .gitignore to exclude __pycache__ and .pyc files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 17:52:15 -04:00
0ef72bcc53 Move modules into es24n/ subfolder
Keeps the repo root clean with es24n_conf.py as the sole entry point.
All supporting modules (ui, serial_port, models, redfish, workflow_*)
now live in es24n/. The entry point adds es24n/ to sys.path at startup
so inter-module imports within the package remain unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 17:50:57 -04:00
ac2f67adad 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>
2026-03-17 17:48:51 -04:00
c11208199d Auto-detect firmware files in CWD for firmware update prompts
Instead of requiring a manual path entry, scan the current working
directory for files with common firmware extensions and present them
as a numbered list (most recently modified first). The last option
always allows entering a custom path, and the manual prompt is used
as a fallback if no matching files are found.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 17:28:47 -04:00
4e0a17429b Surface per-IOM Redfish error details in network config query
Previously fetch_current_config silently dropped the error string when
an IOM failed to respond, showing only "No response". Now the specific
error (HTTP status, connection refused, timeout, etc.) is printed below
the table to aid diagnosis.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 17:24:07 -04:00
3b14e7b3bc set executable 2026-03-16 19:11:28 -04:00
b65019df4f Add per-IOM selection to firmware update workflow
Prompt user to choose IOM1 only, IOM2 only, or both before collecting
IPs or running updates. Only prompts for IPs of selected IOMs, and
suppresses the IOM1-first HA warning when updating a single IOM.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 09:07:06 -04:00
ae415f89e1 Use root user for serial loopback, Admin for network connections
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 15:56:35 -05:00
73802ad96c Remove password masking from admin password prompt
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 15:27:59 -05:00
66142dcb3c Merge develop: Add README
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 13:16:55 -05:00
11 changed files with 1753 additions and 1128 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
__pycache__/
*.pyc

85
CLAUDE.md Normal file
View File

@@ -0,0 +1,85 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Running the Tool
```bash
python3 es24n_conf.py
```
The script requires root or appropriate serial device permissions. If the serial device is inaccessible, re-run with `sudo`.
## What This Is
An interactive CLI tool for configuring and updating TrueNAS ES24N expansion shelf IOM (I/O Module) controllers via the Redfish API. It supports two connection methods:
- **Serial (loopback):** Connects via USB serial cable to the IOM1 console port, then reaches the Redfish API at `https://127.0.0.1` using `root` credentials. Used for initial network configuration when the IOM has no IP address yet.
- **Network:** Connects directly to the IOM's management IP address using `Admin` credentials. Used for firmware updates and (planned) network reconfiguration once the IOM is reachable on the network.
No external dependencies — Python 3 standard library only. Compatible with TrueNAS (FreeBSD) and Linux.
## File Structure
```
es24n_conf.py ← Entry point and main menu (run this)
modules/
ui.py ← ANSI colours, display helpers, input prompts
serial_port.py ← SerialPort class (termios/fcntl/select, no pyserial)
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
workflow_check.py ← Read-only system check (network settings + firmware versions)
```
`es24n_conf.py` adds `modules/` to `sys.path` at startup so all inter-module imports work without a package structure.
## Workflows
### 1 — Serial Network Configuration (`workflow_serial.py`)
5-step workflow using the USB serial cable:
1. Detect USB serial device (`/dev/ttyUSB*`, `/dev/ttyACM*`, `/dev/ttyU*`)
2. Open 115200-baud 8N1 connection and wake the IOM console
3. Query current network settings via Redfish (`GET` over `127.0.0.1`)
4. Collect new settings from user (Static IP or DHCP)
5. Apply via Redfish `PATCH` over `127.0.0.1`
### 2 — Firmware Update (`workflow_firmware.py`)
Updates IOM firmware and/or Fabric Card firmware over the network:
- Prompts for which IOM(s) to update (IOM1, IOM2, or both)
- Uploads firmware via `POST` to `/redfish/v1/UpdateService` (multipart form)
- Triggers update via Redfish `SimpleUpdate` action
- Polls `/redfish/v1/TaskService/Tasks/` until completion
- Restarts the IOM (and fabric card if applicable) after each update
- The firmware file must be re-uploaded between the IOM and Fabric Card steps — it does not persist after the first update (firmware quirk, documented in `_update_fabric_fw()`)
- Scans the current working directory for firmware files (`.bin`, `.img`, `.fw`, `.hex`, `.zip`, `.tar`, `.tgz`, `.gz`) and presents them as a numbered list before falling back to manual path entry
### 3 — System Check (`workflow_check.py`)
Read-only diagnostic workflow — queries current network settings and firmware versions, makes no changes:
- **Serial:** logs in via serial console, queries both IOMs using `_serial_redfish_request()` (curl over the serial session); covers network settings, IOM firmware, and Fabric Card firmware
- **Network:** prompts for management IP(s), queries via direct HTTPS using `Admin` credentials; reuses `_redfish_request()`, `_get_iom_fw_version()`, `_get_fabric_fw_version()` from `redfish.py`
- Displays results in two tables: network configuration and firmware versions
- User selects Serial, Network, or Cancel at the sub-menu prompt
## Key Design Notes
**Static IP firmware bug workaround:** Setting a static IP requires two sequential PATCH requests:
- Pass 1: set `IPv4StaticAddresses` (while DHCP is still enabled)
- Pass 2: disable `DHCPv4` (after the address is committed)
A single PATCH combining both fields fails due to a known ES24N firmware bug. Documented in `_apply_iom()` in `workflow_serial.py`.
**Authentication:**
- Serial loopback (`127.0.0.1`): username `root`
- Network IP: username `Admin`
**Redfish API paths:**
- Network config: `/redfish/v1/Managers/{IOM1|IOM2}/EthernetInterfaces/1`
- Firmware update: `/redfish/v1/UpdateService`
- Fabric card: `/redfish/v1/Chassis/{IOM1|IOM2}/NetworkAdapters/1`
## Planned Features
- Network-based IOM network configuration (`workflow_network.py`) — same config workflow as serial but connecting via the IOM's existing IP address using `Admin` credentials, for cases where the IOM is already reachable on the network
- Auto-detect password from serial login prompt — the IOM BMC serial number (e.g. `MXE3000043CHA007`) appears to be embedded in the login prompt hostname; if confirmed on a live system, this could allow `_login_serial_console()` to skip the manual password prompt

View File

@@ -1,12 +1,11 @@
# 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.
An interactive CLI tool for configuring network settings and updating firmware on TrueNAS ES24N expansion shelf IOM (I/O Module) controllers via the Redfish API.
## 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
- Root or serial device group permissions (for serial-based configuration)
## Usage
@@ -22,45 +21,64 @@ sudo python3 es24n_conf.py
## Features
### Network Configuration
### Serial Network Configuration
Connects to the IOM over a USB serial connection and configures each IOM's management network interface via the Redfish API.
Connects to the IOM over a USB serial cable and configures each IOM's management network interface via the Redfish API over the serial loopback (`127.0.0.1`). Used for initial setup when the IOM has no IP address yet.
- Supports both **Static IP** and **DHCP** configuration
- Configures IOM1 and IOM2 independently in a single session
- Displays current network settings before making any changes
- Supports configuring multiple shelves back-to-back in one session
### Firmware Updates
Connects to each IOM directly over the network (not serial) and updates IOM and/or Fabric Card firmware via the Redfish API.
Connects to each IOM directly over the network and updates IOM and/or Fabric Card firmware via the Redfish API.
- Update IOM firmware, Fabric Card firmware, or both
- Select IOM1 only, IOM2 only, or both
- Update IOM firmware, Fabric Card firmware, or both in a single run
- Scans the current working directory for firmware files automatically
- Displays current firmware versions before and after the update
- Polls update task progress automatically
## Workflow
### Configure a Shelf
### Configure a Shelf (Serial)
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`)
6. Changes are applied via Redfish PATCH over the serial loopback (`127.0.0.1`)
7. Verify each expander appears in TrueNAS under **System Settings > Enclosure > NVMe-oF Expansion Shelves** before disconnecting the serial cable
### 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
3. Enter the admin password and select which IOM(s) to update
4. Enter the IP address(es) for the selected IOM(s)
5. Select what to update (IOM firmware, Fabric Card firmware, or both)
6. Select firmware file(s) from the auto-detected list or enter a custom path
7. 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.
## File Structure
```
es24n_conf.py ← Entry point — run this
modules/
ui.py ← Display helpers and input prompts
serial_port.py ← Serial port driver (no pyserial required)
models.py ← IOMConfig and ShelfConfig data classes
redfish.py ← Redfish API client (shared by all workflows)
workflow_serial.py ← Serial-based network configuration workflow
workflow_firmware.py ← Firmware update workflow
```
## 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.
- The firmware file must be re-uploaded between the IOM firmware and Fabric Card firmware steps as it does not persist after the first update.

1135
es24n_conf.py Normal file → Executable file

File diff suppressed because it is too large Load Diff

23
modules/models.py Normal file
View 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"))

238
modules/redfish.py Normal file
View File

@@ -0,0 +1,238 @@
"""
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(targets: list):
"""
Query and display firmware versions.
targets: list of (label, iom, ip, password) tuples where
label: display string (e.g. "IOM1", "S1 / IOM1")
iom: actual IOM name used in Redfish paths ("IOM1" or "IOM2")
ip: management IP address
password: Admin password for this shelf
"""
info("Querying firmware versions...")
rows = []
for label, iom, ip, password in targets:
iom_ver = _get_iom_fw_version(password, ip, iom)
fabric_ver = _get_fabric_fw_version(password, ip, iom)
rows.append([label, ip, iom_ver, fabric_ver])
print()
draw_table(
["IOM", "IP Address", "IOM Firmware", "Fabric Firmware"],
rows,
[12, 16, 32, 20],
)
print()

126
modules/serial_port.py Normal file
View File

@@ -0,0 +1,126 @@
"""
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,
timeout: Optional[float] = None) -> str:
"""
Read until no new bytes arrive for `quiet_period` seconds,
or until `timeout` (default: self.timeout) seconds have elapsed.
Pass a longer timeout for operations like curl that take more time.
"""
output = b""
deadline = time.monotonic() + (timeout if timeout is not None else 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
modules/ui.py Normal file
View 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.")

235
modules/workflow_check.py Normal file
View File

@@ -0,0 +1,235 @@
"""
workflow_check.py — System check workflow for ES24N IOM controllers.
Queries current network settings and firmware versions from one or both IOMs
via either a serial connection (for IOMs not yet on the network) or a direct
network connection (for IOMs already reachable via their management IP).
No changes are made — this is a read-only diagnostic workflow.
"""
import time
from redfish import _redfish_request, _get_iom_fw_version, _get_fabric_fw_version
from workflow_serial import (
detect_serial_device,
open_serial_connection,
close_serial_connection,
_login_serial_console,
_serial_redfish_request,
)
from ui import (
_c, C,
banner, rule, draw_table,
info, ok, warn, error,
prompt, prompt_ip, prompt_password,
)
# ── Shared helpers ─────────────────────────────────────────────────────────────
def _parse_network_data(data: dict) -> tuple:
"""
Extract (dhcp_enabled, ip, gateway, netmask, origin) from a
Redfish EthernetInterfaces/1 response dict.
"""
dhcp_enabled = (
data.get("DHCPv4", {}).get("DHCPEnabled", False) or
data.get("DHCPv6", {}).get("DHCPEnabled", False)
)
addrs = data.get("IPv4StaticAddresses") or data.get("IPv4Addresses", [])
if addrs:
addr = addrs[0]
ip = addr.get("Address", "--")
gateway = addr.get("Gateway", "--")
netmask = addr.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")
)
return dhcp_enabled, ip, gateway, netmask, origin
def _print_results(net_rows: list, fw_rows: list):
rule("Network Settings")
draw_table(
["IOM", "Mode", "Origin", "IP Address", "Gateway", "Subnet Mask"],
net_rows,
[5, 10, 8, 16, 16, 16],
)
print()
rule("Firmware Versions")
draw_table(
["IOM", "IOM Firmware", "Fabric Firmware"],
fw_rows,
[5, 32, 24],
)
print()
# ── Serial check ───────────────────────────────────────────────────────────────
def _check_via_serial():
banner()
rule("System Check -- Serial Connection")
device = detect_serial_device()
if not device:
error("Could not detect a serial device. Returning to main menu.")
time.sleep(2)
return
ser = open_serial_connection(device)
if not ser:
error("Could not open serial port. Returning to main menu.")
time.sleep(2)
return
print()
password = prompt_password()
if not _login_serial_console(ser, password):
error("Could not log in to IOM console. Returning to main menu.")
close_serial_connection(ser, device)
time.sleep(2)
return
rule("Querying IOM Status")
info("Querying network settings and firmware versions via serial console...")
print()
net_rows = []
fw_rows = []
for iom in ("IOM1", "IOM2"):
# ── Network settings ───────────────────────────────────────────────────
net_ok, net_data = _serial_redfish_request(
ser, password, "GET",
f"/redfish/v1/Managers/{iom}/EthernetInterfaces/1",
)
if net_ok and isinstance(net_data, dict):
dhcp, ip, gw, nm, origin = _parse_network_data(net_data)
mode = _c(C.CYN, "DHCP") if dhcp else _c(C.GRN, "Static")
net_rows.append([iom, mode, origin, ip, gw, nm])
else:
net_rows.append([iom, _c(C.RED, "No response"), "--", "--", "--", "--"])
error(f"{iom} network query failed: {net_data}")
# ── IOM firmware version ───────────────────────────────────────────────
iom_ok, iom_data = _serial_redfish_request(
ser, password, "GET",
f"/redfish/v1/Managers/{iom}",
)
iom_ver = (
iom_data.get("FirmwareVersion", "Unknown")
if (iom_ok and isinstance(iom_data, dict))
else _c(C.RED, "Unreachable")
)
# ── Fabric card firmware version ───────────────────────────────────────
fab_ok, fab_data = _serial_redfish_request(
ser, password, "GET",
f"/redfish/v1/Chassis/{iom}/NetworkAdapters/1",
)
fab_ver = (
(fab_data.get("Oem", {})
.get("Version", {})
.get("ActiveFirmwareVersion", "Unknown"))
if (fab_ok and isinstance(fab_data, dict))
else _c(C.RED, "Unreachable")
)
fw_rows.append([iom, iom_ver, fab_ver])
_print_results(net_rows, fw_rows)
close_serial_connection(ser, device)
# ── Network check ──────────────────────────────────────────────────────────────
def _check_via_network():
banner()
rule("System Check -- Network Connection")
print()
password = prompt_password()
print()
print(" Which IOM(s) do you want to check?")
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:
choice = prompt("Select [1/2/3]")
if choice in ("1", "2", "3"):
break
warn("Please enter 1, 2, or 3.")
iom_list = []
if choice in ("1", "3"):
ip1 = prompt_ip(" IOM1 IP address")
iom_list.append(("IOM1", ip1))
if choice in ("2", "3"):
ip2 = prompt_ip(" IOM2 IP address")
iom_list.append(("IOM2", ip2))
rule("Querying IOM Status")
info("Querying network settings and firmware versions over the network...")
print()
net_rows = []
fw_rows = []
for iom, ip in iom_list:
# ── Network settings ───────────────────────────────────────────────────
net_ok, net_data = _redfish_request(
password, "GET",
f"/redfish/v1/Managers/{iom}/EthernetInterfaces/1",
host=ip,
)
if net_ok and isinstance(net_data, dict):
dhcp, ip_addr, gw, nm, origin = _parse_network_data(net_data)
mode = _c(C.CYN, "DHCP") if dhcp else _c(C.GRN, "Static")
net_rows.append([iom, mode, origin, ip_addr, gw, nm])
else:
net_rows.append([iom, _c(C.RED, "No response"), "--", "--", "--", "--"])
error(f"{iom} network query failed: {net_data}")
# ── Firmware versions (reuse shared redfish helpers) ───────────────────
iom_ver = _get_iom_fw_version(password, ip, iom)
fab_ver = _get_fabric_fw_version(password, ip, iom)
fw_rows.append([iom, iom_ver, fab_ver])
_print_results(net_rows, fw_rows)
# ── Top-level entry point ──────────────────────────────────────────────────────
def system_check_workflow():
banner()
rule("System Check")
print(" How do you want to connect to the IOM(s)?")
print()
print(f" {_c(C.BOLD, '1')} Serial connection (IOM not yet on the network)")
print(f" {_c(C.BOLD, '2')} Network connection (IOM reachable via management IP)")
print(f" {_c(C.BOLD, '3')} Cancel")
print()
while True:
choice = prompt("Select [1/2/3]")
if choice in ("1", "2", "3"):
break
warn("Please enter 1, 2, or 3.")
if choice == "1":
_check_via_serial()
elif choice == "2":
_check_via_network()
# choice == "3" returns to main menu

View File

@@ -0,0 +1,304 @@
"""
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}")
# ── Multi-shelf IP collection ─────────────────────────────────────────────────
def _collect_shelves(iom_choice: str) -> list:
"""
Prompt for the password and IOM IP addresses one shelf at a time,
offering to add more shelves after each entry.
Each shelf has its own password because the Admin password is the BMC
serial number, which is unique to each physical shelf.
Returns a list of (password, [(iom, ip), ...]) tuples, one per shelf.
"""
shelves = []
shelf_num = 1
while True:
info(f"Shelf {shelf_num} — enter password and IP address(es).")
password = prompt_password()
shelf = []
if iom_choice in ("1", "3"):
ip = prompt_ip(f" Shelf {shelf_num} IOM1 IP address")
shelf.append(("IOM1", ip))
if iom_choice in ("2", "3"):
ip = prompt_ip(f" Shelf {shelf_num} IOM2 IP address")
shelf.append(("IOM2", ip))
shelves.append((password, shelf))
print()
if not prompt_yn("Add another shelf?", default=False):
break
shelf_num += 1
print()
return shelves
def _make_targets(shelves: list) -> list:
"""
Convert the shelves structure into a flat list of (label, iom, ip, password)
tuples suitable for _show_fw_versions(). When there is only one shelf the
label is just the IOM name; for multiple shelves it includes the shelf number.
"""
multi = len(shelves) > 1
return [
(f"S{i} / {iom}" if multi else iom, iom, ip, password)
for i, (password, shelf) in enumerate(shelves, 1)
for iom, ip in shelf
]
# ── 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.
Supports updating multiple shelves in a single run.
"""
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()
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()
# ── Collect IPs for one or more shelves ───────────────────────────────────
shelves = _collect_shelves(iom_choice)
targets = _make_targets(shelves)
rule("Current Firmware Versions")
_show_fw_versions(targets)
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(shelves) > 1:
warn(f"Updating {len(shelves)} shelves sequentially — Shelf 1 first.")
elif any(len(shelf) > 1 for shelf in shelves):
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
# ── Run updates shelf by shelf, IOM by IOM ────────────────────────────────
multi_shelf = len(shelves) > 1
for i, (password, shelf) in enumerate(shelves, 1):
for iom, ip in shelf:
heading = f"Shelf {i}{iom} ({ip})" if multi_shelf else f"{iom} ({ip})"
rule(heading)
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(targets)
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()

569
modules/workflow_serial.py Normal file
View File

@@ -0,0 +1,569 @@
"""
workflow_serial.py — Serial-based ES24N IOM network configuration workflow.
Connects via USB serial cable, logs in to the IOM console, and configures
network settings by running curl commands through the shell session.
The IOM's Redfish API is only accessible locally at https://127.0.0.1 from
within the IOM's own shell — not directly from the host over the serial cable.
All Redfish operations are therefore performed by issuing curl commands over
the serial connection and parsing the JSON responses.
"""
import glob
import json
import os
import re
import subprocess
import time
from typing import Optional
from models import IOMConfig, ShelfConfig
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,
)
# Strip ANSI escape sequences from serial terminal output
_ANSI_RE = re.compile(r'\x1b\[[0-9;]*[mGKHF]')
# ── Serial Redfish transport ───────────────────────────────────────────────────
def _login_serial_console(ser: SerialPort, password: str) -> bool:
"""
Perform the root login sequence on the IOM serial console.
Handles both the case where a login prompt is showing and where
a shell session is already active.
Returns True if a shell prompt is reached, False on failure.
"""
info("Logging in to IOM serial console...")
# Send a newline to get the current state of the console
ser.send_line("", delay=0.5)
response = _ANSI_RE.sub("", ser.read_until_quiet(quiet_period=0.5))
low = response.lower()
# Already at a shell prompt — no login needed
if ("#" in response or "$" in response) and "login" not in low:
ok("Already at shell prompt.")
return True
# Send username if we see a login prompt (or an empty/unknown response)
if "login" in low or not response.strip():
ser.send_line("root", delay=0.5)
response = _ANSI_RE.sub("", ser.read_until_quiet(quiet_period=0.5))
# Send password
if "password" in response.lower():
ser.send_line(password, delay=0.5)
response = _ANSI_RE.sub("", ser.read_until_quiet(quiet_period=2.0))
if "#" in response or "$" in response:
ok("Logged in to IOM console.")
return True
error(f"Login failed. Console response: {response.strip()[:120]}")
return False
def _serial_redfish_request(ser: SerialPort, password: str, method: str,
path: str, payload: Optional[dict] = None) -> tuple:
"""
Issue a Redfish request by running curl on the IOM's serial console.
The Redfish API is available at https://127.0.0.1 from within the IOM shell.
Appends -w '\\nHTTP_CODE:%{http_code}' to every curl call so the HTTP
status can be parsed independently of the response body.
Returns (success: bool, data: dict | str).
"""
url = f"https://127.0.0.1{path}"
cmd = f"curl -sk -u root:{password} -X {method}"
if payload:
body = json.dumps(payload, separators=(",", ":"))
cmd += f" -H 'Content-Type: application/json' -d '{body}'"
cmd += f" -w '\\nHTTP_CODE:%{{http_code}}' '{url}'"
ser.send_line(cmd, delay=0.3)
raw = _ANSI_RE.sub("", ser.read_until_quiet(quiet_period=1.5, timeout=30.0))
# ── Parse HTTP status code ─────────────────────────────────────────────────
http_code = 0
if "HTTP_CODE:" in raw:
try:
http_code = int(raw.split("HTTP_CODE:")[-1].strip()[:3])
except ValueError:
pass
# ── Extract JSON body (outermost { ... }) ──────────────────────────────────
data: dict = {}
json_start = raw.find("{")
json_end = raw.rfind("}") + 1
if json_start >= 0 and json_end > json_start:
try:
data = json.loads(raw[json_start:json_end])
except json.JSONDecodeError:
pass
# ── Determine success ──────────────────────────────────────────────────────
if http_code >= 400:
msg = (data.get("error", {}).get("message", "")
if isinstance(data, dict) else "")
return False, f"HTTP {http_code}: {msg or raw[json_start:json_start+120]}"
if http_code >= 200 or data:
return True, data
# No HTTP code and no JSON — check for curl-level error
if "curl:" in raw.lower():
return False, f"curl error: {raw.strip()[:120]}"
return True, data
# ── 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 = _ANSI_RE.sub("", 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; login will be attempted next.")
else:
warn("No response received from IOM console.")
warn("Login will be attempted after the password is entered.")
print()
return ser
# ── Step 3: Fetch & display current IOM network settings ─────────────────────
def fetch_current_config(cfg: ShelfConfig, ser: SerialPort) -> bool:
"""
Query Redfish for the current network config of both IOMs via curl
over the serial console session.
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 via serial console...")
print()
any_ok = False
rows = []
errors = []
for iom in ("IOM1", "IOM2"):
path = f"/redfish/v1/Managers/{iom}/EthernetInterfaces/1"
ok_flag, data = _serial_redfish_request(ser, 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, ser: SerialPort) -> bool:
rule("Step 5 of 5 -- Applying Configuration via Redfish API")
info("Sending Redfish PATCH requests via serial console curl...")
print()
results = []
all_ok = True
for iom_cfg in [cfg.iom1, cfg.iom2]:
success, detail = _apply_iom(cfg.password, iom_cfg, ser)
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, ser: SerialPort) -> tuple:
"""
Apply network config to a single IOM via curl over the serial console.
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 = _serial_redfish_request(
ser, 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 = _serial_redfish_request(
ser, 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 = _serial_redfish_request(
ser, 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 login
print()
cfg.password = prompt_password()
# Log in to the IOM console (required before any Redfish curl calls)
if not _login_serial_console(ser, cfg.password):
error("Could not log in to IOM console. Returning to main menu.")
close_serial_connection(ser, device)
time.sleep(2)
return True
# 3 — Fetch & display current settings
fetch_current_config(cfg, ser)
# 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 via curl on the IOM console.")
print()
if prompt_yn("Apply configuration now?", default=True):
apply_configuration(cfg, ser)
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)