Files
es24n-conf/modules/redfish.py
scott 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

231 lines
7.7 KiB
Python

"""
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()