diff --git a/redfish_ctl/api.py b/redfish_ctl/api.py index f1f4c92..646959a 100644 --- a/redfish_ctl/api.py +++ b/redfish_ctl/api.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any, Mapping, Protocol +from typing import Any, Iterable, Mapping, Protocol from .idrac_shared import ApiRequestType from .redfish_manager import CommandResult @@ -82,6 +82,82 @@ class ThermalStatus: raw: Mapping[str, Any] +@dataclass(frozen=True) +class GpuMetricRow: + """Normalized GPU row from the gpu-metrics command.""" + + system_id: str | None + gpu_id: str | None + processor_uri: str | None + processor_metrics_uri: str | None + name: str | None + model: str | None + manufacturer: str | None + firmware_version: str | None + status: Mapping[str, Any] + operating_speed_mhz: int | float | str | None + temperatures_celsius: Mapping[str, Any] + compute_utilization_percent: Mapping[str, Any] + throttle_duration_seconds: Mapping[str, Any] + processor_metrics: Mapping[str, Any] + memory: tuple[Mapping[str, Any], ...] + memory_summary_metrics: Mapping[str, Any] + raw: Mapping[str, Any] + + +@dataclass(frozen=True) +class GpuMetricsStatus: + """Typed summary of the gpu-metrics command payload.""" + + summary: Mapping[str, Any] + gpus: tuple[GpuMetricRow, ...] + raw: Mapping[str, Any] + + +@dataclass(frozen=True) +class NtpTarget: + """A ManagerNetworkProtocol resource that can receive an NTP PATCH.""" + + manager: str | None + target: str | None + payload: Mapping[str, Any] + raw: Mapping[str, Any] + + +@dataclass(frozen=True) +class NtpSkipped: + """A ManagerNetworkProtocol resource skipped by the ntp-set command.""" + + manager: str | None + target: str | None + reason: str | None + raw: Mapping[str, Any] + + +@dataclass(frozen=True) +class NtpApplied: + """A ManagerNetworkProtocol PATCH result from the ntp-set command.""" + + manager: str | None + target: str | None + status: str | None + error: Any + raw: Mapping[str, Any] + + +@dataclass(frozen=True) +class NtpSetResult: + """Typed result returned by the guarded ntp-set command.""" + + dry_run: bool + servers: tuple[str, ...] + plan: tuple[NtpTarget, ...] + skipped: tuple[NtpSkipped, ...] + applied: tuple[NtpApplied, ...] + note: str | None + raw: Mapping[str, Any] + + def _invoke( manager: SyncInvoker, api_call: ApiRequestType, @@ -104,6 +180,17 @@ def _rows(data: Any) -> tuple[Mapping[str, Any], ...]: return tuple(row for row in data if isinstance(row, Mapping)) +def _server_list(servers: str | Iterable[str]) -> list[str]: + if isinstance(servers, str): + return [servers] + return list(servers) + + +def _server_tuple(data: Any, fallback: Iterable[str]) -> tuple[str, ...]: + values = data if isinstance(data, list) else list(fallback) + return tuple(str(value) for value in values) + + def get_system(manager: SyncInvoker, *, deep: bool = False) -> SystemStatus: """Return typed ComputerSystem status through the existing system command.""" data = _mapping( @@ -185,3 +272,98 @@ def get_thermal(manager: SyncInvoker) -> ThermalStatus: fans=fans, raw=data, ) + + +def get_gpu_metrics(manager: SyncInvoker) -> GpuMetricsStatus: + """Return typed GPU metric rows through the gpu-metrics command.""" + data = _mapping(_invoke(manager, ApiRequestType.GpuMetrics, "gpu-metrics")) + gpu_rows = tuple( + GpuMetricRow( + system_id=row.get("SystemId"), + gpu_id=row.get("GpuId"), + processor_uri=row.get("ProcessorUri"), + processor_metrics_uri=row.get("ProcessorMetricsUri"), + name=row.get("Name"), + model=row.get("Model"), + manufacturer=row.get("Manufacturer"), + firmware_version=row.get("FirmwareVersion"), + status=_mapping(row.get("Status")), + operating_speed_mhz=row.get("OperatingSpeedMHz"), + temperatures_celsius=_mapping(row.get("TemperaturesCelsius")), + compute_utilization_percent=_mapping( + row.get("ComputeUtilizationPercent") + ), + throttle_duration_seconds=_mapping( + row.get("ThrottleDurationSeconds") + ), + processor_metrics=_mapping(row.get("ProcessorMetrics")), + memory=_rows(row.get("Memory")), + memory_summary_metrics=_mapping(row.get("MemorySummaryMetrics")), + raw=row, + ) + for row in _rows(data.get("gpus")) + ) + return GpuMetricsStatus( + summary=_mapping(data.get("summary")), + gpus=gpu_rows, + raw=data, + ) + + +def set_ntp( + manager: SyncInvoker, + servers: str | Iterable[str], + *, + manager_id: str | None = None, + confirm: bool = False, +) -> NtpSetResult: + """Preview or apply NTP servers through the guarded ntp-set command.""" + requested_servers = _server_list(servers) + data = _mapping( + _invoke( + manager, + ApiRequestType.NtpSet, + "ntp-set", + servers=requested_servers, + manager_id=manager_id, + confirm=confirm, + ) + ) + plan = tuple( + NtpTarget( + manager=row.get("Manager"), + target=row.get("target"), + payload=_mapping(row.get("payload")), + raw=row, + ) + for row in _rows(data.get("plan")) + ) + skipped = tuple( + NtpSkipped( + manager=row.get("Manager"), + target=row.get("target"), + reason=row.get("reason"), + raw=row, + ) + for row in _rows(data.get("skipped")) + ) + applied = tuple( + NtpApplied( + manager=row.get("Manager"), + target=row.get("target"), + status=row.get("status"), + error=row.get("error"), + raw=row, + ) + for row in _rows(data.get("applied")) + ) + note = data.get("note") + return NtpSetResult( + dry_run=bool(data.get("dry_run", False)), + servers=_server_tuple(data.get("servers"), requested_servers), + plan=plan, + skipped=skipped, + applied=applied, + note=note if isinstance(note, str) else None, + raw=data, + ) diff --git a/tests/test_api_facade.py b/tests/test_api_facade.py index 3d309ad..387980a 100644 --- a/tests/test_api_facade.py +++ b/tests/test_api_facade.py @@ -7,13 +7,21 @@ from redfish_ctl.api import ( FanReading, + GpuMetricRow, + GpuMetricsStatus, + NtpApplied, + NtpSetResult, + NtpSkipped, + NtpTarget, SensorReading, SystemStatus, TemperatureReading, ThermalStatus, + get_gpu_metrics, get_sensors, get_system, get_thermal, + set_ntp, ) from redfish_ctl.idrac_manager import IDracManager from redfish_ctl.idrac_shared import ApiRequestType @@ -207,6 +215,199 @@ def test_get_thermal_returns_typed_summary_temperatures_and_fans(): assert manager.calls == [(ApiRequestType.Thermal, "thermal", {})] +def test_get_gpu_metrics_returns_typed_gpu_rows_from_command(): + payload = { + "summary": {"systems": 1, "processors": 2, "gpus": 1}, + "gpus": [ + { + "SystemId": "HGX_Baseboard_0", + "GpuId": "GPU_0", + "ProcessorUri": "/redfish/v1/Systems/HGX/Processors/GPU_0", + "ProcessorMetricsUri": ( + "/redfish/v1/Systems/HGX/Processors/GPU_0/" + "ProcessorMetrics" + ), + "Name": "GPU 0", + "Model": "NVIDIA GB300", + "Manufacturer": "NVIDIA", + "FirmwareVersion": "97.00.00", + "Status": {"Health": "OK", "State": "Enabled"}, + "OperatingSpeedMHz": 2070, + "TemperaturesCelsius": {"GPU_0_TEMP": 32.9}, + "ComputeUtilizationPercent": {"fp32_activity": 0.0}, + "ThrottleDurationSeconds": {"thermal_limit": 0.0}, + "ProcessorMetrics": {"OperatingSpeedMHz": 2070}, + "Memory": [{"MemoryId": "GPU_0_DRAM_0"}], + "MemorySummaryMetrics": {"CapacityUtilizationPercent": 0}, + } + ], + } + manager = RecordingManager({ + (ApiRequestType.GpuMetrics, "gpu-metrics"): CommandResult( + payload, None, None, None) + }) + + metrics = get_gpu_metrics(manager) + + assert metrics == GpuMetricsStatus( + summary=payload["summary"], + gpus=( + GpuMetricRow( + system_id="HGX_Baseboard_0", + gpu_id="GPU_0", + processor_uri="/redfish/v1/Systems/HGX/Processors/GPU_0", + processor_metrics_uri=( + "/redfish/v1/Systems/HGX/Processors/GPU_0/" + "ProcessorMetrics" + ), + name="GPU 0", + model="NVIDIA GB300", + manufacturer="NVIDIA", + firmware_version="97.00.00", + status={"Health": "OK", "State": "Enabled"}, + operating_speed_mhz=2070, + temperatures_celsius={"GPU_0_TEMP": 32.9}, + compute_utilization_percent={"fp32_activity": 0.0}, + throttle_duration_seconds={"thermal_limit": 0.0}, + processor_metrics={"OperatingSpeedMHz": 2070}, + memory=({"MemoryId": "GPU_0_DRAM_0"},), + memory_summary_metrics={"CapacityUtilizationPercent": 0}, + raw=payload["gpus"][0], + ), + ), + raw=payload, + ) + assert manager.calls == [(ApiRequestType.GpuMetrics, "gpu-metrics", {})] + + +def test_set_ntp_returns_typed_dry_run_plan_by_default(): + payload = { + "dry_run": True, + "note": "preview only; re-run with --confirm to apply", + "servers": ["0.pool.ntp.org", "1.pool.ntp.org"], + "plan": [ + { + "Manager": "BMC_0", + "target": "/redfish/v1/Managers/BMC_0/NetworkProtocol", + "payload": { + "NTP": { + "NTPServers": ["0.pool.ntp.org", "1.pool.ntp.org"], + "ProtocolEnabled": True, + } + }, + } + ], + "skipped": [ + { + "Manager": "HGX_BMC_0", + "target": "/redfish/v1/Managers/HGX_BMC_0/NetworkProtocol", + "reason": "NTP block is not available", + } + ], + } + manager = RecordingManager({ + (ApiRequestType.NtpSet, "ntp-set"): CommandResult( + payload, None, None, None) + }) + + result = set_ntp(manager, ["0.pool.ntp.org", "1.pool.ntp.org"]) + + assert result == NtpSetResult( + dry_run=True, + servers=("0.pool.ntp.org", "1.pool.ntp.org"), + plan=( + NtpTarget( + manager="BMC_0", + target="/redfish/v1/Managers/BMC_0/NetworkProtocol", + payload={ + "NTP": { + "NTPServers": ["0.pool.ntp.org", "1.pool.ntp.org"], + "ProtocolEnabled": True, + } + }, + raw=payload["plan"][0], + ), + ), + skipped=( + NtpSkipped( + manager="HGX_BMC_0", + target="/redfish/v1/Managers/HGX_BMC_0/NetworkProtocol", + reason="NTP block is not available", + raw=payload["skipped"][0], + ), + ), + applied=(), + note="preview only; re-run with --confirm to apply", + raw=payload, + ) + assert manager.calls == [ + ( + ApiRequestType.NtpSet, + "ntp-set", + { + "servers": ["0.pool.ntp.org", "1.pool.ntp.org"], + "manager_id": None, + "confirm": False, + }, + ) + ] + + +def test_set_ntp_returns_typed_apply_result_when_confirmed(): + payload = { + "servers": ["0.pool.ntp.org"], + "applied": [ + { + "Manager": "BMC_0", + "target": "/redfish/v1/Managers/BMC_0/NetworkProtocol", + "status": "IdracApiRespond.Ok", + "error": None, + } + ], + "skipped": [], + } + manager = RecordingManager({ + (ApiRequestType.NtpSet, "ntp-set"): CommandResult( + payload, None, None, None) + }) + + result = set_ntp( + manager, + ("0.pool.ntp.org",), + manager_id="BMC_0", + confirm=True, + ) + + assert result == NtpSetResult( + dry_run=False, + servers=("0.pool.ntp.org",), + plan=(), + skipped=(), + applied=( + NtpApplied( + manager="BMC_0", + target="/redfish/v1/Managers/BMC_0/NetworkProtocol", + status="IdracApiRespond.Ok", + error=None, + raw=payload["applied"][0], + ), + ), + note=None, + raw=payload, + ) + assert manager.calls == [ + ( + ApiRequestType.NtpSet, + "ntp-set", + { + "servers": ["0.pool.ntp.org"], + "manager_id": "BMC_0", + "confirm": True, + }, + ) + ] + + def test_facade_wrappers_read_gb300_corpus_through_command_registry( gb300_corpus_manager, ): @@ -216,6 +417,8 @@ def test_facade_wrappers_read_gb300_corpus_through_command_registry( system = get_system(manager) sensors = get_sensors(manager) thermal = get_thermal(manager) + gpu_metrics = get_gpu_metrics(manager) + ntp = set_ntp(manager, ["0.pool.ntp.org"]) assert system.id == "System_0" assert system.name == "System_0" @@ -234,8 +437,40 @@ def test_facade_wrappers_read_gb300_corpus_through_command_registry( and reading.reading_celsius == 24.437 for reading in thermal.temperatures ) + assert gpu_metrics.summary["gpus"] == 4 + assert any( + row.gpu_id == "GPU_0" + and row.model == "NVIDIA GB300" + and row.temperatures_celsius["HGX_GPU_0_TEMP_0"] == 32.9375 + for row in gpu_metrics.gpus + ) + assert ntp.dry_run is True + assert ntp.servers == ("0.pool.ntp.org",) + assert ntp.plan == ( + NtpTarget( + manager="BMC_0", + target="/redfish/v1/Managers/BMC_0/NetworkProtocol", + payload={ + "NTP": { + "NTPServers": ["0.pool.ntp.org"], + "ProtocolEnabled": True, + } + }, + raw=ntp.raw["plan"][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/chassis/chassis_0/thermalsubsystem" in paths + assert ( + "/redfish/v1/systems/hgx_baseboard_0/processors/gpu_0/" + "processormetrics" + ) in paths + assert "/redfish/v1/managers/bmc_0/networkprotocol" in paths + assert { + request.method + for request in requests + if request.method in {"POST", "PATCH", "DELETE"} + } == set()