diff --git a/docs/redfish-proxy.md b/docs/redfish-proxy.md index 8ade72ef..58869fdf 100644 --- a/docs/redfish-proxy.md +++ b/docs/redfish-proxy.md @@ -2,7 +2,7 @@ Author: Mus -> Status: design only. The CLI works without this service. +> Status: read-only core exists. The CLI works without this service. For one server, I use `redfish_ctl` directly. For a fleet, I want one service that can reach the BMC network, keep desired state, read observed state, and reconcile the two. Clients should not all need @@ -15,6 +15,8 @@ The CLI already has the pieces I would reuse: - `RedfishManager`, defined in `redfish_ctl/redfish_manager.py`, for product-neutral HTTP. - `IDracManager`, defined in `redfish_ctl/idrac_manager.py`, for Dell/iDRAC behavior and host-system selection. +- `redfish_ctl.proxy`, defined under `redfish_ctl/proxy/`, for the first dependency-light read proxy + core and optional FastAPI route binding. - Vendor-neutral reads such as `sensors`, `network-adapters`, `metric-reports`, `logs`, `secure-boot`, and `oem-info`. - Four offline corpora: Dell, Supermicro GB300, HPE iLO, and generic DMTF Redfish. @@ -39,6 +41,29 @@ clients / kubectl / CI A Kubernetes CustomResourceDefinition (CRD), installed later by an operator, could expose the same model to cluster users. The first useful version can be smaller: an API, a database, and workers. +## Read-Only Core + +The first code increment is `redfish_ctl.proxy`, a dependency-light package in the Python source tree. +It keeps an in-memory `NodeRegistry`, accepts a caller-provided manager factory, and shapes existing +read commands into service responses. `NodeConfig.username` and `NodeConfig.password`, connection-only +fields defined by the registry entry, are omitted from list responses. + +The optional FastAPI adapter, defined in `redfish_ctl/proxy/fastapi_app.py`, imports FastAPI only when +`create_app()` is called. That keeps the CLI install dependency-light while still providing the route +binding for deployments that install an ASGI runtime. + +| Method | Path | Backing read | +| --- | --- | --- | +| `GET` | `/nodes` | Sanitized `NodeRegistry` inventory | +| `GET` | `/nodes/{node_id}` | `redfish_ctl.api.get_system()` + `get_thermal()` | +| `GET` | `/nodes/{node_id}/sensors` | `sensors` command via `redfish_ctl.api.get_sensors()` | +| `GET` | `/nodes/{node_id}/gpu-metrics` | `gpu-metrics` command | +| `GET` | `/nodes/{node_id}/bios?attr_filter=...` | `bios` command | + +These routes are read-only. They do not create desired state, patch BMC resources, or run Redfish +actions. The manager factory is intentionally supplied by the embedding service so credentials can +come from a secret store without being serialized into proxy responses. + ## Stored State Clients write the `spec` fields through the proxy API. The reconcile worker writes the `status` @@ -96,13 +121,15 @@ scale. ## First Useful Version -The first version I would build: +The read-only core now covers: - register a server, - read observed state, -- set desired power and boot override, - list servers, -- reconcile one server at a time with SQLite or Postgres. +- expose sensor, GPU metric, and BIOS reads through GET endpoints. + +The next useful version should add persistent storage, desired power and boot override fields, and a +reconcile loop that handles one server at a time with SQLite or Postgres. Bounded concurrency, simulator-backed benchmarks, and 1,000-node gates are separate work; see [Scaling and benchmarks](scaling-and-benchmarks.md). The current offline and emulator test lanes are diff --git a/redfish_ctl/proxy/__init__.py b/redfish_ctl/proxy/__init__.py new file mode 100644 index 00000000..94306857 --- /dev/null +++ b/redfish_ctl/proxy/__init__.py @@ -0,0 +1,12 @@ +"""Read-only fleet proxy helpers.""" + +from .core import NodeConfig, NodeNotFound, NodeRegistry, ReadOnlyProxy +from .fastapi_app import create_app + +__all__ = [ + "NodeConfig", + "NodeNotFound", + "NodeRegistry", + "ReadOnlyProxy", + "create_app", +] diff --git a/redfish_ctl/proxy/core.py b/redfish_ctl/proxy/core.py new file mode 100644 index 00000000..4fab86a7 --- /dev/null +++ b/redfish_ctl/proxy/core.py @@ -0,0 +1,206 @@ +"""Dependency-light read proxy core for Redfish fleet services.""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any + +from redfish_ctl.api import ( + RedfishApiError, + SyncInvoker, + ThermalStatus, + get_sensors, + get_system, + get_thermal, +) +from redfish_ctl.idrac_shared import ApiRequestType + + +@dataclass(frozen=True) +class NodeConfig: + """Connection metadata for one managed BMC.""" + + id: str + address: str + port: int = 443 + username: str | None = None + password: str | None = None + insecure: bool = True + description: str | None = None + + def public_dict(self) -> dict[str, Any]: + """Return node metadata safe for API responses.""" + return { + "id": self.id, + "address": self.address, + "port": self.port, + "insecure": self.insecure, + "description": self.description, + } + + +class NodeNotFound(KeyError): + """Raised when a proxy request references an unknown node.""" + + +class NodeRegistry: + """In-memory node registry for the first read-only proxy increment.""" + + def __init__(self, nodes: Iterable[NodeConfig]): + self._nodes = {} + for node in nodes: + if node.id in self._nodes: + raise ValueError(f"duplicate node id: {node.id}") + self._nodes[node.id] = node + + def list(self) -> list[NodeConfig]: + """Return nodes sorted by stable id.""" + return [self._nodes[node_id] for node_id in sorted(self._nodes)] + + def get(self, node_id: str) -> NodeConfig: + """Return one node or raise NodeNotFound.""" + try: + return self._nodes[node_id] + except KeyError as exc: + raise NodeNotFound(node_id) from exc + + +def _utc_now() -> datetime: + return datetime.now(timezone.utc) + + +def _rfc3339(value: datetime) -> str: + if value.tzinfo is None: + value = value.replace(tzinfo=timezone.utc) + value = value.astimezone(timezone.utc) + return value.isoformat().replace("+00:00", "Z") + + +def _as_float(value: Any) -> float | None: + if value is None or isinstance(value, bool): + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def _temperature_summary(thermal: ThermalStatus) -> dict[str, float | int | None]: + values = [ + numeric + for reading in thermal.temperatures + if (numeric := _as_float(reading.reading_celsius)) is not None + ] + return { + "count": len(values), + "maxCelsius": max(values) if values else None, + } + + +def _raw_command( + manager: SyncInvoker, + api_call: ApiRequestType, + name: str, + **kwargs: Any, +) -> Any: + result = manager.sync_invoke(api_call, name, **kwargs) + if result.error: + raise RedfishApiError(str(result.error)) + return result.data + + +def _sensor_dicts(manager: SyncInvoker) -> list[dict[str, Any]]: + return [ + { + "chassis": reading.chassis, + "name": reading.name, + "reading": reading.reading, + "readingUnits": reading.reading_units, + "readingType": reading.reading_type, + "health": reading.health, + } + for reading in get_sensors(manager) + ] + + +class ReadOnlyProxy: + """Read-only facade that shapes Redfish command results for service APIs.""" + + def __init__( + self, + registry: NodeRegistry, + manager_factory: Callable[[NodeConfig], SyncInvoker], + clock: Callable[[], datetime] = _utc_now, + ): + self._registry = registry + self._manager_factory = manager_factory + self._clock = clock + + def list_nodes(self) -> dict[str, Any]: + """List registered nodes without exposing credentials.""" + return {"nodes": [node.public_dict() for node in self._registry.list()]} + + def _node_and_manager(self, node_id: str) -> tuple[NodeConfig, SyncInvoker]: + node = self._registry.get(node_id) + return node, self._manager_factory(node) + + def node_status(self, node_id: str) -> dict[str, Any]: + """Read one node's host status and thermal summary.""" + node, manager = self._node_and_manager(node_id) + system = get_system(manager) + thermal = get_thermal(manager) + return { + "id": node.id, + "address": node.address, + "system": { + "id": system.id, + "name": system.name, + "powerState": system.power_state, + "health": system.health, + "state": system.state, + }, + "temperature": _temperature_summary(thermal), + "lastPolled": _rfc3339(self._clock()), + } + + def node_sensors(self, node_id: str) -> dict[str, Any]: + """Read normalized chassis sensor rows for one node.""" + node, manager = self._node_and_manager(node_id) + return { + "id": node.id, + "sensors": _sensor_dicts(manager), + } + + def node_gpu_metrics(self, node_id: str) -> dict[str, Any]: + """Read consolidated GPU metric rows for one node.""" + node, manager = self._node_and_manager(node_id) + return { + "id": node.id, + "gpuMetrics": _raw_command( + manager, + ApiRequestType.GpuMetrics, + "gpu-metrics", + ), + } + + def node_bios( + self, + node_id: str, + *, + attr_filter: str | None = None, + ) -> dict[str, Any]: + """Read BIOS attributes for one node.""" + node, manager = self._node_and_manager(node_id) + return { + "id": node.id, + "bios": _raw_command( + manager, + ApiRequestType.BiosQuery, + "bios_inventory", + attr_filter=attr_filter or "", + attr_only=False, + do_deep=False, + ), + } diff --git a/redfish_ctl/proxy/fastapi_app.py b/redfish_ctl/proxy/fastapi_app.py new file mode 100644 index 00000000..6c3f0003 --- /dev/null +++ b/redfish_ctl/proxy/fastapi_app.py @@ -0,0 +1,52 @@ +"""Optional FastAPI adapter for the read-only proxy core.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from redfish_ctl.api import RedfishApiError + +from .core import NodeNotFound, ReadOnlyProxy + + +def create_app(proxy: ReadOnlyProxy): + """Create a FastAPI app for read-only proxy routes.""" + try: + from fastapi import FastAPI, HTTPException + except ImportError as exc: + raise RuntimeError( + "Install FastAPI and an ASGI server to run the read-only proxy." + ) from exc + + app = FastAPI(title="redfish_ctl read-only proxy") + + def call(func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any: + try: + return func(*args, **kwargs) + except NodeNotFound as exc: + raise HTTPException(status_code=404, detail=f"unknown node: {exc.args[0]}") from exc + except RedfishApiError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + + @app.get("/nodes") + def list_nodes(): + return proxy.list_nodes() + + @app.get("/nodes/{node_id}") + def node_status(node_id: str): + return call(proxy.node_status, node_id) + + @app.get("/nodes/{node_id}/sensors") + def node_sensors(node_id: str): + return call(proxy.node_sensors, node_id) + + @app.get("/nodes/{node_id}/gpu-metrics") + def node_gpu_metrics(node_id: str): + return call(proxy.node_gpu_metrics, node_id) + + @app.get("/nodes/{node_id}/bios") + def node_bios(node_id: str, attr_filter: str | None = None): + return call(proxy.node_bios, node_id, attr_filter=attr_filter) + + return app diff --git a/tests/test_proxy_readonly.py b/tests/test_proxy_readonly.py new file mode 100644 index 00000000..9e2cae0f --- /dev/null +++ b/tests/test_proxy_readonly.py @@ -0,0 +1,296 @@ +"""Tests for the read-only fleet proxy core.""" + +from __future__ import annotations + +import json +import sys +from datetime import datetime, timezone +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from redfish_ctl.idrac_manager import IDracManager +from redfish_ctl.idrac_shared import ApiRequestType +from redfish_ctl.proxy import NodeConfig, NodeRegistry, ReadOnlyProxy, create_app +from redfish_ctl.redfish_manager import CommandResult + +GB300_CORPUS = ( + Path(__file__).parent + / "supermicro_gb300_corpus" + / "json_responses" + / "172.25.230.37" +) +GB300_INDEX = {path.name.lower(): path for path in GB300_CORPUS.glob("*.json")} + + +class RecordingManager: + """Record sync_invoke calls and return configured command payloads.""" + + def __init__(self, results): + self.results = results + self.calls = [] + + def sync_invoke(self, api_call, name, **kwargs): + self.calls.append((api_call, name, kwargs)) + return self.results[(api_call, name)] + + +def _fixture_for_path(path): + name = "_" + path.strip("/").replace("/", "_") + ".json" + return GB300_INDEX.get(name.lower()) + + +@pytest.fixture +def gb300_corpus_manager(): + """Serve the committed GB300 crawl over requests-mock.""" + requests_mock = pytest.importorskip("requests_mock") + requests = [] + + def get_cb(request, context): + requests.append(request) + fixture = _fixture_for_path(request.path) + if fixture is None: + context.status_code = 404 + return json.dumps({"error": f"no fixture for {request.path}"}) + context.status_code = 200 + return fixture.read_text() + + with requests_mock.Mocker() as mocker: + mocker.get(requests_mock.ANY, text=get_cb) + manager = IDracManager( + idrac_ip="mock-gb300", + idrac_username="root", + idrac_password="mock", + insecure=True, + is_debug=False, + ) + yield manager, requests + + +def _node(): + return NodeConfig( + id="gb300-a", + address="redfish://203.0.113.10", + username="operator", + password="do-not-expose", + description="Rack A node", + ) + + +def test_proxy_lists_nodes_without_exposing_credentials(): + """Node inventory responses omit username and password fields.""" + proxy = ReadOnlyProxy( + NodeRegistry([_node()]), + manager_factory=lambda node: RecordingManager({}), + ) + + payload = proxy.list_nodes() + + assert payload == { + "nodes": [ + { + "id": "gb300-a", + "address": "redfish://203.0.113.10", + "port": 443, + "insecure": True, + "description": "Rack A node", + } + ] + } + encoded = json.dumps(payload) + assert "operator" not in encoded + assert "do-not-expose" not in encoded + + +def test_proxy_status_uses_facade_and_summarizes_temperatures(): + """Node status reads system and thermal data without mutating the BMC.""" + manager = RecordingManager({ + (ApiRequestType.SystemQuery, "system_query"): CommandResult( + { + "Id": "System_0", + "Name": "System_0", + "PowerState": "On", + "Status": {"Health": "OK", "State": "Enabled"}, + }, + None, + None, + None, + ), + (ApiRequestType.Thermal, "thermal"): CommandResult( + { + "summary": {}, + "temperature_readings": [ + {"ReadingCelsius": 39.25}, + {"ReadingCelsius": "42.5"}, + {"ReadingCelsius": None}, + {"ReadingCelsius": "not-a-number"}, + ], + "fans": [], + }, + None, + None, + None, + ), + }) + proxy = ReadOnlyProxy( + NodeRegistry([_node()]), + manager_factory=lambda node: manager, + clock=lambda: datetime(2026, 7, 10, 18, 0, tzinfo=timezone.utc), + ) + + payload = proxy.node_status("gb300-a") + + assert payload == { + "id": "gb300-a", + "address": "redfish://203.0.113.10", + "system": { + "id": "System_0", + "name": "System_0", + "powerState": "On", + "health": "OK", + "state": "Enabled", + }, + "temperature": {"count": 2, "maxCelsius": 42.5}, + "lastPolled": "2026-07-10T18:00:00Z", + } + assert manager.calls == [ + (ApiRequestType.SystemQuery, "system_query", {"do_deep": False}), + (ApiRequestType.Thermal, "thermal", {}), + ] + + +def test_proxy_read_endpoints_delegate_to_existing_commands(): + """Sensors, GPU metrics, and BIOS endpoints reuse registered read commands.""" + manager = RecordingManager({ + (ApiRequestType.Sensors, "sensors"): CommandResult( + [ + { + "Chassis": "Chassis_0", + "Name": "Front IO Temp", + "Reading": 24.4, + "ReadingUnits": "Cel", + "ReadingType": "Temperature", + "Health": "OK", + } + ], + None, + None, + None, + ), + (ApiRequestType.GpuMetrics, "gpu-metrics"): CommandResult( + {"summary": {"gpus": 4}, "gpus": [{"GpuId": "GPU_0"}]}, + None, + None, + None, + ), + (ApiRequestType.BiosQuery, "bios_inventory"): CommandResult( + {"Attributes": {"ProcCStates": "Disabled"}}, + None, + None, + None, + ), + }) + proxy = ReadOnlyProxy( + NodeRegistry([_node()]), + manager_factory=lambda node: manager, + ) + + sensors = proxy.node_sensors("gb300-a") + gpu_metrics = proxy.node_gpu_metrics("gb300-a") + bios = proxy.node_bios("gb300-a", attr_filter="ProcCStates") + + assert sensors["sensors"][0]["name"] == "Front IO Temp" + assert sensors["sensors"][0]["readingUnits"] == "Cel" + assert gpu_metrics["gpuMetrics"]["summary"]["gpus"] == 4 + assert bios["bios"]["Attributes"] == {"ProcCStates": "Disabled"} + assert manager.calls == [ + (ApiRequestType.Sensors, "sensors", {"do_expanded": False}), + (ApiRequestType.GpuMetrics, "gpu-metrics", {}), + ( + ApiRequestType.BiosQuery, + "bios_inventory", + { + "attr_filter": "ProcCStates", + "attr_only": False, + "do_deep": False, + }, + ), + ] + + +def test_create_app_registers_read_only_routes(monkeypatch): + """The optional FastAPI adapter exposes only GET routes.""" + routes = [] + + class FakeFastAPI: + def __init__(self, **kwargs): + self.kwargs = kwargs + + def get(self, path): + def decorator(func): + routes.append(("GET", path, func)) + return func + + return decorator + + class FakeHTTPException(Exception): + def __init__(self, status_code, detail): + self.status_code = status_code + self.detail = detail + super().__init__(detail) + + monkeypatch.setitem( + sys.modules, + "fastapi", + SimpleNamespace(FastAPI=FakeFastAPI, HTTPException=FakeHTTPException), + ) + proxy = ReadOnlyProxy( + NodeRegistry([_node()]), + manager_factory=lambda node: RecordingManager({}), + ) + + app = create_app(proxy) + + assert app.kwargs["title"] == "redfish_ctl read-only proxy" + assert [(method, path) for method, path, _ in routes] == [ + ("GET", "/nodes"), + ("GET", "/nodes/{node_id}"), + ("GET", "/nodes/{node_id}/sensors"), + ("GET", "/nodes/{node_id}/gpu-metrics"), + ("GET", "/nodes/{node_id}/bios"), + ] + + +def test_proxy_reads_gb300_corpus_through_registered_commands( + gb300_corpus_manager, +): + """Proxy reads use real commands against the GB300 fixture tree.""" + manager, requests = gb300_corpus_manager + proxy = ReadOnlyProxy( + NodeRegistry([_node()]), + manager_factory=lambda node: manager, + clock=lambda: datetime(2026, 7, 10, 18, 0, tzinfo=timezone.utc), + ) + + status = proxy.node_status("gb300-a") + sensors = proxy.node_sensors("gb300-a") + gpu_metrics = proxy.node_gpu_metrics("gb300-a") + bios = proxy.node_bios("gb300-a", attr_filter="EGM") + + assert status["system"]["id"] == "System_0" + assert status["system"]["powerState"] == "On" + assert status["system"]["health"] == "OK" + assert status["temperature"]["count"] == 56 + assert status["temperature"]["maxCelsius"] > 50 + assert len(sensors["sensors"]) >= 250 + assert gpu_metrics["gpuMetrics"]["summary"]["gpus"] == 4 + assert bios["bios"]["EGM"] is True + assert bios["bios"]["EGMHypervisorReservedMemory"] == 0 + + paths = {request.path.lower() for request in requests} + assert "/redfish/v1/systems/system_0" in paths + assert "/redfish/v1/chassis/chassis_0/sensors" in paths + assert "/redfish/v1/systems/hgx_baseboard_0/processors/gpu_0" in paths + assert "/redfish/v1/systems/system_0/bios" in paths + assert {request.method for request in requests} == {"GET"}