]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
node-proxy: expose FCM stats 69477/head
authorGuillaume Abrioux <gabrioux@ibm.com>
Thu, 18 Jun 2026 14:54:38 +0000 (16:54 +0200)
committerGuillaume Abrioux <gabrioux@ibm.com>
Wed, 24 Jun 2026 02:17:52 +0000 (04:17 +0200)
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 <gabrioux@ibm.com>
19 files changed:
src/ceph-node-proxy/ceph_node_proxy/api.py
src/ceph-node-proxy/ceph_node_proxy/atollon.py
src/ceph-node-proxy/ceph_node_proxy/baseredfishsystem.py
src/ceph-node-proxy/ceph_node_proxy/basesystem.py
src/ceph-node-proxy/ceph_node_proxy/fcm_stats.py [new file with mode: 0644]
src/ceph-node-proxy/ceph_node_proxy/local_collectors.py [new file with mode: 0644]
src/ceph-node-proxy/ceph_node_proxy/main.py
src/ceph-node-proxy/ceph_node_proxy/node_backend.py [new file with mode: 0644]
src/ceph-node-proxy/ceph_node_proxy/protocols.py
src/ceph-node-proxy/ceph_node_proxy/registry.py
src/ceph-node-proxy/tests/test_atollon.py
src/ceph-node-proxy/tests/test_baseredfishsystem.py
src/ceph-node-proxy/tests/test_fcm_stats.py [new file with mode: 0644]
src/ceph-node-proxy/tests/test_local_collectors.py [new file with mode: 0644]
src/ceph-node-proxy/tests/test_node_backend.py [new file with mode: 0644]
src/pybind/mgr/cephadm/agent.py
src/pybind/mgr/cephadm/tests/node_proxy_data.py
src/pybind/mgr/cephadm/tests/test_node_proxy.py
src/pybind/mgr/orchestrator/module.py

