From: Guillaume Abrioux Date: Thu, 18 Jun 2026 14:54:38 +0000 (+0200) Subject: node-proxy: expose FCM stats X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=e982412a7a1f1ec6fc2fe70d11e839365b50aa91;p=ceph.git node-proxy: expose FCM stats Collect FCM stats locally from NVMe drives (vendor log page 0xCA) and expose them via node-proxy, the cephadm agent, and `ceph orch hardware status --category fcm`. Introduce a node backend that aggregates Redfish data with node-local collectors, since FCM metrics are not available from the BMC. Fixes: https://tracker.ceph.com/issues/77521 Signed-off-by: Guillaume Abrioux --- diff --git a/src/ceph-node-proxy/ceph_node_proxy/api.py b/src/ceph-node-proxy/ceph_node_proxy/api.py index 563ac88c8f7..7df31540afd 100644 --- a/src/ceph-node-proxy/ceph_node_proxy/api.py +++ b/src/ceph-node-proxy/ceph_node_proxy/api.py @@ -98,6 +98,12 @@ class API(Server): def storage(self) -> Dict[str, Any]: return {"storage": self.backend.get_storage()} + @cherrypy.expose + @cherrypy.tools.allow(methods=["GET"]) + @cherrypy.tools.json_out() + def fcm(self) -> Dict[str, Any]: + return {"fcm": self.backend.get_fcm()} + @cherrypy.expose @cherrypy.tools.allow(methods=["GET"]) @cherrypy.tools.json_out() diff --git a/src/ceph-node-proxy/ceph_node_proxy/atollon.py b/src/ceph-node-proxy/ceph_node_proxy/atollon.py index 32aa249b4be..c440535ffb2 100644 --- a/src/ceph-node-proxy/ceph_node_proxy/atollon.py +++ b/src/ceph-node-proxy/ceph_node_proxy/atollon.py @@ -7,7 +7,9 @@ from ceph_node_proxy.util import get_logger INVALID_SERIALS = ("unknown", "not available", "n/a", "") -class AtollonSystem(BaseRedfishSystem): +class AtollonRedfishProvider(BaseRedfishSystem): + """Redfish provider for Atollon BMC quirks.""" + def __init__(self, **kw: Any) -> None: super().__init__(**kw) self.log = get_logger(__name__) @@ -124,3 +126,7 @@ class AtollonSystem(BaseRedfishSystem): speed_gbps = controller.get("SpeedGbps") if speed_gbps is not None: drive_data["speed_gbps"] = speed_gbps + + +# Backward-compatible alias for entry points and external imports. +AtollonSystem = AtollonRedfishProvider diff --git a/src/ceph-node-proxy/ceph_node_proxy/baseredfishsystem.py b/src/ceph-node-proxy/ceph_node_proxy/baseredfishsystem.py index 3da5a0f9398..3d9b0ab97e8 100644 --- a/src/ceph-node-proxy/ceph_node_proxy/baseredfishsystem.py +++ b/src/ceph-node-proxy/ceph_node_proxy/baseredfishsystem.py @@ -139,6 +139,16 @@ class BaseRedfishSystem(BaseSystem): "refresh_interval", DEFAULTS["system"]["refresh_interval"] ) + def initialize_redfish_session(self) -> None: + self.client.login() + self.endpoints.init() + + def run_update_cycle(self) -> None: + self._update_system() + self._update_sn() + with concurrent.futures.ThreadPoolExecutor() as executor: + executor.map(lambda f: f(), self.update_funcs) + def update( self, collection: str, @@ -160,8 +170,7 @@ class BaseRedfishSystem(BaseSystem): def main(self) -> None: self.stop = False - self.client.login() - self.endpoints.init() + self.initialize_redfish_session() while not self.stop: self.log.debug("waiting for a lock in the update loop.") @@ -169,12 +178,7 @@ class BaseRedfishSystem(BaseSystem): if not self.pending_shutdown: self.log.debug("lock acquired in the update loop.") try: - self._update_system() - self._update_sn() - - with concurrent.futures.ThreadPoolExecutor() as executor: - executor.map(lambda f: f(), self.update_funcs) - + self.run_update_cycle() self.data_ready = True except RuntimeError as e: self.stop = True diff --git a/src/ceph-node-proxy/ceph_node_proxy/basesystem.py b/src/ceph-node-proxy/ceph_node_proxy/basesystem.py index aecc0e137c0..405b9d3c1d9 100644 --- a/src/ceph-node-proxy/ceph_node_proxy/basesystem.py +++ b/src/ceph-node-proxy/ceph_node_proxy/basesystem.py @@ -48,6 +48,9 @@ class BaseSystem(BaseThread): def get_storage(self) -> Dict[str, Dict[str, Dict]]: raise NotImplementedError() + def get_fcm(self) -> Dict[str, Dict[str, Dict]]: + return {} + def get_firmware(self) -> Dict[str, Dict[str, Dict]]: raise NotImplementedError() diff --git a/src/ceph-node-proxy/ceph_node_proxy/fcm_stats.py b/src/ceph-node-proxy/ceph_node_proxy/fcm_stats.py new file mode 100644 index 00000000000..9e235760ba9 --- /dev/null +++ b/src/ceph-node-proxy/ceph_node_proxy/fcm_stats.py @@ -0,0 +1,284 @@ +import ctypes +import fcntl +import glob +import os +import struct +from typing import Optional, TypedDict + +from ceph_node_proxy.util import get_logger + +SYSFS_BLOCK = "/sys/block" + +NVME_ADMIN_OPC_GET_LOG_PAGE = 0x02 +NVME_NSID_ALL = 0xFFFFFFFF +FCM_LOG_PAGE_ID = 0xCA +FCM_LOG_PAGE_LEN = 32 +FCM_LOG_PAGE_OFFSET = 280 +FCM_RATIO_DISPLAY_MIN_LOG_UTIL_PERCENT = 0.05 + +_IOC_NRBITS = 8 +_IOC_TYPEBITS = 8 +_IOC_SIZEBITS = 14 +_IOC_DIRBITS = 2 +_IOC_NRSHIFT = 0 +_IOC_TYPESHIFT = _IOC_NRSHIFT + _IOC_NRBITS +_IOC_SIZESHIFT = _IOC_TYPESHIFT + _IOC_TYPEBITS +_IOC_DIRSHIFT = _IOC_SIZESHIFT + _IOC_SIZEBITS + + +def ioc(dir_: int, type_: str, nr: int, size: int) -> int: + return ( + (dir_ << _IOC_DIRSHIFT) + | (ord(type_) << _IOC_TYPESHIFT) + | (nr << _IOC_NRSHIFT) + | (size << _IOC_SIZESHIFT) + ) + + +class _NvmePassthruCmd64(ctypes.Structure): + _fields_ = [ + ("opcode", ctypes.c_uint8), + ("flags", ctypes.c_uint8), + ("rsvd1", ctypes.c_uint16), + ("nsid", ctypes.c_uint32), + ("cdw2", ctypes.c_uint32), + ("cdw3", ctypes.c_uint32), + ("metadata", ctypes.c_uint64), + ("addr", ctypes.c_uint64), + ("metadata_len", ctypes.c_uint32), + ("data_len", ctypes.c_uint32), + ("cdw10", ctypes.c_uint32), + ("cdw11", ctypes.c_uint32), + ("cdw12", ctypes.c_uint32), + ("cdw13", ctypes.c_uint32), + ("cdw14", ctypes.c_uint32), + ("cdw15", ctypes.c_uint32), + ("timeout_ms", ctypes.c_uint32), + ("rsvd2", ctypes.c_uint32), + ("result", ctypes.c_uint64), + ] + + +NVME_IOCTL_ADMIN64_CMD = ioc( + 3, "N", 0x47, ctypes.sizeof(_NvmePassthruCmd64) +) + +logger = get_logger(__name__) + + +class FCMStatsData(TypedDict): + device: str + model: str + serial_number: str + valid: bool + phy_size_bytes: int + phy_util_bytes: int + log_size_bytes: int + log_util_bytes: int + phy_util_percent: float + log_util_percent: float + compression_ratio: float + compression_ratio_str: str + savings_bytes: int + compression_ratio_display: str + savings_display: str + phy_usage_display: str + log_usage_display: str + status: dict[str, str] + + +def read_sysfs(path: str) -> str: + try: + with open(path, encoding="utf-8") as handle: + return handle.read().strip() + except OSError: + return "" + + +def read_sysfs_block(device: str, attribute: str) -> str: + return read_sysfs(os.path.join(SYSFS_BLOCK, device, attribute)) + + +def list_nvme_namespace_names() -> list[str]: + return sorted( + os.path.basename(path) + for path in glob.glob(f"{SYSFS_BLOCK}/nvme*") + if "c" not in os.path.basename(path) + ) + + +def is_fcm_device(device: str) -> bool: + model = read_sysfs_block(device, "device/model") + return "FCM" in model + + +def query_nvme_log_page(device: str) -> Optional[bytes]: + device_path = f"/dev/{device}" + numd = (FCM_LOG_PAGE_LEN // 4) - 1 + numdl = numd & 0xFFFF + numdu = (numd >> 16) & 0xFFFF + + buffer = (ctypes.c_uint8 * FCM_LOG_PAGE_LEN)() + command = _NvmePassthruCmd64() + command.opcode = NVME_ADMIN_OPC_GET_LOG_PAGE + command.nsid = NVME_NSID_ALL + command.addr = ctypes.addressof(buffer) + command.data_len = FCM_LOG_PAGE_LEN + command.cdw10 = FCM_LOG_PAGE_ID | (numdl << 16) + command.cdw11 = numdu + command.cdw12 = FCM_LOG_PAGE_OFFSET & 0xFFFFFFFF + command.cdw13 = (FCM_LOG_PAGE_OFFSET >> 32) & 0xFFFFFFFF + + fd = os.open(device_path, os.O_RDONLY) + try: + fcntl.ioctl(fd, NVME_IOCTL_ADMIN64_CMD, command) + except OSError as exc: + logger.debug( + "NVMe log page 0xCA query failed for device %s: %s", + device, + exc, + ) + return None + finally: + os.close(fd) + + if command.result: + logger.debug( + "NVMe log page 0xCA returned non-zero status 0x%x for device %s", + command.result, + device, + ) + return None + + return bytes(buffer) + + +def compression_ratio_str(ratio: float) -> str: + ratio_text = f"{ratio:.1f}" + if ratio_text.endswith("0"): + return f"{int(ratio)}:1" + return f"{ratio_text}:1" + + +def human_readable(capacity: int, dec_places: int = 1) -> str: + suffixes = ["b", "KB", "MB", "GB", "TB", "PB"] + size = float(capacity) + unit = suffixes[0] + for unit in suffixes: + if size < 1000: + break + size /= 1000 + return f"{size:.{dec_places}f} {unit}" + + +def fcm_usage_display(util_bytes: int, util_percent: float) -> str: + return f"{human_readable(util_bytes)}({int(util_percent)}%)" + + +def empty_fcm_display_fields() -> dict[str, str]: + return { + "compression_ratio_display": "", + "savings_display": "", + "phy_usage_display": "", + "log_usage_display": "", + } + + +def apply_fcm_display_fields(stats: FCMStatsData) -> FCMStatsData: + display = empty_fcm_display_fields() + if not stats["valid"]: + return {**stats, **display} + + display["savings_display"] = human_readable(stats["savings_bytes"]) + display["phy_usage_display"] = fcm_usage_display( + stats["phy_util_bytes"], stats["phy_util_percent"] + ) + display["log_usage_display"] = fcm_usage_display( + stats["log_util_bytes"], stats["log_util_percent"] + ) + if stats["log_util_percent"] > FCM_RATIO_DISPLAY_MIN_LOG_UTIL_PERCENT: + display["compression_ratio_display"] = stats["compression_ratio_str"] + return {**stats, **display} + + +def read_fcm_stats(device: str) -> FCMStatsData: + model = read_sysfs_block(device, "device/model") + serial_number = read_sysfs_block(device, "device/serial") + invalid = apply_fcm_display_fields({ + "device": device, + "model": model, + "serial_number": serial_number, + "valid": False, + "phy_size_bytes": 0, + "phy_util_bytes": 0, + "log_size_bytes": 0, + "log_util_bytes": 0, + "phy_util_percent": 0.0, + "log_util_percent": 0.0, + "compression_ratio": 0.0, + "compression_ratio_str": "", + "savings_bytes": 0, + "status": {"health": "Unknown", "state": "Unavailable"}, + "compression_ratio_display": "", + "savings_display": "", + "phy_usage_display": "", + "log_usage_display": "", + }) + + raw_bytes = query_nvme_log_page(device) + if raw_bytes is None: + return invalid + + try: + phy_size_bytes = struct.unpack(" dict[str, FCMStatsData]: + stats: dict[str, FCMStatsData] = {} + for device in list_nvme_namespace_names(): + if not is_fcm_device(device): + continue + stats[device] = read_fcm_stats(device) + return stats diff --git a/src/ceph-node-proxy/ceph_node_proxy/local_collectors.py b/src/ceph-node-proxy/ceph_node_proxy/local_collectors.py new file mode 100644 index 00000000000..1be1b231544 --- /dev/null +++ b/src/ceph-node-proxy/ceph_node_proxy/local_collectors.py @@ -0,0 +1,66 @@ +from abc import ABC, abstractmethod +from typing import Any, Dict + +from ceph_node_proxy.fcm_stats import collect_fcm_stats + + +class LocalCollector(ABC): + """Collects node-local hardware metrics outside of Redfish.""" + + @property + @abstractmethod + def name(self) -> str: + """Category name exposed in node-proxy reports.""" + + @abstractmethod + def update(self) -> None: + """Refresh collector data from the local OS.""" + + @abstractmethod + def get_data(self) -> Dict[str, Any]: + """Return the latest collected data.""" + + def flush(self) -> None: + """Clear cached collector data.""" + + +class LocalCollectorRunner: + def __init__(self, collectors: list[LocalCollector]) -> None: + self._collectors = collectors + + def update(self) -> None: + for collector in self._collectors: + collector.update() + + def flush(self) -> None: + for collector in self._collectors: + collector.flush() + + def categories(self) -> list[str]: + return [collector.name for collector in self._collectors] + + def get_category(self, name: str) -> Dict[str, Any]: + for collector in self._collectors: + if collector.name == name: + return collector.get_data() + return {} + + +class FCMCollector(LocalCollector): + """Collect FCM stats via NVMe ioctls.""" + + def __init__(self) -> None: + self._data: Dict[str, Any] = {} + + @property + def name(self) -> str: + return "fcm" + + def update(self) -> None: + self._data = {"local": collect_fcm_stats()} + + def get_data(self) -> Dict[str, Any]: + return dict(self._data) + + def flush(self) -> None: + self._data = {} diff --git a/src/ceph-node-proxy/ceph_node_proxy/main.py b/src/ceph-node-proxy/ceph_node_proxy/main.py index 6f0ab0b593d..775626af216 100644 --- a/src/ceph-node-proxy/ceph_node_proxy/main.py +++ b/src/ceph-node-proxy/ceph_node_proxy/main.py @@ -11,7 +11,7 @@ from urllib.error import HTTPError from ceph_node_proxy.api import NodeProxyApi from ceph_node_proxy.bootstrap import create_node_proxy_manager from ceph_node_proxy.config import load_cephadm_config -from ceph_node_proxy.registry import get_system_class +from ceph_node_proxy.registry import create_node_backend from ceph_node_proxy.reporter import Reporter from ceph_node_proxy.util import DEFAULTS, Config, get_logger, http_req @@ -105,8 +105,8 @@ class NodeProxyManager: try: vendor = self.config.get("system", {}).get("vendor", "generic") self.log.info("Using Redfish vendor: %s", vendor) - system_cls = get_system_class(vendor) - self.system = system_cls( + self.system = create_node_backend( + vendor, host=oob_details["host"], port=oob_details["port"], username=oob_details["username"], diff --git a/src/ceph-node-proxy/ceph_node_proxy/node_backend.py b/src/ceph-node-proxy/ceph_node_proxy/node_backend.py new file mode 100644 index 00000000000..e7549e5913e --- /dev/null +++ b/src/ceph-node-proxy/ceph_node_proxy/node_backend.py @@ -0,0 +1,143 @@ +from time import sleep +from typing import Any, Dict + +from ceph_node_proxy.baseredfishsystem import BaseRedfishSystem +from ceph_node_proxy.basesystem import BaseSystem +from ceph_node_proxy.local_collectors import LocalCollectorRunner +from ceph_node_proxy.util import get_logger + + +class NodeBackend(BaseSystem): + def __init__( + self, + redfish: BaseRedfishSystem, + local_collectors: LocalCollectorRunner, + ) -> None: + super().__init__(config=redfish.config) + self.redfish = redfish + self._local = local_collectors + self.data_ready = False + self.previous_data: Dict[str, Any] = {} + self.log = get_logger(__name__) + + @property + def client(self) -> Any: + return self.redfish.client + + def main(self) -> None: + self.stop = False + self.redfish.stop = False + self.redfish.initialize_redfish_session() + + while not self.stop: + self.log.debug("waiting for a lock in the update loop.") + with self.lock: + if not self.pending_shutdown: + self.log.debug("lock acquired in the update loop.") + try: + self.redfish.run_update_cycle() + self._local.update() + self.data_ready = True + except RuntimeError as exc: + self.stop = True + self.redfish.stop = True + self.log.error( + "Error detected, trying to gracefully log out from " + "redfish api.\n%s", + exc, + ) + self.redfish.client.logout() + raise + sleep(self.redfish.refresh_interval) + self.log.debug("lock released in the update loop.") + self.log.debug("exiting update loop.") + raise SystemExit(0) + + def flush(self) -> None: + self.log.debug("Acquiring lock to flush data.") + self.lock.acquire() + self.log.debug("Lock acquired, flushing data.") + self.redfish.flush() + self._local.flush() + self.previous_data = {} + self.data_ready = False + self.log.info("Data flushed.") + self.lock.release() + + def get_system(self) -> Dict[str, Any]: + result = self.redfish.get_system() + for category in self._local.categories(): + result["status"][category] = self._local.get_category(category) + return result + + def get_local_category(self, name: str) -> Dict[str, Any]: + return self._local.get_category(name) + + def get_fcm(self) -> Dict[str, Any]: + return self.get_local_category("fcm") + + def get_status(self) -> Dict[str, Dict[str, Dict]]: + return dict(self.redfish.get_status()) + + def get_memory(self) -> Dict[str, Dict[str, Dict]]: + return self.redfish.get_memory() + + def get_processors(self) -> Dict[str, Dict[str, Dict]]: + return self.redfish.get_processors() + + def get_network(self) -> Dict[str, Dict[str, Dict]]: + return self.redfish.get_network() + + def get_storage(self) -> Dict[str, Dict[str, Dict]]: + return self.redfish.get_storage() + + def get_firmware(self) -> Dict[str, Dict[str, Dict]]: + return self.redfish.get_firmware() + + def get_power(self) -> Dict[str, Dict[str, Dict]]: + return self.redfish.get_power() + + def get_fans(self) -> Dict[str, Dict[str, Dict]]: + return self.redfish.get_fans() + + def get_temperatures(self) -> Dict[str, Dict[str, Dict]]: + return self.redfish.get_temperatures() + + def get_sn(self) -> str: + return self.redfish.get_sn() + + def get_led(self) -> Dict[str, Any]: + return self.redfish.get_led() + + def set_led(self, data: Dict[str, str]) -> int: + return self.redfish.set_led(data) + + def get_chassis_led(self) -> Dict[str, Any]: + return self.redfish.get_chassis_led() + + def set_chassis_led(self, data: Dict[str, str]) -> int: + return self.redfish.set_chassis_led(data) + + def device_led_on(self, device: str) -> int: + return self.redfish.device_led_on(device) + + def device_led_off(self, device: str) -> int: + return self.redfish.device_led_off(device) + + def get_device_led(self, device: str) -> Dict[str, Any]: + return self.redfish.get_device_led(device) + + def set_device_led(self, device: str, data: Dict[str, bool]) -> int: + return self.redfish.set_device_led(device, data) + + def chassis_led_on(self) -> int: + return self.redfish.chassis_led_on() + + def chassis_led_off(self) -> int: + return self.redfish.chassis_led_off() + + def shutdown_host(self, force: bool = False) -> int: + return self.redfish.shutdown_host(force=force) + + def powercycle(self) -> int: + return self.redfish.powercycle() diff --git a/src/ceph-node-proxy/ceph_node_proxy/protocols.py b/src/ceph-node-proxy/ceph_node_proxy/protocols.py index e82d425bea5..85f76b48360 100644 --- a/src/ceph-node-proxy/ceph_node_proxy/protocols.py +++ b/src/ceph-node-proxy/ceph_node_proxy/protocols.py @@ -10,6 +10,8 @@ class SystemBackend(Protocol): def get_storage(self) -> Dict[str, Any]: ... + def get_fcm(self) -> Dict[str, Any]: ... + def get_processors(self) -> Dict[str, Any]: ... def get_power(self) -> Dict[str, Any]: ... diff --git a/src/ceph-node-proxy/ceph_node_proxy/registry.py b/src/ceph-node-proxy/ceph_node_proxy/registry.py index 6c9436bd2f9..22eecb64658 100644 --- a/src/ceph-node-proxy/ceph_node_proxy/registry.py +++ b/src/ceph-node-proxy/ceph_node_proxy/registry.py @@ -1,43 +1,84 @@ -from typing import Dict, Type +from typing import Dict, List, Type -# Built-in implementations -from ceph_node_proxy.atollon import AtollonSystem +from ceph_node_proxy.atollon import AtollonRedfishProvider from ceph_node_proxy.baseredfishsystem import BaseRedfishSystem +from ceph_node_proxy.local_collectors import FCMCollector, LocalCollector, LocalCollectorRunner +from ceph_node_proxy.node_backend import NodeBackend from ceph_node_proxy.redfishdellsystem import RedfishDellSystem -from ceph_node_proxy.util import get_logger +from ceph_node_proxy.util import Config, get_logger -REDFISH_SYSTEM_CLASSES: Dict[str, Type[BaseRedfishSystem]] = { +REDFISH_PROVIDER_CLASSES: Dict[str, Type[BaseRedfishSystem]] = { "generic": BaseRedfishSystem, "dell": RedfishDellSystem, - "atollon": AtollonSystem, + "atollon": AtollonRedfishProvider, +} + +LOCAL_COLLECTOR_CLASSES_BY_VENDOR: Dict[str, List[Type[LocalCollector]]] = { + "atollon": [FCMCollector], } logger = get_logger(__name__) -def _load_entry_point_systems() -> None: +def _load_entry_point_redfish_providers() -> None: try: import pkg_resources # type: ignore[import-not-found] except ImportError: logger.debug( - "pkg_resources not available; only built-in Redfish systems will be used." + "pkg_resources not available; only built-in Redfish providers will be used." ) return for ep in pkg_resources.iter_entry_points("ceph_node_proxy.systems"): try: - REDFISH_SYSTEM_CLASSES[ep.name] = ep.load() - except (ImportError, AttributeError, ModuleNotFoundError) as e: + REDFISH_PROVIDER_CLASSES[ep.name] = ep.load() + except (ImportError, AttributeError, ModuleNotFoundError) as exc: logger.warning( - "Failed to load Redfish system entry point %s: %s", ep.name, e + "Failed to load Redfish provider entry point %s: %s", ep.name, exc ) +def get_redfish_provider_class(vendor: str) -> Type[BaseRedfishSystem]: + """Return the Redfish provider class for the given vendor.""" + return REDFISH_PROVIDER_CLASSES.get(vendor, BaseRedfishSystem) + + def get_system_class(vendor: str) -> Type[BaseRedfishSystem]: - """Return the Redfish system class for the given vendor. - Falls back to generic.""" - return REDFISH_SYSTEM_CLASSES.get(vendor, BaseRedfishSystem) + """Deprecated alias for get_redfish_provider_class.""" + return get_redfish_provider_class(vendor) + + +def create_local_collector_runner(vendor: str) -> LocalCollectorRunner: + """Build the local collector runner configured for a vendor.""" + collector_classes = LOCAL_COLLECTOR_CLASSES_BY_VENDOR.get(vendor, []) + collectors = [collector_cls() for collector_cls in collector_classes] + return LocalCollectorRunner(collectors) + + +def create_node_backend( + vendor: str, + *, + host: str, + port: str, + username: str, + password: str, + config: Config, +) -> NodeBackend: + """Create the node backend facade for a vendor.""" + provider_cls = get_redfish_provider_class(vendor) + redfish = provider_cls( + host=host, + port=port, + username=username, + password=password, + config=config, + ) + local_collectors = create_local_collector_runner(vendor) + return NodeBackend(redfish, local_collectors) + +_load_entry_point_redfish_providers() -_load_entry_point_systems() +# Backward-compatible alias used by older imports and entry point docs. +REDFISH_SYSTEM_CLASSES = REDFISH_PROVIDER_CLASSES diff --git a/src/ceph-node-proxy/tests/test_atollon.py b/src/ceph-node-proxy/tests/test_atollon.py index 7c4b9a179e1..c2b1fea3df4 100644 --- a/src/ceph-node-proxy/tests/test_atollon.py +++ b/src/ceph-node-proxy/tests/test_atollon.py @@ -2,17 +2,17 @@ from unittest.mock import MagicMock, patch import pytest -from ceph_node_proxy.atollon import AtollonSystem +from ceph_node_proxy.atollon import AtollonRedfishProvider from ceph_node_proxy.baseredfishsystem import BaseRedfishSystem @pytest.fixture -def atollon_system(): +def atollon_redfish(): with ( patch("ceph_node_proxy.baseredfishsystem.RedFishClient", return_value=MagicMock()), patch("ceph_node_proxy.baseredfishsystem.EndpointMgr", return_value=MagicMock()), ): - return AtollonSystem( + return AtollonRedfishProvider( host="testhost", port="443", username="user", @@ -21,16 +21,16 @@ def atollon_system(): ) -class TestAtollonSystemMemoryOverrides: - def test_get_specs_memory_uses_id_instead_of_description(self, atollon_system): - specs = atollon_system.get_specs("memory") +class TestAtollonRedfishProviderMemoryOverrides: + def test_get_specs_memory_uses_id_instead_of_description(self, atollon_redfish): + specs = atollon_redfish.get_specs("memory") assert len(specs) == 1 assert "Id" in specs[0].fields assert "Description" not in specs[0].fields assert "MemoryDeviceType" in specs[0].fields - def test_update_memory_maps_id_to_description(self, atollon_system): - atollon_system._sys["memory"] = { + def test_update_memory_maps_id_to_description(self, atollon_redfish): + atollon_redfish._sys["memory"] = { "1": { "dimm0": { "id": "dimm0", @@ -43,14 +43,14 @@ class TestAtollonSystemMemoryOverrides: with patch.object(BaseRedfishSystem, "_update_memory") as mock_super: mock_super.side_effect = lambda: None - atollon_system._update_memory() + atollon_redfish._update_memory() - assert atollon_system._sys["memory"]["1"]["dimm0"]["description"] == "dimm0" + assert atollon_redfish._sys["memory"]["1"]["dimm0"]["description"] == "dimm0" -class TestAtollonSystemStorageOverrides: - def test_update_storage_replaces_unknown_description(self, atollon_system): - atollon_system._sys["storage"] = { +class TestAtollonRedfishProviderStorageOverrides: + def test_update_storage_replaces_unknown_description(self, atollon_redfish): + atollon_redfish._sys["storage"] = { "Self": { "nvme_device0_nsid1": { "description": "unknown", @@ -70,13 +70,13 @@ class TestAtollonSystemStorageOverrides: with patch.object(BaseRedfishSystem, "_update_storage") as mock_super: mock_super.side_effect = lambda: None - atollon_system.fix_storage_descriptions() + atollon_redfish.fix_storage_descriptions() - drive = atollon_system._sys["storage"]["Self"]["nvme_device0_nsid1"] + drive = atollon_redfish._sys["storage"]["Self"]["nvme_device0_nsid1"] assert drive["description"] == "NVMe_Device0_NSID1" - def test_enrich_storage_from_controllers_by_serial(self, atollon_system): - atollon_system._sys["storage"] = { + def test_enrich_storage_from_controllers_by_serial(self, atollon_redfish): + atollon_redfish._sys["storage"] = { "Self": { "nvme_device0_nsid1": { "description": "NVMe_Device0_NSID1", @@ -109,21 +109,21 @@ class TestAtollonSystemStorageOverrides: systems_endpoint.__getitem__ = MagicMock( side_effect=lambda key: member_endpoint if key == "Self" else MagicMock() ) - atollon_system.endpoints = MagicMock() - atollon_system.endpoints.__getitem__ = MagicMock( + atollon_redfish.endpoints = MagicMock() + atollon_redfish.endpoints.__getitem__ = MagicMock( side_effect=lambda key: systems_endpoint if key == "systems" else MagicMock() ) - atollon_system.enrich_storage_from_controllers() + atollon_redfish.enrich_storage_from_controllers() - drive = atollon_system._sys["storage"]["Self"]["nvme_device0_nsid1"] + drive = atollon_redfish._sys["storage"]["Self"]["nvme_device0_nsid1"] assert drive["firmware_version"] == "V6MA001" assert drive["slot"] == "0" assert drive["speed_gbps"] == 63.02 assert drive["physical_location"]["partlocation"]["locationordinalvalue"] == 0 - def test_enrich_storage_from_controllers_by_device_index(self, atollon_system): - atollon_system._sys["storage"] = { + def test_enrich_storage_from_controllers_by_device_index(self, atollon_redfish): + atollon_redfish._sys["storage"] = { "Self": { "nvme_device2_nsid1": { "description": "NVMe_Device2_NSID1", @@ -153,13 +153,13 @@ class TestAtollonSystemStorageOverrides: systems_endpoint.__getitem__ = MagicMock( side_effect=lambda key: member_endpoint if key == "Self" else MagicMock() ) - atollon_system.endpoints = MagicMock() - atollon_system.endpoints.__getitem__ = MagicMock( + atollon_redfish.endpoints = MagicMock() + atollon_redfish.endpoints.__getitem__ = MagicMock( side_effect=lambda key: systems_endpoint if key == "systems" else MagicMock() ) - atollon_system.enrich_storage_from_controllers() + atollon_redfish.enrich_storage_from_controllers() - drive = atollon_system._sys["storage"]["Self"]["nvme_device2_nsid1"] + drive = atollon_redfish._sys["storage"]["Self"]["nvme_device2_nsid1"] assert drive["firmware_version"] == "000A5305" assert drive["slot"] == "2" diff --git a/src/ceph-node-proxy/tests/test_baseredfishsystem.py b/src/ceph-node-proxy/tests/test_baseredfishsystem.py index 28a90521406..05102ca1445 100644 --- a/src/ceph-node-proxy/tests/test_baseredfishsystem.py +++ b/src/ceph-node-proxy/tests/test_baseredfishsystem.py @@ -140,8 +140,12 @@ class TestBaseRedfishSystemGetSystem: assert result["status"]["power"] == {} assert result["status"]["fans"] == {} assert result["status"]["temperatures"] == {} + assert "fcm" not in result["status"] assert "firmware" in result + def test_get_fcm_empty(self, system): + assert system.get_fcm() == {} + class TestBaseRedfishSystemGetSpecs: diff --git a/src/ceph-node-proxy/tests/test_fcm_stats.py b/src/ceph-node-proxy/tests/test_fcm_stats.py new file mode 100644 index 00000000000..686cb948de8 --- /dev/null +++ b/src/ceph-node-proxy/tests/test_fcm_stats.py @@ -0,0 +1,196 @@ +import struct +from unittest.mock import patch + +from ceph_node_proxy.fcm_stats import ( + apply_fcm_display_fields, + collect_fcm_stats, + fcm_usage_display, + is_fcm_device, + read_fcm_stats, +) + + +class TestFCMStatsHelpers: + def test_is_fcm_device_true(self): + with patch( + "ceph_node_proxy.fcm_stats.read_sysfs_block", + return_value="IBM FCM5 3.2TB", + ): + assert is_fcm_device("nvme2n1") is True + + def test_is_fcm_device_false(self): + with patch( + "ceph_node_proxy.fcm_stats.read_sysfs_block", + return_value="Micron_2550_MTFDKBK512TGE", + ): + assert is_fcm_device("nvme0n1") is False + + +class TestReadFCMStats: + def _sample_log_page(self) -> bytes: + return struct.pack( + " Dict[str, Any]: + try: + results = self.mgr.node_proxy_cache.common('fcm', **kw) + except (KeyError, OrchestratorError): + raise cherrypy.HTTPError(404, f"{kw.get('hostname')} not found.") + return results + @cherrypy.expose @cherrypy.tools.allow(methods=['GET']) @cherrypy.tools.json_out() diff --git a/src/pybind/mgr/cephadm/tests/node_proxy_data.py b/src/pybind/mgr/cephadm/tests/node_proxy_data.py index 3c01582d756..8e4230fe15f 100644 --- a/src/pybind/mgr/cephadm/tests/node_proxy_data.py +++ b/src/pybind/mgr/cephadm/tests/node_proxy_data.py @@ -1,3 +1,3 @@ full_set_with_critical = {'host': 'host01', 'sn': '12345', 'status': {'storage': {'1': {'disk.bay.0:enclosure.internal.0-1:raid.integrated.1-1': {'description': 'Solid State Disk 0:1:0', 'entity': 'RAID.Integrated.1-1', 'capacity_bytes': 959656755200, 'model': 'KPM5XVUG960G', 'protocol': 'SAS', 'serial_number': '8080A1CRTP5F', 'status': {'health': 'Critical', 'healthrollup': 'OK', 'state': 'Enabled'}, 'physical_location': {'partlocation': {'locationordinalvalue': 0, 'locationtype': 'Slot'}}}, 'disk.bay.9:enclosure.internal.0-1': {'description': 'PCIe SSD in Slot 9 in Bay 1', 'entity': 'CPU.1', 'capacity_bytes': 1600321314816, 'model': 'Dell Express Flash NVMe P4610 1.6TB SFF', 'protocol': 'PCIe', 'serial_number': 'PHLN035305MN1P6AGN', 'status': {'health': 'Critical', 'healthrollup': 'OK', 'state': 'Enabled'}, 'physical_location': {'partlocation': {'locationordinalvalue': 9, 'locationtype': 'Slot'}}}}}, 'processors': {'1': {'cpu.socket.2': {'description': 'Represents the properties of a Processor attached to this System', 'total_cores': 20, 'total_threads': 40, 'processor_type': 'CPU', 'model': 'Intel(R) Xeon(R) Gold 6230 CPU @ 2.10GHz', 'status': {'health': 'OK', 'state': 'Enabled'}, 'manufacturer': 'Intel'}}}, 'network': {'1': {'nic.slot.1-1-1': {'description': 'NIC in Slot 1 Port 1 Partition 1', 'name': 'System Ethernet Interface', 'speed_mbps': 0, 'status': {'health': 'OK', 'state': 'StandbyOffline'}}}}, 'memory': {'1': {'dimm.socket.a1': {'description': 'DIMM A1', 'memory_device_type': 'DDR4', 'capacity_mi_b': 31237, 'status': {'health': 'Critical', 'state': 'Enabled'}}}}}, 'firmware': {}} mgr_inventory_cache = {'host01': {'hostname': 'host01', 'addr': '10.10.10.11', 'labels': ['_admin'], 'status': '', 'oob': {'hostname': '10.10.10.11', 'username': 'root', 'password': 'ceph123'}}, 'host02': {'hostname': 'host02', 'addr': '10.10.10.12', 'labels': [], 'status': '', 'oob': {'hostname': '10.10.10.12', 'username': 'root', 'password': 'ceph123'}}} -full_set = {'host01': {'host': 'host01', 'sn': 'FR8Y5X3', 'status': {'storage': {'1': {'disk.bay.8:enclosure.internal.0-1:nonraid.slot.2-1': {'description': 'Disk 8 in Backplane 1 of Storage Controller in Slot 2', 'entity': 'NonRAID.Slot.2-1', 'capacity_bytes': 20000588955136, 'model': 'ST20000NM008D-3D', 'protocol': 'SATA', 'serial_number': 'ZVT99QLL', 'status': {'health': 'OK', 'healthrollup': 'OK', 'state': 'Enabled'}, 'physical_location': {'partlocation': {'locationordinalvalue': 8, 'locationtype': 'Slot'}}}}}, 'processors': {'1': {'cpu.socket.2': {'description': 'Represents the properties of a Processor attached to this System', 'total_cores': 16, 'total_threads': 32, 'processor_type': 'CPU', 'model': 'Intel(R) Xeon(R) Silver 4314 CPU @ 2.40GHz', 'status': {'health': 'OK', 'state': 'Enabled'}, 'manufacturer': 'Intel'}, 'cpu.socket.1': {'description': 'Represents the properties of a Processor attached to this System', 'total_cores': 16, 'total_threads': 32, 'processor_type': 'CPU', 'model': 'Intel(R) Xeon(R) Silver 4314 CPU @ 2.40GHz', 'status': {'health': 'OK', 'state': 'Enabled'}, 'manufacturer': 'Intel'}}}, 'network': {'1': {'oslogicalnetwork.2': {'description': 'eno8303', 'name': 'eno8303', 'speed_mbps': 0, 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'memory': {'1': {'dimm.socket.a1': {'description': 'DIMM A1', 'memory_device_type': 'DDR4', 'capacity_mi_b': 16384, 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'power': {'1': {'0': {'name': 'PS1 Status', 'model': 'PWR SPLY,800W,RDNT,LTON', 'manufacturer': 'DELL', 'status': {'health': 'OK', 'state': 'Enabled'}}, '1': {'name': 'PS2 Status', 'model': 'PWR SPLY,800W,RDNT,LTON', 'manufacturer': 'DELL', 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'fans': {'1': {'0': {'name': 'System Board Fan1A', 'physical_context': 'SystemBoard', 'reading': 4800, 'reading_units': 'RPM', 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'temperatures': {'1': {'0': {'name': 'System Board Inlet Temp', 'physical_context': 'Intake', 'reading': 24, 'reading_units': 'Cel', 'status': {'health': 'OK', 'state': 'Enabled'}}}}}, 'firmware': {'installed-28897-6.10.30.20__usc.embedded.1:lc.embedded.1': {'name': 'Lifecycle Controller', 'description': 'Represents Firmware Inventory', 'release_date': '00:00:00Z', 'version': '6.10.30.20', 'updateable': True, 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'host02': {'host': 'host02', 'sn': 'FR8Y5X4', 'status': {'storage': {'1': {'disk.bay.8:enclosure.internal.0-1:nonraid.slot.2-1': {'description': 'Disk 8 in Backplane 1 of Storage Controller in Slot 2', 'entity': 'NonRAID.Slot.2-1', 'capacity_bytes': 20000588955136, 'model': 'ST20000NM008D-3D', 'protocol': 'SATA', 'serial_number': 'ZVT99QLL', 'status': {'health': 'OK', 'healthrollup': 'OK', 'state': 'Enabled'}, 'physical_location': {'partlocation': {'locationordinalvalue': 8, 'locationtype': 'Slot'}}}}}, 'processors': {'1': {'cpu.socket.2': {'description': 'Represents the properties of a Processor attached to this System', 'total_cores': 16, 'total_threads': 32, 'processor_type': 'CPU', 'model': 'Intel(R) Xeon(R) Silver 4314 CPU @ 2.40GHz', 'status': {'health': 'OK', 'state': 'Enabled'}, 'manufacturer': 'Intel'}, 'cpu.socket.1': {'description': 'Represents the properties of a Processor attached to this System', 'total_cores': 16, 'total_threads': 32, 'processor_type': 'CPU', 'model': 'Intel(R) Xeon(R) Silver 4314 CPU @ 2.40GHz', 'status': {'health': 'OK', 'state': 'Enabled'}, 'manufacturer': 'Intel'}}}, 'network': {'1': {'oslogicalnetwork.2': {'description': 'eno8303', 'name': 'eno8303', 'speed_mbps': 0, 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'memory': {'1': {'dimm.socket.a1': {'description': 'DIMM A1', 'memory_device_type': 'DDR4', 'capacity_mi_b': 16384, 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'power': {'1': {'0': {'name': 'PS1 Status', 'model': 'PWR SPLY,800W,RDNT,LTON', 'manufacturer': 'DELL', 'status': {'health': 'OK', 'state': 'Enabled'}}, '1': {'name': 'PS2 Status', 'model': 'PWR SPLY,800W,RDNT,LTON', 'manufacturer': 'DELL', 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'fans': {'1': {'0': {'name': 'System Board Fan1A', 'physical_context': 'SystemBoard', 'reading': 4800, 'reading_units': 'RPM', 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'temperatures': {'1': {'0': {'name': 'System Board Inlet Temp', 'physical_context': 'Intake', 'reading': 24, 'reading_units': 'Cel', 'status': {'health': 'OK', 'state': 'Enabled'}}}}}, 'firmware': {'installed-28897-6.10.30.20__usc.embedded.1:lc.embedded.1': {'name': 'Lifecycle Controller', 'description': 'Represents Firmware Inventory', 'release_date': '00:00:00Z', 'version': '6.10.30.20', 'updateable': True, 'status': {'health': 'OK', 'state': 'Enabled'}}}}} +full_set = {'host01': {'host': 'host01', 'sn': 'FR8Y5X3', 'status': {'storage': {'1': {'disk.bay.8:enclosure.internal.0-1:nonraid.slot.2-1': {'description': 'Disk 8 in Backplane 1 of Storage Controller in Slot 2', 'entity': 'NonRAID.Slot.2-1', 'capacity_bytes': 20000588955136, 'model': 'ST20000NM008D-3D', 'protocol': 'SATA', 'serial_number': 'ZVT99QLL', 'status': {'health': 'OK', 'healthrollup': 'OK', 'state': 'Enabled'}, 'physical_location': {'partlocation': {'locationordinalvalue': 8, 'locationtype': 'Slot'}}}}}, 'processors': {'1': {'cpu.socket.2': {'description': 'Represents the properties of a Processor attached to this System', 'total_cores': 16, 'total_threads': 32, 'processor_type': 'CPU', 'model': 'Intel(R) Xeon(R) Silver 4314 CPU @ 2.40GHz', 'status': {'health': 'OK', 'state': 'Enabled'}, 'manufacturer': 'Intel'}, 'cpu.socket.1': {'description': 'Represents the properties of a Processor attached to this System', 'total_cores': 16, 'total_threads': 32, 'processor_type': 'CPU', 'model': 'Intel(R) Xeon(R) Silver 4314 CPU @ 2.40GHz', 'status': {'health': 'OK', 'state': 'Enabled'}, 'manufacturer': 'Intel'}}}, 'network': {'1': {'oslogicalnetwork.2': {'description': 'eno8303', 'name': 'eno8303', 'speed_mbps': 0, 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'memory': {'1': {'dimm.socket.a1': {'description': 'DIMM A1', 'memory_device_type': 'DDR4', 'capacity_mi_b': 16384, 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'power': {'1': {'0': {'name': 'PS1 Status', 'model': 'PWR SPLY,800W,RDNT,LTON', 'manufacturer': 'DELL', 'status': {'health': 'OK', 'state': 'Enabled'}}, '1': {'name': 'PS2 Status', 'model': 'PWR SPLY,800W,RDNT,LTON', 'manufacturer': 'DELL', 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'fans': {'1': {'0': {'name': 'System Board Fan1A', 'physical_context': 'SystemBoard', 'reading': 4800, 'reading_units': 'RPM', 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'temperatures': {'1': {'0': {'name': 'System Board Inlet Temp', 'physical_context': 'Intake', 'reading': 24, 'reading_units': 'Cel', 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'fcm': {'local': {'nvme2n1': {'device': 'nvme2n1', 'model': 'IBM FCM5 3.2TB', 'serial_number': '03NK797YS344D57S056', 'valid': True, 'compression_ratio': 2.5, 'compression_ratio_str': '2.5:1', 'savings_bytes': 1000000, 'phy_util_percent': 50.0, 'log_util_percent': 25.0, 'status': {'health': 'OK', 'state': 'Enabled'}}}}}, 'firmware': {'installed-28897-6.10.30.20__usc.embedded.1:lc.embedded.1': {'name': 'Lifecycle Controller', 'description': 'Represents Firmware Inventory', 'release_date': '00:00:00Z', 'version': '6.10.30.20', 'updateable': True, 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'host02': {'host': 'host02', 'sn': 'FR8Y5X4', 'status': {'storage': {'1': {'disk.bay.8:enclosure.internal.0-1:nonraid.slot.2-1': {'description': 'Disk 8 in Backplane 1 of Storage Controller in Slot 2', 'entity': 'NonRAID.Slot.2-1', 'capacity_bytes': 20000588955136, 'model': 'ST20000NM008D-3D', 'protocol': 'SATA', 'serial_number': 'ZVT99QLL', 'status': {'health': 'OK', 'healthrollup': 'OK', 'state': 'Enabled'}, 'physical_location': {'partlocation': {'locationordinalvalue': 8, 'locationtype': 'Slot'}}}}}, 'processors': {'1': {'cpu.socket.2': {'description': 'Represents the properties of a Processor attached to this System', 'total_cores': 16, 'total_threads': 32, 'processor_type': 'CPU', 'model': 'Intel(R) Xeon(R) Silver 4314 CPU @ 2.40GHz', 'status': {'health': 'OK', 'state': 'Enabled'}, 'manufacturer': 'Intel'}, 'cpu.socket.1': {'description': 'Represents the properties of a Processor attached to this System', 'total_cores': 16, 'total_threads': 32, 'processor_type': 'CPU', 'model': 'Intel(R) Xeon(R) Silver 4314 CPU @ 2.40GHz', 'status': {'health': 'OK', 'state': 'Enabled'}, 'manufacturer': 'Intel'}}}, 'network': {'1': {'oslogicalnetwork.2': {'description': 'eno8303', 'name': 'eno8303', 'speed_mbps': 0, 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'memory': {'1': {'dimm.socket.a1': {'description': 'DIMM A1', 'memory_device_type': 'DDR4', 'capacity_mi_b': 16384, 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'power': {'1': {'0': {'name': 'PS1 Status', 'model': 'PWR SPLY,800W,RDNT,LTON', 'manufacturer': 'DELL', 'status': {'health': 'OK', 'state': 'Enabled'}}, '1': {'name': 'PS2 Status', 'model': 'PWR SPLY,800W,RDNT,LTON', 'manufacturer': 'DELL', 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'fans': {'1': {'0': {'name': 'System Board Fan1A', 'physical_context': 'SystemBoard', 'reading': 4800, 'reading_units': 'RPM', 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'temperatures': {'1': {'0': {'name': 'System Board Inlet Temp', 'physical_context': 'Intake', 'reading': 24, 'reading_units': 'Cel', 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'fcm': {'local': {'nvme2n1': {'device': 'nvme2n1', 'model': 'IBM FCM5 3.2TB', 'serial_number': '03NK797YS344D57S056', 'valid': True, 'compression_ratio': 2.5, 'compression_ratio_str': '2.5:1', 'savings_bytes': 1000000, 'phy_util_percent': 50.0, 'log_util_percent': 25.0, 'status': {'health': 'OK', 'state': 'Enabled'}}}}}, 'firmware': {'installed-28897-6.10.30.20__usc.embedded.1:lc.embedded.1': {'name': 'Lifecycle Controller', 'description': 'Represents Firmware Inventory', 'release_date': '00:00:00Z', 'version': '6.10.30.20', 'updateable': True, 'status': {'health': 'OK', 'state': 'Enabled'}}}}} diff --git a/src/pybind/mgr/cephadm/tests/test_node_proxy.py b/src/pybind/mgr/cephadm/tests/test_node_proxy.py index ba311b19421..e053e10e920 100644 --- a/src/pybind/mgr/cephadm/tests/test_node_proxy.py +++ b/src/pybind/mgr/cephadm/tests/test_node_proxy.py @@ -331,6 +331,18 @@ class TestNodeProxyEndpoint(helper.CPWebCase): self.getPage("/host03/temperatures", method="GET") self.assertStatus('404 Not Found') + def test_fcm_with_valid_hostname(self): + self.getPage("/host02/fcm", method="GET") + self.assertStatus('200 OK') + + def test_fcm_no_hostname(self): + self.getPage("/fcm", method="GET") + self.assertStatus('200 OK') + + def test_fcm_with_invalid_hostname(self): + self.getPage("/host03/fcm", method="GET") + self.assertStatus('404 Not Found') + def test_firmware_with_valid_hostname(self): self.getPage("/host02/firmware", method="GET") self.assertStatus('200 OK') diff --git a/src/pybind/mgr/orchestrator/module.py b/src/pybind/mgr/orchestrator/module.py index c6024549618..f9d5a4b9237 100644 --- a/src/pybind/mgr/orchestrator/module.py +++ b/src/pybind/mgr/orchestrator/module.py @@ -536,6 +536,8 @@ class OrchestratorCli(OrchestratorClientMixin, MgrModule): 'power': ['HOST', 'CHASSIS_ID', 'ID', 'NAME', 'MODEL', 'MANUFACTURER', 'STATUS', 'STATE'], 'fans': ['HOST', 'CHASSIS_ID', 'ID', 'NAME', 'READING', 'UNITS', 'STATUS', 'STATE'], 'temperatures': ['HOST', 'CHASSIS_ID', 'ID', 'NAME', 'READING', 'UNITS', 'STATUS', 'STATE'], + 'fcm': ['HOST', 'SOURCE', 'DEVICE', 'MODEL', 'SN', 'RATIO', 'SAVINGS', + 'PHYS USED', 'LOG USED', 'VALID', 'STATUS', 'STATE'], } if category == 'firmwares': @@ -625,6 +627,9 @@ class OrchestratorCli(OrchestratorClientMixin, MgrModule): 'power': ('name', 'model', 'manufacturer', 'health', 'state'), 'fans': ('name', 'reading', 'reading_units', 'health', 'state'), 'temperatures': ('name', 'reading', 'reading_units', 'health', 'state'), + 'fcm': ('device', 'model', 'serial_number', 'compression_ratio_display', + 'savings_display', 'phy_usage_display', 'log_usage_display', + 'valid', 'health', 'state'), } fields = mapping.get(category, ())