Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 31 additions & 4 deletions docs/redfish-proxy.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Author: Mus <spyroot@gmail.com>

> 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
Expand All @@ -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.
Expand All @@ -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`
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions redfish_ctl/proxy/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
206 changes: 206 additions & 0 deletions redfish_ctl/proxy/core.py
Original file line number Diff line number Diff line change
@@ -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,
),
}
52 changes: 52 additions & 0 deletions redfish_ctl/proxy/fastapi_app.py
Original file line number Diff line number Diff line change
@@ -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
Loading