index 563ac88c8f79391a8880f8d67e09bfc0e7d7e48d..7df31540afd322401ec8b6cdcef22bed645225d2 100644 (file)
@@ -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()
index 32aa249b4be0f67d1a8170f4e8d662f12fc58c49..c440535ffb22f388d45a4dcdd89f2249e5bee3c0 100644 (file)
@@ -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
index 3da5a0f9398bf36984e1871c37e1d7759f89d195..3d9b0ab97e824b2b0cb743c9c295d1d5d91ed8e5 100644 (file)
@@ -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
index aecc0e137c0d5be6a1e6f9a24158c907fa953ff5..405b9d3c1d9b08bab926c78a86cc7aec2ade990b 100644 (file)
@@ -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 (file)
index 0000000..9e23576
--- /dev/null
@@ -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("<Q", raw_bytes[:8])[0]
+        phy_util_bytes = struct.unpack("<Q", raw_bytes[8:16])[0]
+        log_size_bytes = struct.unpack("<Q", raw_bytes[16:24])[0]
+        log_util_bytes = struct.unpack("<Q", raw_bytes[24:32])[0]
+    except struct.error as exc:
+        logger.error(
+            "Problem unpacking usage stats from log page 0xCA for device %s: %s",
+            device,
+            exc,
+        )
+        return invalid
+
+    phy_util_percent = (
+        (phy_util_bytes / phy_size_bytes) * 100 if phy_size_bytes else 0.0
+    )
+    log_util_percent = (
+        (log_util_bytes / log_size_bytes) * 100 if log_size_bytes else 0.0
+    )
+    compression_ratio = (
+        log_util_bytes / phy_util_bytes if phy_util_bytes else 0.0
+    )
+    savings_bytes = log_util_bytes - phy_util_bytes
+
+    return apply_fcm_display_fields({
+        "device": device,
+        "model": model,
+        "serial_number": serial_number,
+        "valid": True,
+        "phy_size_bytes": phy_size_bytes,
+        "phy_util_bytes": phy_util_bytes,
+        "log_size_bytes": log_size_bytes,
+        "log_util_bytes": log_util_bytes,
+        "phy_util_percent": phy_util_percent,
+        "log_util_percent": log_util_percent,
+        "compression_ratio": compression_ratio,
+        "compression_ratio_str": compression_ratio_str(compression_ratio),
+        "savings_bytes": savings_bytes,
+        "status": {"health": "OK", "state": "Enabled"},
+        "compression_ratio_display": "",
+        "savings_display": "",
+        "phy_usage_display": "",
+        "log_usage_display": "",
+    })
+
+
+def collect_fcm_stats() -> 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 (file)
index 0000000..1be1b23
--- /dev/null
@@ -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 = {}
index 6f0ab0b593da661990112687c591b626d8f4261e..775626af21686afae8fcccf4b576f8a720f4fcd5 100644 (file)
@@ -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 (file)
index 0000000..e7549e5
--- /dev/null
@@ -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()
index e82d425bea5de79e2df1495492afb6fd6b415fcc..85f76b48360b5152327ceafe95877cbea81b9634 100644 (file)
@@ -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]: ...
index 6c9436bd2f9c6fb9eedaeb951bb5cfa164f47767..22eecb646588c822a78f1b47310cbaba1e33074a 100644 (file)
@@ -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
index 7c4b9a179e143a19b1bfff2a3d697712b5e430f0..c2b1fea3df421f4a09c97dd9010bce55d4bd1291 100644 (file)
@@ -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"
index 28a9052140617ec0337a70f22b101b792400905b..05102ca14452ba8933cc9d8f53d748fa9dd2d445 100644 (file)
@@ -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 (file)
index 0000000..686cb94
--- /dev/null
@@ -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(
+            "<QQQQ",
+            3_200_000_000_000,
+            1_600_000_000_000,
+            6_400_000_000_000,
+            3_200_000_000_000,
+        )
+
+    def test_read_fcm_stats_success(self):
+        with (
+            patch(
+                "ceph_node_proxy.fcm_stats.read_sysfs_block",
+                side_effect=lambda device, attr: {
+                    "device/model": "IBM FCM5 3.2TB",
+                    "device/serial": "03NK797YS344D57S056",
+                }[attr],
+            ),
+            patch(
+                "ceph_node_proxy.fcm_stats.query_nvme_log_page",
+                return_value=self._sample_log_page(),
+            ),
+        ):
+            stats = read_fcm_stats("nvme2n1")
+
+        assert stats["valid"] is True
+        assert stats["device"] == "nvme2n1"
+        assert stats["compression_ratio"] == 2.0
+        assert stats["compression_ratio_str"] == "2:1"
+        assert stats["savings_bytes"] == 1_600_000_000_000
+        assert stats["compression_ratio_display"] == "2:1"
+        assert stats["savings_display"] == "1.6 TB"
+        assert stats["phy_usage_display"].endswith("(50%)")
+        assert stats["log_usage_display"].endswith("(50%)")
+        assert stats["status"]["health"] == "OK"
+
+    def test_read_fcm_stats_ioctl_failure(self):
+        with (
+            patch(
+                "ceph_node_proxy.fcm_stats.read_sysfs_block",
+                side_effect=lambda device, attr: {
+                    "device/model": "IBM FCM5 3.2TB",
+                    "device/serial": "03NK797YS344D57S056",
+                }[attr],
+            ),
+            patch(
+                "ceph_node_proxy.fcm_stats.query_nvme_log_page",
+                return_value=None,
+            ),
+        ):
+            stats = read_fcm_stats("nvme2n1")
+
+        assert stats["valid"] is False
+        assert stats["compression_ratio_str"] == ""
+        assert stats["compression_ratio_display"] == ""
+        assert stats["phy_usage_display"] == ""
+        assert stats["status"]["health"] == "Unknown"
+
+
+class TestFCMDisplayFields:
+    def test_fcm_usage_display_shows_human_bytes_and_int_percent(self):
+        usage = fcm_usage_display(15833446, 0.00027025391332477435)
+        assert usage.endswith("(0%)")
+        assert "MB" in usage
+
+    def test_apply_fcm_display_fields_hides_ratio_when_logical_usage_is_low(self):
+        stats = apply_fcm_display_fields({
+            "device": "nvme2n1",
+            "model": "FCM5",
+            "serial_number": "SN1",
+            "valid": True,
+            "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": "0:1",
+            "savings_bytes": 0,
+            "compression_ratio_display": "",
+            "savings_display": "",
+            "phy_usage_display": "",
+            "log_usage_display": "",
+            "status": {"health": "OK", "state": "Enabled"},
+        })
+        assert stats["compression_ratio_display"] == ""
+        assert stats["log_usage_display"].endswith("(0%)")
+
+    def test_apply_fcm_display_fields_shows_ratio_and_formatted_values(self):
+        stats = apply_fcm_display_fields({
+            "device": "nvme5n1",
+            "model": "FCM5",
+            "serial_number": "SN2",
+            "valid": True,
+            "phy_size_bytes": 1,
+            "phy_util_bytes": 15833446,
+            "log_size_bytes": 1,
+            "log_util_bytes": 16032506,
+            "phy_util_percent": 0.00027025391332477435,
+            "log_util_percent": 1.0,
+            "compression_ratio": 1.0,
+            "compression_ratio_str": "1:1",
+            "savings_bytes": 199060,
+            "compression_ratio_display": "",
+            "savings_display": "",
+            "phy_usage_display": "",
+            "log_usage_display": "",
+            "status": {"health": "OK", "state": "Enabled"},
+        })
+        assert stats["compression_ratio_display"] == "1:1"
+        assert stats["savings_display"] == "199.1 KB"
+        assert stats["phy_usage_display"].endswith("(0%)")
+        assert stats["log_usage_display"].endswith("(1%)")
+
+    def test_apply_fcm_display_fields_clears_metrics_when_invalid(self):
+        stats = apply_fcm_display_fields({
+            "device": "nvme0n1",
+            "model": "FCM5",
+            "serial_number": "SN3",
+            "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,
+            "compression_ratio_display": "",
+            "savings_display": "",
+            "phy_usage_display": "",
+            "log_usage_display": "",
+            "status": {"health": "Unknown", "state": "Unavailable"},
+        })
+        assert stats["compression_ratio_display"] == ""
+        assert stats["savings_display"] == ""
+        assert stats["phy_usage_display"] == ""
+        assert stats["log_usage_display"] == ""
+
+
+class TestCollectFCMStats:
+    def test_collect_fcm_stats_only_fcm_devices(self):
+        with (
+            patch(
+                "ceph_node_proxy.fcm_stats.list_nvme_namespace_names",
+                return_value=["nvme0n1", "nvme2n1"],
+            ),
+            patch(
+                "ceph_node_proxy.fcm_stats.is_fcm_device",
+                side_effect=lambda device: device == "nvme2n1",
+            ),
+            patch(
+                "ceph_node_proxy.fcm_stats.read_fcm_stats",
+                side_effect=lambda device: {
+                    "device": device,
+                    "valid": True,
+                    "compression_ratio": 2.0,
+                    "compression_ratio_str": "2:1",
+                },
+            ),
+        ):
+            stats = collect_fcm_stats()
+
+        assert set(stats.keys()) == {"nvme2n1"}
+        assert stats["nvme2n1"]["compression_ratio_str"] == "2:1"
diff --git a/src/ceph-node-proxy/tests/test_local_collectors.py b/src/ceph-node-proxy/tests/test_local_collectors.py
new file mode 100644 (file)
index 0000000..312d92c
--- /dev/null
@@ -0,0 +1,52 @@
+from unittest.mock import patch
+
+from ceph_node_proxy.local_collectors import FCMCollector, LocalCollectorRunner
+
+
+class TestFCMCollector:
+    def test_name(self):
+        assert FCMCollector().name == "fcm"
+
+    def test_update_and_get_data(self):
+        sample_stats = {
+            "nvme2n1": {
+                "device": "nvme2n1",
+                "valid": True,
+                "compression_ratio": 2.5,
+                "compression_ratio_str": "2.5:1",
+            },
+        }
+        collector = FCMCollector()
+        with patch(
+            "ceph_node_proxy.local_collectors.collect_fcm_stats",
+            return_value=sample_stats,
+        ):
+            collector.update()
+
+        assert collector.get_data() == {"local": sample_stats}
+
+    def test_flush_clears_data(self):
+        collector = FCMCollector()
+        collector._data = {"local": {"nvme2n1": {"valid": True}}}
+        collector.flush()
+        assert collector.get_data() == {}
+
+
+class TestLocalCollectorRunner:
+    def test_runner_update_and_get_category(self):
+        sample_stats = {"nvme2n1": {"valid": True}}
+        runner = LocalCollectorRunner([FCMCollector()])
+        with patch(
+            "ceph_node_proxy.local_collectors.collect_fcm_stats",
+            return_value=sample_stats,
+        ):
+            runner.update()
+
+        assert runner.get_category("fcm") == {"local": sample_stats}
+        assert runner.get_category("missing") == {}
+
+    def test_runner_flush(self):
+        runner = LocalCollectorRunner([FCMCollector()])
+        runner._collectors[0]._data = {"local": {"nvme2n1": {"valid": True}}}
+        runner.flush()
+        assert runner.get_category("fcm") == {}
diff --git a/src/ceph-node-proxy/tests/test_node_backend.py b/src/ceph-node-proxy/tests/test_node_backend.py
new file mode 100644 (file)
index 0000000..92e7686
--- /dev/null
@@ -0,0 +1,107 @@
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from ceph_node_proxy.local_collectors import FCMCollector, LocalCollectorRunner
+from ceph_node_proxy.node_backend import NodeBackend
+from ceph_node_proxy.registry import create_local_collector_runner, create_node_backend
+
+
+@pytest.fixture
+def mock_redfish():
+    redfish = MagicMock()
+    redfish.config = {}
+    redfish.refresh_interval = 1
+    redfish.get_system.return_value = {
+        "host": "node01",
+        "sn": "SN123",
+        "status": {
+            "storage": {},
+            "processors": {},
+            "network": {},
+            "memory": {},
+            "power": {},
+            "fans": {},
+            "temperatures": {},
+        },
+        "firmware": {},
+    }
+    return redfish
+
+
+class TestLocalCollectorRunnerRegistry:
+    def test_atollon_vendor_includes_fcm_collector(self):
+        runner = create_local_collector_runner("atollon")
+        assert runner.categories() == ["fcm"]
+
+    def test_generic_vendor_has_no_collectors(self):
+        runner = create_local_collector_runner("generic")
+        assert runner.categories() == []
+
+
+class TestNodeBackend:
+    def test_get_fcm_delegates_to_local_runner(self, mock_redfish):
+        runner = LocalCollectorRunner([FCMCollector()])
+        backend = NodeBackend(mock_redfish, runner)
+        sample_stats = {
+            "nvme2n1": {
+                "device": "nvme2n1",
+                "valid": True,
+                "compression_ratio": 2.5,
+                "compression_ratio_str": "2.5:1",
+            },
+        }
+        with patch(
+            "ceph_node_proxy.local_collectors.collect_fcm_stats",
+            return_value=sample_stats,
+        ):
+            runner.update()
+
+        assert backend.get_fcm() == {"local": sample_stats}
+
+    def test_get_system_merges_redfish_and_local_categories(self, mock_redfish):
+        runner = LocalCollectorRunner([FCMCollector()])
+        backend = NodeBackend(mock_redfish, runner)
+        sample_stats = {"nvme2n1": {"valid": True, "compression_ratio": 2.0}}
+        with patch(
+            "ceph_node_proxy.local_collectors.collect_fcm_stats",
+            return_value=sample_stats,
+        ):
+            runner.update()
+
+        result = backend.get_system()
+        assert result["status"]["fcm"] == {"local": sample_stats}
+        assert "storage" in result["status"]
+        mock_redfish.get_system.assert_called_once()
+
+    def test_flush_clears_local_collectors(self, mock_redfish):
+        runner = LocalCollectorRunner([FCMCollector()])
+        backend = NodeBackend(mock_redfish, runner)
+        runner.update()
+        backend.flush()
+        assert backend.get_fcm() == {}
+        mock_redfish.flush.assert_called_once()
+
+    def test_delegates_storage_to_redfish(self, mock_redfish):
+        mock_redfish.get_storage.return_value = {"Self": {"drive1": {}}}
+        backend = NodeBackend(mock_redfish, LocalCollectorRunner([]))
+        assert backend.get_storage() == {"Self": {"drive1": {}}}
+
+
+class TestCreateNodeBackend:
+    def test_create_node_backend_atollon(self):
+        with patch("ceph_node_proxy.registry.get_redfish_provider_class") as mock_provider:
+            mock_redfish = MagicMock()
+            mock_provider.return_value.return_value = mock_redfish
+            backend = create_node_backend(
+                "atollon",
+                host="bmc",
+                port="443",
+                username="user",
+                password="secret",
+                config=MagicMock(),
+            )
+
+        assert isinstance(backend, NodeBackend)
+        assert backend.redfish is mock_redfish
+        assert backend._local.categories() == ["fcm"]
index a7405c969a2159fb273a300ed337cf6620aa6f0d..2956e38c2a39d0b9e43faf0a10a7e6b243c64f63 100644 (file)
@@ -622,6 +622,16 @@ class NodeProxyEndpoint:
             raise cherrypy.HTTPError(404, f"{kw.get('hostname')} not found.")
         return results
 
+    @cherrypy.expose
+    @cherrypy.tools.allow(methods=['GET'])
+    @cherrypy.tools.json_out()
+    def fcm(self, **kw: Any) -> 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()
index 3c01582d756e141b45a1c45fd3e408071235165c..8e4230fe15f3da8076cccb21e84619ebe7a6b215 100644 (file)
@@ -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'}}}}}
index ba311b19421ed5a418886bc6e331a65aaac9677e..e053e10e920ff9ee4ceed87e0514be5fc052423e 100644 (file)
@@ -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')
index c602454961819bdac2302b50871d76caeeb531d1..f9d5a4b923790deb537e4043a40f715811d11acd 100644 (file)
@@ -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, ())