diff --git a/Makefile b/Makefile index 966e5ab..a1d2455 100644 --- a/Makefile +++ b/Makefile @@ -20,6 +20,12 @@ SONIC_CONFIG_MAKE_JOBS ?= $(shell nproc) BMCWEB_HEAD_COMMIT ?= 6926d430 BMCWEB_REPO_URL ?= https://github.com/openbmc/bmcweb.git +# Pinned stdexec SHA. bmcweb's nested sdbusplus pulls stdexec via a wrap +# that defaults to `revision = HEAD`; we override with this fixed SHA so +# builds are reproducible even if upstream moves. +STDEXEC_REVISION ?= fee4d651494014610a277540f209cae56011e47f +STDEXEC_URL ?= https://github.com/NVIDIA/stdexec.git + # Target directory for build artifacts SONIC_REDFISH_TARGET ?= target/debs/trixie @@ -33,6 +39,7 @@ BUILD_DIR := $(REPO_ROOT)/build TARGET_DIR := $(REPO_ROOT)/$(SONIC_REDFISH_TARGET) SERIES_FILE := $(PATCHES_DIR)/series DEBIAN_DIR := $(BMCWEB_DIR)/debian +OEM_EXT_DIR := $(REPO_ROOT)/oem-extension # Build artifacts BMCWEB_BINARY := $(BMCWEB_DIR)/build/bmcweb @@ -46,7 +53,7 @@ DOCKERFILE_BUILD := $(BUILD_DIR)/Dockerfile.build MAIN_TARGET := $(BMCWEB_BINARY) DERIVED_TARGETS := $(BRIDGE_BINARY) -.PHONY: all build clean reset setup-bmcweb copy-patches apply-patches build-bmcweb build-bridge build-bmcweb-native build-bridge-native build-in-docker test unit-test help +.PHONY: all build clean reset setup-bmcweb copy-oem-extension copy-patches apply-patches build-bmcweb build-bridge build-bmcweb-native build-bridge-native build-in-docker test unit-test help # Recipes in this Makefile share Docker images and the target/ directory, so # the top-level prereq chain (build → unit-test → test) must run sequentially. @@ -136,7 +143,7 @@ build: $(DOCKERFILE_BUILD) # Build inside Docker (called from Docker container) # Note: sdbusplus is pre-installed in the Docker image -build-in-docker: setup-bmcweb apply-patches build-bridge-native build-bmcweb-native +build-in-docker: setup-bmcweb copy-oem-extension apply-patches build-bridge-native build-bmcweb-native @echo " Build inside Docker completed" # Setup bmcweb source @@ -161,14 +168,45 @@ setup-bmcweb: fi @echo " bmcweb ready" -# Copy patches to debian/ directory +# Copy OEM extension files into bmcweb source tree before patching. +# +# Path coupling note (do not change one without the other): +# * Headers land in bmcweb/redfish-core/lib/sonic/ — this path is referenced +# by the patch 0003 which #include's "sonic/sonic_oem_redfish.hpp". +# * JSON schemas land in bmcweb/redfish-core/schema/oem/sonic/json-schema/ +# — this path, plus the meson.build copied next to it, is referenced by +# the OEM schema registration logic patched into bmcweb's top-level +# meson build. Renaming either side requires updating the patch. +copy-oem-extension: setup-bmcweb + @echo "Copying SONiC OEM extension into bmcweb..." + @mkdir -p $(BMCWEB_DIR)/redfish-core/lib/sonic + @mkdir -p $(BMCWEB_DIR)/redfish-core/schema/oem/sonic/json-schema + @# Use -u (update) so an older OEM file cannot overwrite a newer in-tree + @# copy that a developer may have iterated on inside bmcweb/. + @cp -u $(OEM_EXT_DIR)/sonic/*.hpp $(BMCWEB_DIR)/redfish-core/lib/sonic/ + @cp -u $(OEM_EXT_DIR)/schema/json-schema/*.json $(BMCWEB_DIR)/redfish-core/schema/oem/sonic/json-schema/ + @cp -u $(OEM_EXT_DIR)/schema/meson.build $(BMCWEB_DIR)/redfish-core/schema/oem/sonic/ + @echo " OEM extension files copied" + + @# Ensure stdexec.wrap exists in bmcweb subprojects, pinned to a fixed + @# SHA. sdbusplus depends on stdexec but bmcweb does not ship a + @# top-level wrap for it; without this, meson cannot resolve the + @# nested subproject dependency when building from a clean tree. + @# Always overwrite so a stale wrap from a previous build cannot + @# silently keep us on an older / drifting revision. + @echo " Writing pinned stdexec.wrap (revision $(STDEXEC_REVISION))..." + @printf '[wrap-git]\nurl = %s\nrevision = %s\ndepth = 1\n\n[provide]\nstdexec = stdexec_dep\n' \ + '$(STDEXEC_URL)' '$(STDEXEC_REVISION)' \ + > $(BMCWEB_DIR)/subprojects/stdexec.wrap + +# Copy patches to debian/ directory copy-patches: $(SERIES_FILE) @echo "Copying patches to debian/ directory ..." @# Note: Patches will create debian/ directory, so we only copy series file after patches are applied @echo " Patches will be applied from $(PATCHES_DIR)" # Apply patches using series file -apply-patches: setup-bmcweb +apply-patches: setup-bmcweb copy-oem-extension @echo "Applying patches from series file..." @if [ ! -d "$(BMCWEB_DIR)" ]; then \ echo "Error: bmcweb directory not found"; \ @@ -195,8 +233,8 @@ apply-patches: setup-bmcweb fi # Build bmcweb Debian package -# Dependencies: clean → setup-bmcweb → apply-patches → build-bmcweb -build-bmcweb: clean setup-bmcweb apply-patches +# Dependencies: clean → setup-bmcweb → copy-oem-extension → apply-patches → build-bmcweb +build-bmcweb: clean setup-bmcweb copy-oem-extension apply-patches @echo "=========================================" @echo "Building bmcweb Debian package" @echo "=========================================" @@ -283,7 +321,7 @@ build-bridge: clean @ls -lh $(TARGET_DIR)/sonic-dbus-bridge* 2>/dev/null || echo " No artifacts found" # Build bmcweb natively (inside Docker container, no nested Docker) -build-bmcweb-native: +build-bmcweb-native: setup-bmcweb copy-oem-extension apply-patches @echo "=========================================" @echo "Building bmcweb Debian package (native)" @echo "=========================================" @@ -346,7 +384,7 @@ BMCWEB = bmcweb_$(SONIC_REDFISH_VERSION)_$(CONFIGURED_ARCH).deb BMCWEB_DBG = bmcweb-dbg_$(SONIC_REDFISH_VERSION)_$(CONFIGURED_ARCH).deb # Main bmcweb package target for sonic-buildimage -$(addprefix $(DEST)/, $(BMCWEB)): $(DEST)/% : setup-bmcweb apply-patches +$(addprefix $(DEST)/, $(BMCWEB)): $(DEST)/% : setup-bmcweb copy-oem-extension apply-patches # Build bmcweb package using dpkg-buildpackage pushd $(BMCWEB_DIR) @@ -446,9 +484,12 @@ endif # Unit Tests (C++ / gtest) # ======================================== # Dumb-and-direct: each tests/unit-tests/_test.cpp is compiled together -# with sonic-dbus-bridge/src/.cpp and linked against gtest. Runs inside -# the builder container -- no services, no privileged mode, no new image. -# If libgtest-dev isn't present in the builder image, it's installed on demand. +# with sonic-dbus-bridge/src/.cpp (when present) and linked against gtest. +# Header-only test targets (no matching src/.cpp) are compiled standalone +# so they can exercise pure inline / template / declarative-table code. +# Runs inside the builder container -- no services, no privileged mode, no +# new image. If libgtest-dev isn't present in the builder image, it's +# installed on demand. UNIT_TEST_DIR := $(REPO_ROOT)/tests/unit-tests @@ -473,12 +514,16 @@ unit-test: for t in tests/unit-tests/*_test.cpp; do \ base=\$$(basename \$$t _test.cpp); \ src=sonic-dbus-bridge/src/\$$base.cpp; \ - if [ ! -f \$$src ]; then continue; fi; \ + if [ -f \$$src ]; then \ + extra_src=\$$src; \ + else \ + extra_src=; \ + fi; \ g++ -std=c++20 -Wall -Wextra -g -O0 -pthread \ -I sonic-dbus-bridge/include \ -I /usr/src/googletest/googletest \ -I /usr/src/googletest/googletest/include \ - \$$t \$$src \ + \$$t \$$extra_src \ /usr/src/googletest/googletest/src/gtest-all.cc \ /usr/src/googletest/googletest/src/gtest_main.cc \ -o /tmp/ut/\$$base || { failed=1; continue; }; \ diff --git a/README.md b/README.md index f3fbe5d..6b15ab9 100644 --- a/README.md +++ b/README.md @@ -4,19 +4,44 @@ SONiC Redfish implementation providing bmcweb and sonic-dbus-bridge as Debian pa ## Table of Contents -- [Overview](#overview) -- [Quick Start](#quick-start) -- [Build System](#build-system) -- [Patch Management](#patch-management) -- [Cleanup Targets](#cleanup-targets) -- [Dependency Management](#dependency-management) -- [Configuration](#configuration) -- [Components](#components) -- [Redfish API Endpoints](#redfish-api-endpoints) -- [Troubleshooting](#troubleshooting) -- [License](#license) - -## Overview +1. [Overview](#1-overview) +2. [Quick Start](#2-quick-start) + 1. [Prerequisites](#21-prerequisites) + 2. [Build](#22-build) + 3. [Build Targets](#23-build-targets) + 4. [Build Options](#24-build-options) +3. [Build System](#3-build-system) + 1. [Build Flow](#31-build-flow) + 2. [Automatic Dependencies](#32-automatic-dependencies) +4. [Patch Management](#4-patch-management) +5. [Cleanup Targets](#5-cleanup-targets) + 1. [`clean` - Remove build artifacts, reset source](#51-clean---remove-build-artifacts-reset-source) + 2. [`reset` - Complete cleanup](#52-reset---complete-cleanup) +6. [Dependency Management](#6-dependency-management) + 1. [bmcweb dependencies](#61-bmcweb-dependencies) + 2. [sonic-dbus-bridge dependencies](#62-sonic-dbus-bridge-dependencies) +7. [Components](#7-components) + 1. [bmcweb](#71-bmcweb) + 2. [sonic-dbus-bridge](#72-sonic-dbus-bridge) +8. [Configuration](#8-configuration) + 1. [sonic-dbus-bridge configuration](#81-sonic-dbus-bridge-configuration) + 2. [D-Bus configuration files](#82-d-bus-configuration-files) +9. [OEM Extension](#9-oem-extension) +10. [Testing](#10-testing) +11. [Redfish API Endpoints](#11-redfish-api-endpoints) + 1. [FirmwareInventory Collection](#111-firmwareinventory-collection) + 2. [FirmwareInventory - BIOS](#112-firmwareinventory---bios) + 3. [FirmwareInventory - BMC Firmware](#113-firmwareinventory---bmc-firmware) + 4. [FirmwareInventory - Switch](#114-firmwareinventory---switch) + 5. [Service Root](#115-service-root) + 6. [ComputerSystem.Reset - Power On](#116-computersystemreset---power-on) + 7. [ComputerSystem.Reset - Graceful Shutdown](#117-computersystemreset---graceful-shutdown) + 8. [ComputerSystem.Reset - Power Cycle](#118-computersystemreset---power-cycle) +12. [License](#12-license) + +--- + +## 1. Overview This repository contains: - **bmcweb**: OpenBMC web server source code with SONiC-specific patches @@ -24,15 +49,19 @@ This repository contains: Both components are built as Debian packages (`.deb`) for easy integration with SONiC. -## Quick Start +[^ Back to Table of Contents](#table-of-contents) -### Prerequisites +--- + +## 2. Quick Start + +### 2.1. Prerequisites - Docker installed on your system - Git - sudo access (for cleaning root-owned build artifacts) -### Build +### 2.2. Build ```bash # Build all components (Docker-based, produces .deb packages) @@ -48,15 +77,15 @@ Build artifacts will be available in `target/debs/trixie/`: - `sonic-dbus-bridge_1.0.0_arm64.deb` - `sonic-dbus-bridge-dbgsym_1.0.0_arm64.deb` -### Build Targets +### 2.3. Build Targets ```bash # Show all available targets make help # Build individual components (automatically runs clean + dependencies) -make build-bmcweb # Runs: clean → setup-bmcweb → apply-patches → build -make build-bridge # Runs: clean → build +make build-bmcweb # Runs: clean -> setup-bmcweb -> apply-patches -> build +make build-bridge # Runs: clean -> build # Clean build artifacts (removes build dirs, resets bmcweb source) make clean @@ -65,7 +94,7 @@ make clean make reset ``` -### Build Options +### 2.4. Build Options ```bash # Use custom number of parallel jobs (default: nproc) @@ -81,7 +110,11 @@ make BMCWEB_HEAD_COMMIT=abc123 make BMCWEB_REPO_URL=https://github.com/custom/bmcweb.git ``` -## Build System +[^ Back to Table of Contents](#table-of-contents) + +--- + +## 3. Build System The build system is designed for **Debian Trixie** and uses: @@ -91,11 +124,11 @@ The build system is designed for **Debian Trixie** and uses: 4. **Automatic dependencies**: Build targets automatically trigger required cleanup and setup steps 5. **Patch management**: Uses a `patches/series` file to define patch order -### Build Flow +### 3.1. Build Flow ![Build Flow Chart](images/BuildFlowChart.png) -``` +```text make all 1. Build Docker image (sonic-redfish-builder:latest) @@ -125,55 +158,70 @@ make all - Plus .changes, .buildinfo, .dsc files ``` -### Automatic Dependencies +### 3.2. Automatic Dependencies The build system automatically handles dependencies: -- **`build-bmcweb`**: Automatically runs `clean` → `setup-bmcweb` → `apply-patches` → build -- **`build-bridge`**: Automatically runs `clean` → build +- **`build-bmcweb`**: Automatically runs `clean` -> `setup-bmcweb` -> `apply-patches` -> build +- **`build-bridge`**: Automatically runs `clean` -> build This ensures a clean, reproducible build every time. -## Patch Management +[^ Back to Table of Contents](#table-of-contents) + +--- -Patches are located in `patches/` directory: +## 4. Patch Management + +Patches are located in the `patches/` directory: - `patches/series` - Defines patch order (lines starting with `#` are comments) - `patches/*.patch` - Individual patch files Current patches: 1. `0001-Integrating-bmcweb-with-SONiC-s-build-system.patch` - Adds Debian packaging - To add a new patch: -1. Make changes in bmcweb source directory -2. Generate patch: `cd bmcweb && git format-patch -1 HEAD` -3. Move patch to `patches/` directory -4. Add patch filename to `patches/series` +1. Make changes in bmcweb source directory. +2. Generate patch: `cd bmcweb && git format-patch -1 HEAD`. +3. Move patch to `patches/` directory. +4. Add patch filename to `patches/series`. -## Cleanup Targets +[^ Back to Table of Contents](#table-of-contents) + +--- + +## 5. Cleanup Targets + +### 5.1. `clean` - Remove build artifacts, reset source -### `clean` - Remove build artifacts, reset source - Removes: `obj-*`, `debian/`, `.deb` files, subproject builds - Resets: bmcweb source to clean git state (so patches can be reapplied) - Keeps: Docker images, target directory - Use when: You want to rebuild from scratch -### `reset` - Complete cleanup +### 5.2. `reset` - Complete cleanup + - Does everything `clean` does, plus: - Removes: Docker images, target directory - Resets: bmcweb to base commit with `git clean -fdx` - Use when: You want to start completely fresh -## Dependency Management +[^ Back to Table of Contents](#table-of-contents) + +--- + +## 6. Dependency Management Dependencies are managed via **Meson wrap files** (`.wrap`): -### bmcweb dependencies: +### 6.1. bmcweb dependencies + - `bmcweb/subprojects/sdbusplus.wrap` - D-Bus C++ bindings - `bmcweb/subprojects/stdexec.wrap` - C++23 executors - Plus other dependencies defined in bmcweb upstream -### sonic-dbus-bridge dependencies: +### 6.2. sonic-dbus-bridge dependencies + - `sonic-dbus-bridge/subprojects/sdbusplus.wrap` - D-Bus C++ bindings - `sonic-dbus-bridge/subprojects/stdexec.wrap` - C++23 executors @@ -181,9 +229,14 @@ Meson automatically downloads and builds these dependencies during the build pro The Debian packages can be installed in SONiC images. -## Components +[^ Back to Table of Contents](#table-of-contents) + +--- + +## 7. Components + +### 7.1. bmcweb -### bmcweb - **Source**: https://github.com/openbmc/bmcweb - **Base commit**: 6926d430 (configurable via `BMCWEB_HEAD_COMMIT`) - **License**: Apache-2.0 @@ -192,7 +245,8 @@ The Debian packages can be installed in SONiC images. - **Output**: `bmcweb_1.0.0_arm64.deb`, `bmcweb-dbg_1.0.0_arm64.deb` - **Auto-clone**: Automatically cloned from GitHub if not present -### sonic-dbus-bridge +### 7.2. sonic-dbus-bridge + - **License**: Apache-2.0 - **Purpose**: Bridge SONiC Redis database to D-Bus for bmcweb integration - **Features**: @@ -205,9 +259,13 @@ The Debian packages can be installed in SONiC images. - **Output**: `sonic-dbus-bridge_1.0.0_arm64.deb`, `sonic-dbus-bridge-dbgsym_1.0.0_arm64.deb` - **Configuration**: `config/config.yaml` for Redis, D-Bus, and platform settings -## Configuration +[^ Back to Table of Contents](#table-of-contents) + +--- + +## 8. Configuration -### sonic-dbus-bridge Configuration +### 8.1. sonic-dbus-bridge configuration The bridge is configured via `sonic-dbus-bridge/config/config.yaml`: @@ -217,9 +275,10 @@ The bridge is configured via `sonic-dbus-bridge/config/config.yaml`: - **Update behavior**: Polling intervals and pub/sub settings - **Logging**: Log levels and output configuration -### D-Bus Configuration Files +### 8.2. D-Bus configuration files D-Bus security policies are defined in `sonic-dbus-bridge/dbus/`: + - `xyz.openbmc_project.Inventory.Manager.conf` - Inventory management - `xyz.openbmc_project.ObjectMapper.conf` - Object mapper service - `xyz.openbmc_project.State.Host.conf` - Host state management @@ -228,13 +287,74 @@ D-Bus security policies are defined in `sonic-dbus-bridge/dbus/`: These files are installed to `/etc/dbus-1/system.d/` during package installation. -## Redfish API Endpoints +[^ Back to Table of Contents](#table-of-contents) -Below are the currently supported Redfish API endpoints and their sample responses. +--- + +## 9. OEM Extension + +A SONiC-specific OEM extension is exposed under +`Manager.Oem.SONiC.RackManager` and provides two POST actions +(`SubmitAlert`, `SubmitTelemetry`) that a rack-manager device uses to +push structured alerts and periodic telemetry into the BMC. bmcweb +validates and forwards the JSON body verbatim over D-Bus to +`sonic-dbus-bridge`, which persists it as `HSET` rows in Redis STATE_DB. + +The full contract - body envelopes, JSON schemas, per-action request +/ response / Redis state, and error responses - lives in: + +- **[oem-extension/README.md](oem-extension/README.md)** - OEM contract, + schema bindings, and worked POST examples. + +Source layout: -### FirmwareInventory Collection +- `oem-extension/sonic/` - bmcweb-side route handlers + (`sonic_submit_alert.hpp`, `sonic_submit_telemetry.hpp`, + `sonic_rack_manager.hpp`, `sonic_oem_redfish.hpp`). +- `oem-extension/schema/json-schema/` - authoritative + `SonicManager.v1_0_0.json` schema (and its unversioned alias). +[^ Back to Table of Contents](#table-of-contents) + +--- + +## 10. Testing + +Two independent test suites live under [`tests/`](tests/): + +| Suite | Type | Runner | +|--------------------------------------------|-----------------------|-------------------| +| [`tests/redfish-api/`](tests/redfish-api/) | pytest integration | `make test` | +| [`tests/unit-tests/`](tests/unit-tests/) | C++ gtest unit tests | `make unit-test` | + +The integration suite spins up the whole Redfish stack +(dbus-daemon -> redis -> sonic-dbus-bridge -> bmcweb) inside a Docker +container and hits the live HTTPS API on `https://localhost:443`. The +unit suite covers pure-logic C++ classes in `sonic-dbus-bridge/` with +no Redis, no D-Bus, and no network. + +Quick start: + +```bash +make test # full integration suite (builds the test image first) +make unit-test # C++ unit tests in the builder image ``` + +See **[tests/README.md](tests/README.md)** for the JSON case schema, +fixtures, debugging recipes (`NODELETE=1`), and the guide for adding a +new test case. + +[^ Back to Table of Contents](#table-of-contents) + +--- + +## 11. Redfish API Endpoints + +Below are the currently supported Redfish API endpoints and their sample responses. + +### 11.1. FirmwareInventory Collection + +```http GET /redfish/v1/UpdateService/FirmwareInventory ``` @@ -258,9 +378,9 @@ GET /redfish/v1/UpdateService/FirmwareInventory } ``` -### FirmwareInventory - BIOS +### 11.2. FirmwareInventory - BIOS -``` +```http GET /redfish/v1/UpdateService/FirmwareInventory/bios ``` @@ -281,9 +401,9 @@ GET /redfish/v1/UpdateService/FirmwareInventory/bios } ``` -### FirmwareInventory - BMC Firmware +### 11.3. FirmwareInventory - BMC Firmware -``` +```http GET /redfish/v1/UpdateService/FirmwareInventory/bmc ``` @@ -310,9 +430,9 @@ GET /redfish/v1/UpdateService/FirmwareInventory/bmc } ``` -### FirmwareInventory - Switch +### 11.4. FirmwareInventory - Switch -``` +```http GET /redfish/v1/UpdateService/FirmwareInventory/switch ``` @@ -339,9 +459,9 @@ GET /redfish/v1/UpdateService/FirmwareInventory/switch } ``` -### Service Root +### 11.5. Service Root -``` +```http GET /redfish/v1/ ``` @@ -420,9 +540,9 @@ GET /redfish/v1/ } ``` -### ComputerSystem.Reset - Power On +### 11.6. ComputerSystem.Reset - Power On -``` +```http POST /redfish/v1/Systems/system/Actions/ComputerSystem.Reset Content-Type: application/json @@ -432,7 +552,7 @@ Content-Type: application/json This action publishes a `RACK_MANAGER_COMMAND` row to STATE_DB which `sonic-bmcctld` (sonic-platform-daemons) consumes: -``` +```text root@sonic:/home/admin# redis-cli -n 6 KEYS 'RACK_MANAGER_COMMAND|*' 1) "RACK_MANAGER_COMMAND|CMD_1775040896_000001" @@ -447,9 +567,9 @@ root@sonic:/home/admin# redis-cli -n 6 HGETALL 'RACK_MANAGER_COMMAND|CMD_1775040 8) "2026-05-08T12:34:56.157648Z" ``` -### ComputerSystem.Reset - Graceful Shutdown +### 11.7. ComputerSystem.Reset - Graceful Shutdown -``` +```http POST /redfish/v1/Systems/system/Actions/ComputerSystem.Reset Content-Type: application/json @@ -458,7 +578,7 @@ Content-Type: application/json Redis STATE_DB after the request: -``` +```text root@sonic:/home/admin# redis-cli -n 6 HGETALL 'RACK_MANAGER_COMMAND|CMD_1775041067_000002' 1) "command" 2) "POWER_OFF" @@ -470,9 +590,9 @@ root@sonic:/home/admin# redis-cli -n 6 HGETALL 'RACK_MANAGER_COMMAND|CMD_1775041 8) "2026-05-08T12:37:47.766120Z" ``` -### ComputerSystem.Reset - Power Cycle +### 11.8. ComputerSystem.Reset - Power Cycle -``` +```http POST /redfish/v1/Systems/system/Actions/ComputerSystem.Reset Content-Type: application/json @@ -481,7 +601,7 @@ Content-Type: application/json Redis STATE_DB after the request: -``` +```text root@sonic:/home/admin# redis-cli -n 6 HGETALL 'RACK_MANAGER_COMMAND|CMD_1775041121_000003' 1) "command" 2) "POWER_CYCLE" @@ -499,21 +619,14 @@ failure (e.g. `CRITICAL_LEAK_PRESENT`). Authoritative host power state is published by the daemon to `HOST_STATE|switch-host` (`device_power_state`, `device_status`, `last_change_timestamp`). -## Troubleshooting - -### Build fails with "debian/changelog: No such file or directory" -Run `make reset` to completely clean the workspace, then rebuild. +[^ Back to Table of Contents](#table-of-contents) -### Permission denied when cleaning -The build creates root-owned files inside Docker. The Makefile uses `sudo rm` to clean them. -Make sure you have sudo access. +--- -### Docker image build fails -Check your internet connection - the build downloads packages from Debian repositories. +## 12. License -### Meson subproject download fails -Check internet connection and firewall settings. Meson needs to access GitHub to download dependencies. -## License Apache-2.0 + +[^ Back to Table of Contents](#table-of-contents) diff --git a/oem-extension/README.md b/oem-extension/README.md new file mode 100644 index 0000000..b8cc8ab --- /dev/null +++ b/oem-extension/README.md @@ -0,0 +1,341 @@ +# SONiC OEM Redfish Extension + +> <- Back to [sonic-redfish root README](../README.md) + +This directory contains the SONiC OEM extension for the Redfish Manager +resource, plus the JSON schemas that document the OEM contract. + +## Table of contents + +1. [What the extension does](#1-what-the-extension-does) +2. [Body envelopes at a glance](#2-body-envelopes-at-a-glance) +3. [Schema structure](#3-schema-structure) + 1. [Schema bindings for the POST body](#31-schema-bindings-for-the-post-body) +4. [How to prepare a POST request body](#4-how-to-prepare-a-post-request-body) + 1. [SubmitAlert - flat form](#41-submitalert---flat-form) + 2. [SubmitAlert - ShutdownAlert wrapped form](#42-submitalert---shutdownalert-wrapped-form) + 3. [SubmitTelemetry](#43-submittelemetry) +5. [Error responses](#5-error-responses) + +--- + +## 1. What the extension does + +A rack-manager device pushes structured alerts and periodic telemetry into +the switch BMC via two POST actions on a single OEM sub-resource: + +```http +POST /redfish/v1/Managers//Oem/SONiC/RackManager/Actions/SONiC.SubmitAlert +POST /redfish/v1/Managers//Oem/SONiC/RackManager/Actions/SONiC.SubmitTelemetry +``` + +bmcweb validates and forwards the JSON body verbatim over D-Bus to +`sonic-dbus-bridge`, which walks a declarative field-mapping table +(`sonic-dbus-bridge/include/field_mapping.hpp`) to persist the data as +`HSET` commands into Redis **STATE_DB** (db index 6). + +[^ Back to Table of Contents](#table-of-contents) + +--- + +## 2. Body envelopes at a glance + +| Action | Top-level key | Notes | +| ----------------- | ------------- | ------------------------------------------------------ | +| `SubmitAlert` | `Redfish` | Open map. alert-type-name -> `AlertEntry` or wrapper. | +| `SubmitTelemetry` | `Alarms` | Open map. sensor flags + scalars + deviation blocks. | + +> **Note:** bmcweb returns `400 PropertyMissing` if the expected envelope +> key is absent. + +[^ Back to Table of Contents](#table-of-contents) + +--- + +## 3. Schema structure + +`SonicManager.v1_0_0.json` is the authoritative schema. Its definitions: + +1. **`Manager`** - the OEM extension object embedded under `Manager.Oem.SONiC`. +2. **`RackManager`** - the OEM sub-resource (its own `@odata.id`). +3. **`SubmitAlert`** - the alert POST action (descriptor + parameters). +4. **`AlertEntry`** - the per-alert entry shape. `Severity` is required; + `RscmPosition` is optional because the wrapped form (see below) puts it + on the parent. +5. **`SubmitTelemetry`** - the telemetry POST action. +6. **`Alarms`** - the telemetry payload container (open). +7. **`SonicSeverity`** - the severity enum used throughout + (`Normal` / `Minor` / `Major` / `Critical`). + +`SonicManager.json` is the unversioned alias. Clients should reference +`SonicManager.Manager`; the alias resolves to the latest versioned schema +(currently `v1_0_0`). + +### 3.1. Schema bindings for the POST body + +Per DMTF action idiom, each action definition splits into two blocks: the +`parameters` block describes the **POST request body** (what clients send); +the `properties` block (`target`, `title`) describes how the action is +**advertised** on GET of the parent resource. The body is validated against +`parameters`, not `properties`. + +| Where in the JSON body | Schema definition (`#/definitions/...`) | Required | Open map? | Notes | +| ----------------------------------- | --------------------------------------- | -------- | --------- | ------------------------------------------------------------------------------------------------------ | +| `SubmitAlert` body root | `SubmitAlert.parameters` | yes | n/a | Body must contain the `Redfish` parameter. | +| `SubmitAlert` -> `Redfish` | `SubmitAlert.parameters.Redfish` | yes | yes | Container keyed by alert-type name; values are `AlertEntry` or a wrapper (e.g. `ShutdownAlert`). | +| Each alert entry (flat or nested) | `AlertEntry` | yes | yes | `Severity` required. `RscmPosition` lives on the entry (flat) or on the wrapper (nested). | +| `SubmitTelemetry` body root | `SubmitTelemetry.parameters` | yes | n/a | Body must contain the `Alarms` parameter. | +| `SubmitTelemetry` -> `Alarms` | `Alarms` | yes | yes | Open object; field set is defined by the bridge's `field_mapping.hpp`, not enumerated in this schema. | +| `Severity` value anywhere | `SonicSeverity` | - | - | Enum: `Normal` / `Minor` / `Major` / `Critical`. | + +> **Open map** (`additionalProperties: true`) means the schema deliberately +> does not enumerate every legal key - the receiver (sonic-dbus-bridge) +> decides which keys it recognises and stores. Unknown keys are accepted +> by bmcweb and forwarded verbatim; the bridge silently drops keys not +> present in its field-mapping table. + +[^ Back to Table of Contents](#table-of-contents) + +--- + +## 4. How to prepare a POST request body + +Each subsection below documents one supported body shape with three +parts: the **request** sent to bmcweb, the **response** returned, and +the resulting **Redis STATE_DB** state. + +### 4.1. SubmitAlert - flat form + +The `Redfish` envelope holds one entry per alert type. Each entry must +carry `Severity` (one of `Normal` / `Minor` / `Major` / `Critical`) and, +in this form, `RscmPosition`. Type-specific fields (`FlowRate`, +`LiquidPressure`, `InletTemperature`, ...) sit alongside. + +**Request** + +```json +POST /redfish/v1/Managers/bmc/Oem/SONiC/RackManager/Actions/SONiC.SubmitAlert +{ + "Redfish": { + "Alerts": { + "InletTemperature": 18, + "FlowRate": 58, + "Severity": "Minor", + "RscmPosition": 1 + }, + "LiquidPressureDeviation": { + "LiquidPressure": 68, + "Severity": "Major", + "RscmPosition": 1 + }, + "InletTemperatureDeviation": { + "InletTemperature": 46, + "Severity": "Critical", + "RscmPosition": 1 + }, + "LeakDetected": { "Severity": "Critical", "RscmPosition": 1 }, + "LeakRopeBreak": { "Severity": "Critical", "RscmPosition": 1 } + } +} +``` + +**Response** + +```http +HTTP/1.1 204 No Content +``` + +**Redis STATE_DB after the POST** + +Each alert-type entry lands under its own `RSCM_ALERT|` hash: + +```text +HGETALL RSCM_ALERT|Alerts + severity = "Minor" + rscm_position = "1" + inlet_temperature = "18" + flow_rate = "58" + +HGETALL RSCM_ALERT|LiquidPressureDeviation + severity = "Major" + rscm_position = "1" + liquid_pressure = "68" + +HGETALL RSCM_ALERT|InletTemperatureDeviation + severity = "Critical" + rscm_position = "1" + inlet_temperature = "46" + +HGETALL RSCM_ALERT|LeakDetected + severity = "Critical" + rscm_position = "1" + +HGETALL RSCM_ALERT|LeakRopeBreak + severity = "Critical" + rscm_position = "1" +``` + +> **Note:** the key `Alerts` *inside* `Redfish` is an alert-type name +> (the combined inlet-temperature + flow-rate alert) - it is not an +> envelope. + +### 4.2. SubmitAlert - ShutdownAlert wrapped form + +`ShutdownAlert` groups multiple leaf alerts under one wrapper that owns a +single `RscmPosition`. Leaf alerts in this form carry only `Severity` and +their type-specific fields. + +**Request** + +```json +POST /redfish/v1/Managers/bmc/Oem/SONiC/RackManager/Actions/SONiC.SubmitAlert +{ + "Redfish": { + "ShutdownAlert": { + "FlowRateDeviation": { "FlowRate": 58, "Severity": "Minor" }, + "TempDeviation": { "InletTemperature": 17,"Severity": "Normal" }, + "LiquidPressureDeviation": { "LiquidPressure": 68, "Severity": "Major" }, + "LeakDetected": { "Severity": "Critical" }, + "LeakRopeBreak": { "Severity": "Critical" }, + "RscmPosition": 3 + } + } +} +``` + +**Response** + +```http +HTTP/1.1 204 No Content +``` + +**Redis STATE_DB after the POST** + +The wrapper's `RscmPosition` lands on its own hash; each leaf gets a +child key: + +```text +HGETALL RSCM_ALERT|ShutdownAlert + rscm_position = "3" + +HGETALL RSCM_ALERT|ShutdownAlert|FlowRateDeviation + severity = "Minor" + flow_rate = "58" + +HGETALL RSCM_ALERT|ShutdownAlert|TempDeviation + severity = "Normal" + inlet_temperature = "17" + +HGETALL RSCM_ALERT|ShutdownAlert|LiquidPressureDeviation + severity = "Major" + liquid_pressure = "68" + +HGETALL RSCM_ALERT|ShutdownAlert|LeakDetected + severity = "Critical" + +HGETALL RSCM_ALERT|ShutdownAlert|LeakRopeBreak + severity = "Critical" +``` + +> **Note:** Readers should join the wrapper hash with each leaf hash to +> recover the full context of a wrapped alert. + +### 4.3. SubmitTelemetry + +**Request** + +```json +POST /redfish/v1/Managers/bmc/Oem/SONiC/RackManager/Actions/SONiC.SubmitTelemetry +{ + "Alarms": { + "EnergyValveActive": true, + "EnergyValvePresent": true, + "FlowrateSensorActive": true, + "PressureSensorActive": true, + "TemperatureSensorActive": true, + "InletTempDeviation": { "InletTemperature": 16.87, "Severity": "Normal" }, + "FlowRateDeviation": { "FlowRate": 28, "Severity": "Normal" }, + "LiquidPressureDeviation": { "LiquidPressure": 2, "Severity": "Critical" }, + "LeakDetected": { + "LeakDetected": false, + "LeakRopeBreak": false, + "Severity": "Normal" + }, + "ThresholdConfigVersion": "03.03", + "GlycolConcentration": 0.0, + "ErrorState": "0", + "RscmPosition": 3, + "ConfigFileCorrupted": false + } +} +``` + +**Response** + +```http +HTTP/1.1 204 No Content +``` + +**Redis STATE_DB after the POST** + +All telemetry lands in a single Redis hash, `RSCM_TELEMETRY|alarms`, with +one field per mapping-table row: + +```text +HGETALL RSCM_TELEMETRY|alarms + energy_valve_active = "true" + energy_valve_present = "true" + flowrate_sensor_active = "true" + pressure_sensor_active = "true" + temperature_sensor_active = "true" + inlet_temp_deviation_temperature = "16.87" + inlet_temp_deviation_severity = "Normal" + flow_rate_deviation_flow_rate = "28" + flow_rate_deviation_severity = "Normal" + liquid_pressure_deviation_pressure = "2" + liquid_pressure_deviation_severity = "Critical" + leak_detected = "false" + leak_rope_break = "false" + leak_detected_severity = "Normal" + threshold_config_version = "03.03" + glycol_concentration = "0" + error_state = "0" + rscm_position = "3" + config_file_corrupted = "false" +``` + +[^ Back to Table of Contents](#table-of-contents) + +--- + +## 5. Error responses + +The success path is body-less (`204 No Content`). Negative paths return a +standard Redfish error envelope. + +| Condition | Status | Body shape | +| ---------------------------------------- | ------ | -------------------------------------------------------------------------------- | +| Empty / malformed JSON body | 400 | `error.code == Base.1.19.MalformedJSON` | +| Missing top-level envelope key | 400 | `@Message.ExtendedInfo[0].MessageId == Base.1.19.PropertyMissing` | +| GET (or any non-POST) on action target | 405 | `error.code == Base.1.19.OperationNotAllowed` | +| Unauthenticated POST | 401 | empty body (challenge headers only) | + +**Example - missing top-level key on SubmitAlert** + +```http +POST .../SONiC.SubmitAlert +{ "NotRedfish": {} } + +HTTP/1.1 400 Bad Request +{ + "Redfish@Message.ExtendedInfo": [ + { + "MessageId": "Base.1.19.PropertyMissing", + "MessageArgs": ["Redfish"], + "Message": "The property Redfish is a required property and must be included in the request." + } + ] +} +``` + +[^ Back to Table of Contents](#table-of-contents) diff --git a/oem-extension/schema/json-schema/SonicManager.json b/oem-extension/schema/json-schema/SonicManager.json new file mode 100644 index 0000000..0439dd8 --- /dev/null +++ b/oem-extension/schema/json-schema/SonicManager.json @@ -0,0 +1,19 @@ +{ + "$id": "https://github.com/sonic-net/sonic-redfish/tree/master/oem-extension/schema/json-schema/SonicManager.json", + "$ref": "/redfish/v1/JsonSchemas/SonicManager/SonicManager.v1_0_0.json#/definitions/Manager", + "$schema": "http://redfish.dmtf.org/schemas/v1/redfish-schema-v1.json", + "copyright": "Copyright 2026 SONiC Contributors.", + "definitions": { + "Manager": { + "anyOf": [ + { + "$ref": "/redfish/v1/JsonSchemas/SonicManager/SonicManager.v1_0_0.json#/definitions/Manager" + } + ], + "description": "SONiC OEM extension for the Redfish Manager resource (unversioned alias).", + "longDescription": "Unversioned alias schema for the SONiC Manager OEM extension. Clients should resolve this reference to the latest available versioned schema (currently v1_0_0)." + } + }, + "owningEntity": "SONiC", + "title": "#SonicManager.Manager" +} diff --git a/oem-extension/schema/json-schema/SonicManager.v1_0_0.json b/oem-extension/schema/json-schema/SonicManager.v1_0_0.json new file mode 100644 index 0000000..0b6c018 --- /dev/null +++ b/oem-extension/schema/json-schema/SonicManager.v1_0_0.json @@ -0,0 +1,160 @@ +{ + "$id": "https://github.com/sonic-net/sonic-redfish/tree/master/oem-extension/schema/json-schema/SonicManager.v1_0_0.json", + "$ref": "#/definitions/Manager", + "$schema": "http://redfish.dmtf.org/schemas/v1/redfish-schema-v1.json", + "copyright": "Copyright 2026 SONiC Contributors and Nexthop AI.", + "definitions": { + "Manager": { + "additionalProperties": false, + "anyOf": [ + { + "$ref": "http://redfish.dmtf.org/schemas/v1/Resource.json#/definitions/OemObject" + } + ], + "description": "The SONiC OEM extension for the Manager resource.", + "longDescription": "This object shall contain SONiC-specific properties added to the standard Redfish Manager resource as an OEM extension.", + "patternProperties": { + "^([a-zA-Z_][a-zA-Z0-9_]*)?@(odata|Redfish|Message)\\.[a-zA-Z_][a-zA-Z0-9_]*$": { + "type": ["array", "boolean", "integer", "number", "null", "object", "string"] + } + }, + "properties": { + "RackManager": { + "$ref": "#/definitions/RackManager", + "description": "The link to the SONiC rack-manager communication sub-resource." + } + }, + "type": "object" + }, + "RackManager": { + "additionalProperties": false, + "anyOf": [ + { + "$ref": "http://redfish.dmtf.org/schemas/v1/Resource.json#/definitions/OemObject" + } + ], + "description": "The SONiC rack-manager communication sub-resource.", + "longDescription": "This resource shall expose inbound actions through which a rack manager pushes structured alerts and periodic telemetry to the switch BMC.", + "patternProperties": { + "^([a-zA-Z_][a-zA-Z0-9_]*)?@(odata|Redfish|Message)\\.[a-zA-Z_][a-zA-Z0-9_]*$": { + "type": ["array", "boolean", "integer", "number", "null", "object", "string"] + } + }, + "properties": { + "Actions": { + "additionalProperties": false, + "description": "The available actions for this resource.", + "longDescription": "This property shall contain the available actions for this resource.", + "properties": { + "#SONiC.SubmitAlert": { "$ref": "#/definitions/SubmitAlert" }, + "#SONiC.SubmitTelemetry": { "$ref": "#/definitions/SubmitTelemetry" } + }, + "type": "object" + }, + "Description": { + "$ref": "http://redfish.dmtf.org/schemas/v1/Resource.json#/definitions/Description", + "readonly": true + }, + "Id": { + "$ref": "http://redfish.dmtf.org/schemas/v1/Resource.json#/definitions/Id", + "readonly": true + }, + "Name": { + "$ref": "http://redfish.dmtf.org/schemas/v1/Resource.json#/definitions/Name", + "readonly": true + } + }, + "required": ["@odata.id", "@odata.type", "Id", "Name"], + "type": "object" + }, + "SubmitAlert": { + "additionalProperties": false, + "description": "Submits structured alert data from a rack manager to the switch BMC.", + "longDescription": "This action shall submit a set of structured alert entries from a rack manager to the switch BMC. Per DMTF action idiom this definition serves two roles: the `parameters` block describes the POST request body (clients sending POSTs should look there); the `properties` block (target, title) describes how the action is advertised on GET of the parent resource.", + "parameters": { + "Redfish": { + "additionalProperties": true, + "description": "Open container for alert entries, keyed by alert type name.", + "longDescription": "Required. Keys are vendor-defined alert type names; values are either an AlertEntry or a wrapper object (for example, ShutdownAlert) whose children are themselves AlertEntry-shaped with a shared RscmPosition on the wrapper.", + "requiredParameter": true, + "type": "object" + } + }, + "properties": { + "target": { + "description": "Link to invoke action.", + "format": "uri-reference", + "type": "string" + }, + "title": { + "description": "Friendly action name.", + "type": "string" + } + }, + "type": "object" + }, + "AlertEntry": { + "additionalProperties": true, + "description": "A single alert entry.", + "longDescription": "Each entry shall include Severity. RscmPosition is present on flat alerts and inherited from the wrapper for nested alerts under a ShutdownAlert-style container. Additional properties beyond these are alert-type specific (for example FlowRate, LiquidPressure, InletTemperature) and shall be passed through to the BMC for storage.", + "properties": { + "RscmPosition": { + "description": "RSCM position identifier; optional in nested form (inherited from the parent wrapper).", + "type": "integer" + }, + "Severity": { + "$ref": "#/definitions/SonicSeverity", + "description": "Severity of the alert condition." + } + }, + "required": ["Severity"], + "type": "object" + }, + "SubmitTelemetry": { + "additionalProperties": false, + "description": "Submits periodic telemetry and alarm data from a rack manager to the switch BMC.", + "longDescription": "This action shall submit a periodic telemetry payload from a rack manager to the switch BMC. Per DMTF action idiom this definition serves two roles: the `parameters` block describes the POST request body; the `properties` block (target, title) describes how the action is advertised on GET of the parent resource.", + "parameters": { + "Alarms": { + "$ref": "#/definitions/Alarms", + "description": "Container for alarm and sensor telemetry data.", + "longDescription": "The value of this parameter shall conform to Alarms. Receivers shall pass unknown scalar properties through to the BMC for storage.", + "requiredParameter": true + } + }, + "properties": { + "target": { + "description": "Link to invoke action.", + "format": "uri-reference", + "type": "string" + }, + "title": { + "description": "Friendly action name.", + "type": "string" + } + }, + "type": "object" + }, + "Alarms": { + "additionalProperties": true, + "description": "Open telemetry payload container.", + "longDescription": "Following the SubmitAlert idiom (the `Redfish` parameter on SubmitAlert is also an open container; see AlertEntry for the per-entry shape), this object is intentionally open: the schema does not enumerate individual sensor or measurement fields. The set of recognised keys is defined by the bridge's declarative field-mapping table.", + "type": "object" + }, + "SonicSeverity": { + "description": "Severity classification used by SONiC OEM alert and telemetry payloads.", + "longDescription": "This string enumeration shall classify the severity of an alert or telemetry deviation reported by the rack manager. The values are SONiC-specific and do not map one-to-one to the DMTF Health enumeration; receivers shall accept and store the value verbatim.", + "enum": ["Normal", "Minor", "Major", "Critical"], + "enumDescriptions": { + "Normal": "No abnormal condition; the reported value is within expected bounds.", + "Minor": "Degraded condition; service continues without immediate intervention.", + "Major": "Service-impacting condition; operator intervention is expected.", + "Critical": "Service-affecting condition; immediate intervention is required." + }, + "type": "string" + } + }, + "owningEntity": "SONiC", + "release": "1.0", + "title": "#SonicManager.v1_0_0.Manager" +} diff --git a/oem-extension/schema/meson.build b/oem-extension/schema/meson.build new file mode 100644 index 0000000..87eb3e2 --- /dev/null +++ b/oem-extension/schema/meson.build @@ -0,0 +1,9 @@ +# SONiC OEM schema installation +# Installs JSON schemas for SONiC OEM Redfish extensions + +install_data( + 'json-schema/SonicManager.json', + 'json-schema/SonicManager.v1_0_0.json', + install_dir: 'share/www/redfish/v1/JsonSchemas', + follow_symlinks: true, +) diff --git a/oem-extension/sonic/sonic_oem_constants.hpp b/oem-extension/sonic/sonic_oem_constants.hpp new file mode 100644 index 0000000..6bc19a3 --- /dev/null +++ b/oem-extension/sonic/sonic_oem_constants.hpp @@ -0,0 +1,39 @@ +/////////////////////////////////////// +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Nexthop AI +// Copyright (C) 2026 SONiC Project +// Author: Nexthop AI +// Author: SONiC Project +// License file: sonic-redfish/LICENSE +/////////////////////////////////////// + +#pragma once + +#include + +// +// Shared constants for SONiC OEM Redfish route handlers. +// +// Centralised so that the bus name, object path and interface are defined +// in exactly one place; the bridge side (sonic-dbus-bridge) advertises the +// same triple via its own header (rack_manager_receiver.hpp) and a +// compile-time check there guards against drift. +// + +namespace redfish::sonic_oem +{ + +// D-Bus coordinates for the sonic-dbus-bridge rack manager receiver. +// The bridge claims `com.sonic.RackManager` as a dedicated well-known +// name; methods live under /com/sonic/RackManager on the interface of +// the same name. +inline constexpr const char* rackManagerBusName = "com.sonic.RackManager"; +inline constexpr const char* rackManagerObjectPath = "/com/sonic/RackManager"; +inline constexpr const char* rackManagerInterface = "com.sonic.RackManager"; + +// Hard upper bound on POST body size accepted by the SONiC OEM action +// routes. Rack-manager alert/telemetry payloads are small (a few KB); +// anything beyond this is rejected before being forwarded to D-Bus. +inline constexpr std::size_t kMaxRequestBodyBytes = 64 * 1024; // 64 KiB + +} // namespace redfish::sonic_oem diff --git a/oem-extension/sonic/sonic_oem_redfish.hpp b/oem-extension/sonic/sonic_oem_redfish.hpp new file mode 100644 index 0000000..e400b56 --- /dev/null +++ b/oem-extension/sonic/sonic_oem_redfish.hpp @@ -0,0 +1,97 @@ +/////////////////////////////////////// +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Nexthop AI +// Copyright (C) 2026 SONiC Project +// Author: Nexthop AI +// Author: SONiC Project +// License file: sonic-redfish/LICENSE +/////////////////////////////////////// + +#pragma once + +#include "sonic/sonic_rack_manager.hpp" +#include "sonic/sonic_submit_alert.hpp" +#include "sonic/sonic_submit_telemetry.hpp" + +// ============================================================================= +// SONiC OEM Redfish extension — design notes +// ============================================================================= +// +// Scope +// ----- +// This OEM extension adds a single sub-resource, RackManager, under the +// standard Redfish Manager (bmc) /Oem/SONiC tree, exposing two POST actions: +// * #SONiC.SubmitAlert — structured alert push from a rack manager +// * #SONiC.SubmitTelemetry — periodic telemetry/alarm push from a rack +// manager +// +// Why an OEM action and not Redfish EventService / TelemetryService +// ---------------------------------------------------------------- +// Both EventService and TelemetryService were considered. The deciding +// factors against them, given the deployment shape (a chassis-internal rack +// manager pushing into a switch BMC over a private link): +// * Direction. EventService is BMC-as-event-source pushing to a remote +// listener — the opposite direction of what is needed here. Reusing it +// as an "inbound" channel via SSE subscriptions would invert the trust +// and addressing model. +// * Schema fit. TelemetryService is centered on MetricReports and +// MetricDefinitions backed by a Sensor pull model. The rack manager +// pushes a fixed, flat blob of alarm/sensor scalars on a non-uniform +// cadence; modeling each field as a Sensor would require Sensor +// resources and a metric definition per field for no functional +// gain on the read side. +// * Surface. A pair of POST actions is the smallest surface that captures +// the contract. It keeps the OEM footprint reviewable line-by-line and +// leaves room to migrate individual fields into standard Sensor / +// MetricReport resources later without breaking the rack-manager client. +// +// Why a JSON blob over D-Bus (RackManagerReceiver) instead of typed signals +// ------------------------------------------------------------------------ +// The HTTP body is forwarded verbatim, as a JSON string, over a single +// D-Bus method on the well-known name `com.sonic.RackManager`. Alternatives +// were considered: +// * Per-field D-Bus arguments — would require regenerating the interface +// whenever the rack-manager contract grew a field, coupling the bridge's +// release cadence to the rack-manager's. +// * sd-bus signals — fire-and-forget, no per-call ack, harder to surface +// a 5xx back to the rack manager when Redis is down. +// The JSON-blob choice: +// * Keeps the D-Bus interface stable across schema growth. +// * Preserves field-level evolvability (additional alarm types just appear +// in the blob; the bridge's field_mapping table grows alongside). +// * Gives a natural unit of work for the bridge's worker thread: +// parse → resolve JSON paths → pipelined HSET to STATE_DB. +// The trade-off is that field-level type checking moves from D-Bus into the +// bridge (handled by field_mapping.hpp), and JSON parse cost is paid on the +// bridge side; both are acceptable at the expected request rate . +// +// Threading and back-pressure +// --------------------------- +// bmcweb's action handlers do non-blocking sd-bus method calls only. The +// bridge serializes Redis I/O onto a dedicated worker thread with a bounded +// queue, so a slow Redis cannot stall the sd-bus dispatch loop and starve +// other bmcweb traffic. See rack_manager_receiver.{hpp,cpp} for details. +// ============================================================================= + +namespace redfish +{ + +/** + * @brief Register all SONiC OEM Redfish routes. + * + * This is the single entry point called from redfish.cpp. + * To add a new OEM API: + * 1. Create a new header in sonic/ + * 2. Add #include and call its requestRoutes function here. + * + * @param app Crow application for registering standalone action routes + * @param service RedfishService for registering OEM sub-routes (fragments) + */ +inline void requestRoutesSonicOem(App& app, RedfishService& service) +{ + requestRoutesSonicRackManager(service); + requestRoutesSonicSubmitAlert(app); + requestRoutesSonicSubmitTelemetry(app); +} + +} // namespace redfish diff --git a/oem-extension/sonic/sonic_rack_manager.hpp b/oem-extension/sonic/sonic_rack_manager.hpp new file mode 100644 index 0000000..a60dc0e --- /dev/null +++ b/oem-extension/sonic/sonic_rack_manager.hpp @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright SONiC Contributors +#pragma once + +#include "async_resp.hpp" +#include "redfish.hpp" +#include "sub_request.hpp" + +#include + +#include +#include + +namespace redfish +{ + +/** + * @brief Handle GET /redfish/v1/Managers//Oem/SONiC/RackManager + * + * Returns the SONiC RackManager OEM sub-resource, which describes + * the available actions that a rack manager can invoke on this BMC. + */ +inline void handleGetSonicRackManager( + const SubRequest& /*req*/, + const std::shared_ptr& asyncResp, + const std::string& managerId) +{ + if (managerId != BMCWEB_REDFISH_MANAGER_URI_NAME) + { + messages::resourceNotFound(asyncResp->res, "Manager", managerId); + return; + } + + nlohmann::json& json = asyncResp->res.jsonValue; + json["@odata.type"] = "#SonicManager.v1_0_0.RackManager"; + json["@odata.id"] = + "/redfish/v1/Managers/" + std::string(BMCWEB_REDFISH_MANAGER_URI_NAME) + + "/Oem/SONiC/RackManager"; + json["Id"] = "RackManager"; + json["Name"] = "SONiC Rack Manager Interface"; + json["Description"] = + "OEM interface for rack manager to communicate with switch BMC"; + + // Advertise available actions + nlohmann::json& actions = json["Actions"]; + actions["#SONiC.SubmitAlert"]["target"] = + "/redfish/v1/Managers/" + std::string(BMCWEB_REDFISH_MANAGER_URI_NAME) + + "/Oem/SONiC/RackManager/Actions/SONiC.SubmitAlert"; + + actions["#SONiC.SubmitTelemetry"]["target"] = + "/redfish/v1/Managers/" + std::string(BMCWEB_REDFISH_MANAGER_URI_NAME) + + "/Oem/SONiC/RackManager/Actions/SONiC.SubmitTelemetry"; +} + +inline void requestRoutesSonicRackManager(RedfishService& service) +{ + REDFISH_SUB_ROUTE< + "/redfish/v1/Managers//#/Oem/SONiC/RackManager">( + service, HttpVerb::Get)(handleGetSonicRackManager); +} + +} // namespace redfish diff --git a/oem-extension/sonic/sonic_submit_alert.hpp b/oem-extension/sonic/sonic_submit_alert.hpp new file mode 100644 index 0000000..57f991a --- /dev/null +++ b/oem-extension/sonic/sonic_submit_alert.hpp @@ -0,0 +1,144 @@ +/////////////////////////////////////// +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Nexthop AI +// Copyright (C) 2026 SONiC Project +// Author: Nexthop AI +// Author: SONiC Project +// License file: sonic-redfish/LICENSE +/////////////////////////////////////// + +#pragma once + +#include "app.hpp" +#include "async_resp.hpp" +#include "dbus_singleton.hpp" +#include "error_messages.hpp" +#include "http_request.hpp" +#include "logging.hpp" +#include "query.hpp" +#include "registries/privilege_registry.hpp" +#include "sonic/sonic_oem_constants.hpp" + +#include +#include + +#include +#include + +namespace redfish +{ + +/** + * @brief Handle POST .../Actions/SONiC.SubmitAlert + * + * Accepts the rack manager alert JSON and forwards it as-is to + * sonic-dbus-bridge via D-Bus. The bridge uses a declarative + * field-mapping table to persist the data in Redis STATE_DB. + * + * Request body example (flat form): + * { + * "Redfish": { + * "LiquidPressureDeviation": { + * "LiquidPressure": 68, + * "Severity": "Major", + * "RscmPosition": 1 + * }, + * "LeakDetected": { + * "Severity": "Critical", + * "RscmPosition": 1 + * }, + * ... + * } + * } + * + * Wrapped form (ShutdownAlert) is also accepted; see + * oem-extension/README.md for the full alert vocabulary. + */ +inline void handleSonicSubmitAlert( + App& app, const crow::Request& req, + const std::shared_ptr& asyncResp, + const std::string& managerId) +{ + if (!redfish::setUpRedfishRoute(app, req, asyncResp)) + { + return; + } + + if (managerId != BMCWEB_REDFISH_MANAGER_URI_NAME) + { + messages::resourceNotFound(asyncResp->res, "Manager", managerId); + return; + } + + // Reject oversized payloads before parsing + if (req.body().size() > sonic_oem::kMaxRequestBodyBytes) + { + BMCWEB_LOG_WARNING( + "SONiC.SubmitAlert: rejected body of {} bytes (cap {})", + req.body().size(), sonic_oem::kMaxRequestBodyBytes); + asyncResp->res.result( + boost::beast::http::status::payload_too_large); + return; + } + + // Validate that the body is valid JSON + nlohmann::json reqJson = + nlohmann::json::parse(req.body(), nullptr, false); + if (reqJson.is_discarded()) + { + messages::malformedJSON(asyncResp->res); + return; + } + + // Require the top-level key. The rack-manager firmware wraps every + // alert payload (flat or ShutdownAlert form) under "Redfish"; the + // bridge's field_mapping paths assume that envelope. + if (!reqJson.contains("Redfish")) + { + messages::propertyMissing(asyncResp->res, "Redfish"); + return; + } + + std::string jsonStr = reqJson.dump(); + + BMCWEB_LOG_INFO("SONiC.SubmitAlert: forwarding {} bytes to D-Bus", + jsonStr.size()); + + // Forward the raw JSON to sonic-dbus-bridge. + // Transport errors (bridge down, name not owned, timeout) and + // application errors (bridge returned false) are reported distinctly + // so operators can tell "service unavailable" from "bad payload". + crow::connections::systemBus->async_method_call( + [asyncResp](const boost::system::error_code& ec, bool success) { + if (ec) + { + BMCWEB_LOG_ERROR("SubmitAlert D-Bus transport error: {}", + ec.message()); + messages::serviceTemporarilyUnavailable(asyncResp->res, "1"); + return; + } + if (!success) + { + BMCWEB_LOG_WARNING( + "SubmitAlert: bridge rejected payload"); + messages::operationFailed(asyncResp->res); + return; + } + asyncResp->res.result( + boost::beast::http::status::no_content); + }, + sonic_oem::rackManagerBusName, sonic_oem::rackManagerObjectPath, + sonic_oem::rackManagerInterface, "SubmitAlert", jsonStr); +} + +inline void requestRoutesSonicSubmitAlert(App& app) +{ + BMCWEB_ROUTE( + app, + "/redfish/v1/Managers//Oem/SONiC/RackManager/Actions/SONiC.SubmitAlert") + .privileges(redfish::privileges::postManager) + .methods(boost::beast::http::verb::post)( + std::bind_front(handleSonicSubmitAlert, std::ref(app))); +} + +} // namespace redfish diff --git a/oem-extension/sonic/sonic_submit_telemetry.hpp b/oem-extension/sonic/sonic_submit_telemetry.hpp new file mode 100644 index 0000000..e129ade --- /dev/null +++ b/oem-extension/sonic/sonic_submit_telemetry.hpp @@ -0,0 +1,135 @@ +/////////////////////////////////////// +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Nexthop AI +// Copyright (C) 2026 SONiC Project +// Author: Nexthop AI +// Author: SONiC Project +// License file: sonic-redfish/LICENSE +/////////////////////////////////////// + +#pragma once + +#include "app.hpp" +#include "async_resp.hpp" +#include "dbus_singleton.hpp" +#include "error_messages.hpp" +#include "http_request.hpp" +#include "logging.hpp" +#include "query.hpp" +#include "registries/privilege_registry.hpp" +#include "sonic/sonic_oem_constants.hpp" + +#include +#include + +#include +#include + +namespace redfish +{ + +/** + * @brief Handle POST .../Actions/SONiC.SubmitTelemetry + * + * Accepts the rack manager telemetry JSON and forwards it as-is to + * sonic-dbus-bridge via D-Bus. The bridge uses a declarative + * field-mapping table to persist the data in Redis STATE_DB. + * + * Request body example: + * { + * "Alarms": { + * "EnergyValveActive": true, + * "InletTempDeviation": { + * "InletTemperature": 16.87, + * "Severity": "Normal" + * }, + * ... + * } + * } + */ +inline void handleSonicSubmitTelemetry( + App& app, const crow::Request& req, + const std::shared_ptr& asyncResp, + const std::string& managerId) +{ + if (!redfish::setUpRedfishRoute(app, req, asyncResp)) + { + return; + } + + if (managerId != BMCWEB_REDFISH_MANAGER_URI_NAME) + { + messages::resourceNotFound(asyncResp->res, "Manager", managerId); + return; + } + + // Reject oversized payloads before parsing + if (req.body().size() > sonic_oem::kMaxRequestBodyBytes) + { + BMCWEB_LOG_WARNING( + "SONiC.SubmitTelemetry: rejected body of {} bytes (cap {})", + req.body().size(), sonic_oem::kMaxRequestBodyBytes); + asyncResp->res.result( + boost::beast::http::status::payload_too_large); + return; + } + + // Validate that the body is valid JSON + nlohmann::json reqJson = + nlohmann::json::parse(req.body(), nullptr, false); + if (reqJson.is_discarded()) + { + messages::malformedJSON(asyncResp->res); + return; + } + + // Require the top-level key + if (!reqJson.contains("Alarms")) + { + messages::propertyMissing(asyncResp->res, "Alarms"); + return; + } + + std::string jsonStr = reqJson.dump(); + + BMCWEB_LOG_INFO("SONiC.SubmitTelemetry: forwarding {} bytes to D-Bus", + jsonStr.size()); + + // Forward the raw JSON to sonic-dbus-bridge. + // Transport errors (bridge down, name not owned, timeout) and + // application errors (bridge returned false) are reported distinctly + // so operators can tell "service unavailable" from "bad payload". + crow::connections::systemBus->async_method_call( + [asyncResp](const boost::system::error_code& ec, bool success) { + if (ec) + { + BMCWEB_LOG_ERROR("SubmitTelemetry D-Bus transport error: {}", + ec.message()); + messages::serviceTemporarilyUnavailable(asyncResp->res, "1"); + return; + } + if (!success) + { + BMCWEB_LOG_WARNING( + "SubmitTelemetry: bridge rejected payload"); + messages::operationFailed(asyncResp->res); + return; + } + asyncResp->res.result( + boost::beast::http::status::no_content); + }, + sonic_oem::rackManagerBusName, sonic_oem::rackManagerObjectPath, + sonic_oem::rackManagerInterface, "SubmitTelemetry", jsonStr); +} + +inline void requestRoutesSonicSubmitTelemetry(App& app) +{ + BMCWEB_ROUTE( + app, + "/redfish/v1/Managers//Oem/SONiC/RackManager/Actions/SONiC.SubmitTelemetry") + .privileges(redfish::privileges::postManager) + .methods(boost::beast::http::verb::post)( + std::bind_front(handleSonicSubmitTelemetry, std::ref(app))); +} + +} // namespace redfish diff --git a/patches/0003-Integrate-SONiC-OEM-extension.patch b/patches/0003-Integrate-SONiC-OEM-extension.patch new file mode 100644 index 0000000..86486c5 --- /dev/null +++ b/patches/0003-Integrate-SONiC-OEM-extension.patch @@ -0,0 +1,53 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: SONiC Team +Date: Tue, 25 Mar 2026 00:00:00 +0000 +Subject: [PATCH 3/3] Integrate SONiC OEM extension + +Register SONiC OEM Redfish routes and install OEM JSON schemas. + +OEM extension source files are copied into the bmcweb tree at build time +by the Makefile (copy-oem-extension target). This patch only wires them +into the bmcweb build and route registration: + + * redfish.cpp: include sonic_oem_redfish.hpp and call requestRoutesSonicOem() + * schema/oem/meson.build: add sonic subdirectory for OEM schema installation + +Routes added: + - GET /redfish/v1/Managers/Bmc/Oem/SONiC/RackManager (OEM sub-route) + - POST /redfish/v1/Managers/Bmc/Oem/SONiC/RackManager/Actions/SONiC.SubmitAlert + - POST /redfish/v1/Managers/Bmc/Oem/SONiC/RackManager/Actions/SONiC.SubmitTelemetry +--- + redfish-core/schema/oem/meson.build | 1 + + redfish-core/src/redfish.cpp | 3 +++ + 2 files changed, 4 insertions(+) + +diff --git a/redfish-core/schema/oem/meson.build b/redfish-core/schema/oem/meson.build +index abcdef1..1234567 100644 +--- a/redfish-core/schema/oem/meson.build ++++ b/redfish-core/schema/oem/meson.build +@@ -1 +1,2 @@ + subdir('openbmc') ++subdir('sonic') +diff --git a/redfish-core/src/redfish.cpp b/redfish-core/src/redfish.cpp +index abcdef2..1234568 100644 +--- a/redfish-core/src/redfish.cpp ++++ b/redfish-core/src/redfish.cpp +@@ -36,6 +36,7 @@ + #include "openbmc/openbmc_managers.hpp" + #include "pcie.hpp" + #include "power.hpp" ++#include "sonic/sonic_oem_redfish.hpp" + #include "power_subsystem.hpp" + #include "power_supply.hpp" + #include "processor.hpp" +@@ -249,6 +250,8 @@ RedfishService::RedfishService(App& app) + + requestRoutesOpenBmcManager(*this); + ++ requestRoutesSonicOem(app, *this); ++ + validate(); + } + +-- +2.34.1 diff --git a/patches/series b/patches/series index b6122ef..a30bd0d 100644 --- a/patches/series +++ b/patches/series @@ -7,4 +7,5 @@ 0001-Integrating-bmcweb-with-SONiC-s-build-system.patch 0002-Add-Product-field-to-Redfish-service-root.patch +0003-Integrate-SONiC-OEM-extension.patch diff --git a/scripts/format_pytest_output.py b/scripts/format_pytest_output.py index dd2b432..0f17187 100755 --- a/scripts/format_pytest_output.py +++ b/scripts/format_pytest_output.py @@ -55,9 +55,14 @@ def format_progress(progress: str) -> str: return f"[{prog}]" -def shorten_test_path(test_path: str, max_width: int = 100) -> str: - """Shorten test path if needed while keeping it readable.""" - # If it's a parameterized test, extract just the parameter ID (which is suite::case (description)) +def shorten_test_path(test_path: str, max_width: int = 0) -> str: + """Extract the human-readable parameter ID from a pytest test path. + + The parameter ID is `:: ()`, generated by + the JSON-driven runner. We never truncate it: the description carries + the assertion intent, and truncation hides exactly which test failed. + """ + # If it's a parameterized test, extract just the parameter ID match = re.search(r'\[(.*)\]', test_path) if match: clean_path = match.group(1) @@ -68,10 +73,10 @@ def shorten_test_path(test_path: str, max_width: int = 100) -> str: clean_path = clean_path.replace('redfish-api/', '') clean_path = clean_path.replace('tests/', '') - if len(clean_path) <= max_width: + # max_width <= 0 disables truncation (default). Callers can opt in if needed. + if max_width <= 0 or len(clean_path) <= max_width: return clean_path - # Last resort: truncate with ellipsis return clean_path[:max_width-3] + '...' @@ -130,10 +135,13 @@ def main(): # Format and print status_str = format_status(status) progress_str = format_progress(progress) - path_str = shorten_test_path(test_path, max_width=110) + path_str = shorten_test_path(test_path) - # Colorize only the description part in parentheses (Cyan) - match = re.search(r'(.*?)\s*(\([^)]+\))$', path_str) + # Colorize only the description part in parentheses (Cyan). + # The description is the outermost (...) at end of line and may itself + # contain nested parens, so match greedily from the first '(' after + # the test name through end of string. + match = re.search(r'^(.+?)\s+(\(.*\))$', path_str) if match: test_name = match.group(1) desc = match.group(2) diff --git a/sonic-dbus-bridge/dbus/com.sonic.RackManager.conf b/sonic-dbus-bridge/dbus/com.sonic.RackManager.conf new file mode 100644 index 0000000..746b0ac --- /dev/null +++ b/sonic-dbus-bridge/dbus/com.sonic.RackManager.conf @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + diff --git a/sonic-dbus-bridge/debian/sonic-dbus-bridge.install b/sonic-dbus-bridge/debian/sonic-dbus-bridge.install index 636d21a..bb79d7b 100644 --- a/sonic-dbus-bridge/debian/sonic-dbus-bridge.install +++ b/sonic-dbus-bridge/debian/sonic-dbus-bridge.install @@ -1,6 +1,16 @@ +####################################### +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026 Nexthop AI +# Copyright (C) 2026 SONiC Project +# Author: Nexthop AI +# Author: SONiC Project +# License file: sonic-redfish/LICENSE +####################################### + usr/bin/sonic-dbus-bridge etc/sonic-dbus-bridge/config.yaml etc/dbus-1/system.d/xyz.openbmc_project.ObjectMapper.conf etc/dbus-1/system.d/xyz.openbmc_project.User.Manager.conf etc/dbus-1/system.d/xyz.openbmc_project.Inventory.Manager.conf +etc/dbus-1/system.d/com.sonic.RackManager.conf var/lib/sonic-dbus-bridge diff --git a/sonic-dbus-bridge/include/bridge_app.hpp b/sonic-dbus-bridge/include/bridge_app.hpp index 3b213dc..9b589da 100644 --- a/sonic-dbus-bridge/include/bridge_app.hpp +++ b/sonic-dbus-bridge/include/bridge_app.hpp @@ -1,7 +1,7 @@ /////////////////////////////////////// // SPDX-License-Identifier: Apache-2.0 // Copyright (C) 2026 Nexthop AI -// Copyright (C) 2024 SONiC Project +// Copyright (C) 2026 SONiC Project // Author: Nexthop AI // Author: SONiC Project // License file: sonic-redfish/LICENSE @@ -19,6 +19,7 @@ #include "object_mapper.hpp" #include "user_mgr.hpp" #include "state_manager.hpp" +#include "rack_manager_receiver.hpp" #include "redis_state_subscriber.hpp" #include #include @@ -98,6 +99,11 @@ namespace sonic::dbus_bridge std::shared_ptr stateConn_; std::unique_ptr stateServer_; + // Dedicated connection for the rack manager receiver so it + // owns its own well-known name (com.sonic.RackManager). + std::shared_ptr rackManagerConn_; + std::unique_ptr rackManagerServer_; + // Data source adapters std::shared_ptr redisAdapter_; std::unique_ptr platformAdapter_; @@ -117,6 +123,9 @@ namespace sonic::dbus_bridge // Event-driven Redis subscriber std::unique_ptr redisSubscriber_; + // Rack manager alert / telemetry receiver + std::unique_ptr rackManagerReceiver_; + // Current inventory model InventoryModel currentModel_; diff --git a/sonic-dbus-bridge/include/field_mapping.hpp b/sonic-dbus-bridge/include/field_mapping.hpp new file mode 100644 index 0000000..f8c4d15 --- /dev/null +++ b/sonic-dbus-bridge/include/field_mapping.hpp @@ -0,0 +1,196 @@ +/////////////////////////////////////// +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Nexthop AI +// Copyright (C) 2026 SONiC Project +// Author: Nexthop AI +// Author: SONiC Project +// License file: sonic-redfish/LICENSE +/////////////////////////////////////// + +#pragma once + +// +// Declarative field-mapping tables for Rack Manager alerts and telemetry. +// +// Each entry maps a dot-separated JSON path to a Redis hash key+field. +// When the input JSON schema changes, update ONLY these tables -- no other +// code needs to change. +// +// Example (telemetry): +// JSON path "Alarms.InletTempDeviation.InletTemperature" +// is stored as +// HSET RSCM_TELEMETRY|alarms inlet_temp_deviation_temperature +// +// Example (alert, flat): +// JSON path "Redfish.LiquidPressureDeviation.LiquidPressure" +// is stored as +// HSET RSCM_ALERT|LiquidPressureDeviation liquid_pressure +// + +#include +#include + +namespace sonic::dbus_bridge +{ + +enum class FieldType +{ + String, + Number, + Integer, + Boolean +}; + +struct FieldMapping +{ + std::string jsonPath; // Dot-separated path in the input JSON + std::string redisKey; // Redis hash key + std::string redisField; // Redis hash field name + FieldType type; // Expected value type (for serialisation) +}; + +// ----------------------------------------------------------------------- +// Telemetry mappings (input: SubmitTelemetry JSON) +// +// Body envelope: {"Alarms": { ... }} +// +// To add a new field: +// 1. Add a line here with the JSON path, target Redis key/field, and type. +// 2. That's it -- the receiver walks this table automatically. +// +// To remove a field: +// 1. Delete the row. The bridge will silently ignore the data if the +// firmware still emits it (resolveJsonPath returns null for unknown +// paths). Stored history is not deleted by this change. +// ----------------------------------------------------------------------- +inline const std::vector& getTelemetryMappings() +{ + static const std::vector mappings = { + // --- Sensor status flags --- + {"Alarms.EnergyValveActive", "RSCM_TELEMETRY|alarms", "energy_valve_active", FieldType::Boolean}, + {"Alarms.EnergyValvePresent", "RSCM_TELEMETRY|alarms", "energy_valve_present", FieldType::Boolean}, + {"Alarms.FlowrateSensorActive", "RSCM_TELEMETRY|alarms", "flowrate_sensor_active", FieldType::Boolean}, + {"Alarms.PressureSensorActive", "RSCM_TELEMETRY|alarms", "pressure_sensor_active", FieldType::Boolean}, + {"Alarms.TemperatureSensorActive", "RSCM_TELEMETRY|alarms", "temperature_sensor_active", FieldType::Boolean}, + + // --- Inlet temperature deviation --- + {"Alarms.InletTempDeviation.InletTemperature", "RSCM_TELEMETRY|alarms", "inlet_temp_deviation_temperature", FieldType::Number}, + {"Alarms.InletTempDeviation.Severity", "RSCM_TELEMETRY|alarms", "inlet_temp_deviation_severity", FieldType::String}, + + // --- Flow rate deviation --- + {"Alarms.FlowRateDeviation.FlowRate", "RSCM_TELEMETRY|alarms", "flow_rate_deviation_flow_rate", FieldType::Number}, + {"Alarms.FlowRateDeviation.Severity", "RSCM_TELEMETRY|alarms", "flow_rate_deviation_severity", FieldType::String}, + + // --- Liquid pressure deviation --- + {"Alarms.LiquidPressureDeviation.LiquidPressure", "RSCM_TELEMETRY|alarms", "liquid_pressure_deviation_pressure", FieldType::Number}, + {"Alarms.LiquidPressureDeviation.Severity", "RSCM_TELEMETRY|alarms", "liquid_pressure_deviation_severity", FieldType::String}, + + // --- Leak detection --- + {"Alarms.LeakDetected.LeakDetected", "RSCM_TELEMETRY|alarms", "leak_detected", FieldType::Boolean}, + {"Alarms.LeakDetected.LeakRopeBreak", "RSCM_TELEMETRY|alarms", "leak_rope_break", FieldType::Boolean}, + {"Alarms.LeakDetected.Severity", "RSCM_TELEMETRY|alarms", "leak_detected_severity", FieldType::String}, + + // --- General --- + {"Alarms.ThresholdConfigVersion", "RSCM_TELEMETRY|alarms", "threshold_config_version", FieldType::String}, + {"Alarms.GlycolConcentration", "RSCM_TELEMETRY|alarms", "glycol_concentration", FieldType::Number}, + {"Alarms.ErrorState", "RSCM_TELEMETRY|alarms", "error_state", FieldType::String}, + {"Alarms.RscmPosition", "RSCM_TELEMETRY|alarms", "rscm_position", FieldType::Integer}, + {"Alarms.ConfigFileCorrupted", "RSCM_TELEMETRY|alarms", "config_file_corrupted", FieldType::Boolean}, + }; + return mappings; +} + +// ----------------------------------------------------------------------- +// Alert mappings (input: SubmitAlert JSON) +// +// Body envelope: {"Redfish": { ... }} +// +// Two structural variants are supported: +// +// FLAT form: +// {"Redfish": { +// "Alerts": { combined deviation + RscmPosition }, +// "LiquidPressureDeviation": { ... }, +// "InletTemperatureDeviation": { ... }, +// "LeakDetected": { ... }, +// "LeakRopeBreak": { ... } +// }} +// Each child persists under RSCM_ALERT|. +// +// WRAPPED form (ShutdownAlert): +// {"Redfish": { +// "ShutdownAlert": { +// "FlowRateDeviation": {...}, +// "TempDeviation": {...}, +// "LiquidPressureDeviation":{...}, +// "LeakDetected": {...}, +// "LeakRopeBreak": {...}, +// "RscmPosition": // wrapper-level, applies to every leaf +// } +// }} +// Each leaf persists under RSCM_ALERT|ShutdownAlert|. +// The wrapper's RscmPosition lands under RSCM_ALERT|ShutdownAlert; +// readers must join the wrapper key with each leaf key to recover +// the full context of a wrapped alert. +// ----------------------------------------------------------------------- +inline const std::vector& getAlertMappings() +{ + static const std::vector mappings = { + // ------------------------------------------------------------------- + // FLAT form (Sample 1) + // ------------------------------------------------------------------- + + // --- "Alerts": combined inlet temperature + flow rate alert --- + {"Redfish.Alerts.InletTemperature", "RSCM_ALERT|Alerts", "inlet_temperature", FieldType::Number}, + {"Redfish.Alerts.FlowRate", "RSCM_ALERT|Alerts", "flow_rate", FieldType::Number}, + {"Redfish.Alerts.Severity", "RSCM_ALERT|Alerts", "severity", FieldType::String}, + {"Redfish.Alerts.RscmPosition", "RSCM_ALERT|Alerts", "rscm_position", FieldType::Integer}, + + // --- LiquidPressureDeviation --- + {"Redfish.LiquidPressureDeviation.LiquidPressure", "RSCM_ALERT|LiquidPressureDeviation", "liquid_pressure", FieldType::Number}, + {"Redfish.LiquidPressureDeviation.Severity", "RSCM_ALERT|LiquidPressureDeviation", "severity", FieldType::String}, + {"Redfish.LiquidPressureDeviation.RscmPosition", "RSCM_ALERT|LiquidPressureDeviation", "rscm_position", FieldType::Integer}, + + // --- InletTemperatureDeviation --- + {"Redfish.InletTemperatureDeviation.InletTemperature", "RSCM_ALERT|InletTemperatureDeviation", "inlet_temperature", FieldType::Number}, + {"Redfish.InletTemperatureDeviation.Severity", "RSCM_ALERT|InletTemperatureDeviation", "severity", FieldType::String}, + {"Redfish.InletTemperatureDeviation.RscmPosition", "RSCM_ALERT|InletTemperatureDeviation", "rscm_position", FieldType::Integer}, + + // --- LeakDetected (alert form -- no inner LeakDetected bool) --- + {"Redfish.LeakDetected.Severity", "RSCM_ALERT|LeakDetected", "severity", FieldType::String}, + {"Redfish.LeakDetected.RscmPosition", "RSCM_ALERT|LeakDetected", "rscm_position", FieldType::Integer}, + + // --- LeakRopeBreak --- + {"Redfish.LeakRopeBreak.Severity", "RSCM_ALERT|LeakRopeBreak", "severity", FieldType::String}, + {"Redfish.LeakRopeBreak.RscmPosition", "RSCM_ALERT|LeakRopeBreak", "rscm_position", FieldType::Integer}, + + // ------------- + // WRAPPED form + // ------------- + + // wrapper-level RscmPosition (applies to every leaf below) + {"Redfish.ShutdownAlert.RscmPosition", "RSCM_ALERT|ShutdownAlert", "rscm_position", FieldType::Integer}, + + // FlowRateDeviation (wrapped) + {"Redfish.ShutdownAlert.FlowRateDeviation.FlowRate", "RSCM_ALERT|ShutdownAlert|FlowRateDeviation", "flow_rate", FieldType::Number}, + {"Redfish.ShutdownAlert.FlowRateDeviation.Severity", "RSCM_ALERT|ShutdownAlert|FlowRateDeviation", "severity", FieldType::String}, + + // TempDeviation (wrapped) + // it "InletTemperatureDeviation" instead. Both are accepted. + {"Redfish.ShutdownAlert.TempDeviation.InletTemperature", "RSCM_ALERT|ShutdownAlert|TempDeviation", "inlet_temperature", FieldType::Number}, + {"Redfish.ShutdownAlert.TempDeviation.Severity", "RSCM_ALERT|ShutdownAlert|TempDeviation", "severity", FieldType::String}, + + // LiquidPressureDeviation (wrapped) + {"Redfish.ShutdownAlert.LiquidPressureDeviation.LiquidPressure", "RSCM_ALERT|ShutdownAlert|LiquidPressureDeviation", "liquid_pressure", FieldType::Number}, + {"Redfish.ShutdownAlert.LiquidPressureDeviation.Severity", "RSCM_ALERT|ShutdownAlert|LiquidPressureDeviation", "severity", FieldType::String}, + + // LeakDetected (wrapped) + {"Redfish.ShutdownAlert.LeakDetected.Severity", "RSCM_ALERT|ShutdownAlert|LeakDetected", "severity", FieldType::String}, + + // LeakRopeBreak (wrapped) + {"Redfish.ShutdownAlert.LeakRopeBreak.Severity", "RSCM_ALERT|ShutdownAlert|LeakRopeBreak", "severity", FieldType::String}, + }; + return mappings; +} + +} // namespace sonic::dbus_bridge diff --git a/sonic-dbus-bridge/include/rack_manager_receiver.hpp b/sonic-dbus-bridge/include/rack_manager_receiver.hpp new file mode 100644 index 0000000..ac26d2f --- /dev/null +++ b/sonic-dbus-bridge/include/rack_manager_receiver.hpp @@ -0,0 +1,144 @@ +/////////////////////////////////////// +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Nexthop AI +// Copyright (C) 2026 SONiC Project +// Author: Nexthop AI +// Author: SONiC Project +// License file: sonic-redfish/LICENSE +/////////////////////////////////////// + +#pragma once + +#include "field_mapping.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace sonic::dbus_bridge +{ + +// D-Bus object/interface for the rack manager receiver. +// Bus name is configured via meson (RACK_MANAGER_BUSNAME = com.sonic.RackManager) +// and claimed on a dedicated connection in bridge_app. The object path and +// interface follow the bus name's `/com/sonic/...` namespace; bmcweb's OEM +// route handlers reference the identical triple via sonic_oem_constants.hpp. +constexpr const char* RACK_MANAGER_OBJ_PATH = "/com/sonic/RackManager"; +constexpr const char* RACK_MANAGER_IFACE = "com.sonic.RackManager"; + +/** + * @brief Receives alert / telemetry JSON from bmcweb via D-Bus and + * persists the data to Redis using the declarative field-mapping tables. + * + * D-Bus interface: com.sonic.RackManager + * Methods: + * SubmitAlert(s json) -> b accepted + * SubmitTelemetry(s json) -> b accepted + * + * Threading model: the D-Bus method handlers parse the JSON inline and + * enqueue a job onto an internal queue; a dedicated worker thread owns + * the Redis connection and performs the (potentially blocking) HSETs + * via pipelined hiredis commands. This keeps the sdbusplus asio + * dispatch thread free even when Redis is slow or temporarily down. + * + * The "accepted" reply means the JSON was well-formed and the job was + * queued, not that it has been persisted -- alert/telemetry ingestion + * is fire-and-forget; the producer (rack manager) is expected to retry + * on its own cadence. Persistence failures are logged at ERROR. + */ +class RackManagerReceiver +{ + public: + /** + * @param server Object server to register the D-Bus interface on + * @param redisHost STATE_DB Redis host + * @param redisPort STATE_DB Redis port + * @param redisDbIndex STATE_DB index (Redis SELECT target) + */ + RackManagerReceiver(sdbusplus::asio::object_server& server, + const std::string& redisHost = "localhost", + int redisPort = 6379, int redisDbIndex = 6); + ~RackManagerReceiver(); + + // non-copyable + RackManagerReceiver(const RackManagerReceiver&) = delete; + RackManagerReceiver& operator=(const RackManagerReceiver&) = delete; + + /** + * @brief Register D-Bus methods, connect to Redis, start worker. + * @return true on success (worker started; Redis may reconnect lazily) + */ + bool initialize(); + + private: + sdbusplus::asio::object_server& server_; + std::shared_ptr iface_; + + // Redis connection parameters. The redisContext lives exclusively + // on the worker thread to avoid concurrent hiredis access. + std::string redisHost_; + int redisPort_; + int redisDbIndex_; + redisContext* redisCtx_; // worker-thread-only + + // Submission queue. Producer = D-Bus dispatch thread, + // consumer = single worker thread. + struct Job + { + // We hold raw pointers into the static mapping tables (whose + // lifetime is the program), not copies, to keep enqueue O(1). + const std::vector* mappings; + // Pre-extracted (key, field, value) triples; parsing happens on + // the dispatch thread because it is bounded and cheap, while + // I/O is the part that must not block dispatch. + std::vector> + writes; + }; + std::deque queue_; + std::mutex queueMutex_; + std::condition_variable queueCv_; + std::atomic stopping_{false}; + std::thread worker_; + + // Hard cap on outstanding jobs to bound memory if the worker is + // wedged. Newer submissions are dropped (with a WARN log) once + // exceeded -- preferable to unbounded growth under back-pressure. + static constexpr std::size_t kMaxQueueDepth = 1024; + + // Counters for operator visibility. Updated from both the dispatch + // thread (submission-side) and the worker thread (persistence-side); + // atomic to avoid a separate lock on the hot path. Summarised by + // logSummaryIfDue() at a fixed cadence -- see kSummaryEveryNJobs. + std::atomic jobsReceived_{0}; + std::atomic jobsDroppedQueueFull_{0}; + std::atomic jobsDroppedNoRedis_{0}; + std::atomic jobsPersisted_{0}; + std::atomic fieldsPersisted_{0}; + std::atomic redisCommandFailures_{0}; + static constexpr std::uint64_t kSummaryEveryNJobs = 100; + + void logSummaryIfDue(); + + bool connectRedis(); // worker-thread-only + + /** + * @brief Parse JSON + walk the mapping table, build a Job. + * @return std::nullopt on parse failure (caller returns false to D-Bus) + */ + bool buildJob(const std::string& jsonStr, + const std::vector& mappings, Job& out); + + void workerLoop(); + void drainOne(const Job& job); +}; + +} // namespace sonic::dbus_bridge diff --git a/sonic-dbus-bridge/meson.build b/sonic-dbus-bridge/meson.build index e7a38cb..b581da8 100644 --- a/sonic-dbus-bridge/meson.build +++ b/sonic-dbus-bridge/meson.build @@ -1,3 +1,12 @@ +####################################### +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026 Nexthop AI +# Copyright (C) 2026 SONiC Project +# Author: Nexthop AI +# Author: SONiC Project +# License file: sonic-redfish/LICENSE +####################################### + project( 'sonic-dbus-bridge', 'cpp', @@ -33,6 +42,11 @@ conf_data.set_quoted( 'xyz.openbmc_project.State.Host', description: 'The D-Bus busname for host state management.', ) +conf_data.set_quoted( + 'RACK_MANAGER_BUSNAME', + 'com.sonic.RackManager', + description: 'The D-Bus busname for the rack manager alert/telemetry receiver.', +) # Enable root user management by default conf_data.set('ENABLE_ROOT_USER_MGMT', true) @@ -65,6 +79,7 @@ sources = files( 'src/config_manager.cpp', 'src/object_mapper.cpp', 'src/state_manager.cpp', + 'src/rack_manager_receiver.cpp', # User management 'src/user_mgr.cpp', 'src/users.cpp', @@ -108,6 +123,7 @@ install_data( 'dbus/xyz.openbmc_project.Inventory.Manager.conf', 'dbus/xyz.openbmc_project.ObjectMapper.conf', 'dbus/xyz.openbmc_project.User.Manager.conf', + 'dbus/com.sonic.RackManager.conf', install_dir: get_option('sysconfdir') / 'dbus-1' / 'system.d', ) diff --git a/sonic-dbus-bridge/src/bridge_app.cpp b/sonic-dbus-bridge/src/bridge_app.cpp index 9aa0230..3c1f1c8 100644 --- a/sonic-dbus-bridge/src/bridge_app.cpp +++ b/sonic-dbus-bridge/src/bridge_app.cpp @@ -1,13 +1,14 @@ /////////////////////////////////////// // SPDX-License-Identifier: Apache-2.0 // Copyright (C) 2026 Nexthop AI -// Copyright (C) 2024 SONiC Project +// Copyright (C) 2026 SONiC Project // Author: Nexthop AI // Author: SONiC Project // License file: sonic-redfish/LICENSE /////////////////////////////////////// #include "bridge_app.hpp" +#include "rack_manager_receiver.hpp" #include "inventory_model.hpp" #include "logger.hpp" #include "config.h" @@ -83,6 +84,31 @@ bool BridgeApp::initialize() // Create state objects createStateObjects(); + // Initialize rack manager receiver (alert/telemetry via D-Bus -> Redis). + // Uses its own connection / object server so it owns the + // com.sonic.RackManager well-known name independently of the + // Inventory connection. + LOG_INFO("Initializing Rack Manager Receiver..."); + rackManagerReceiver_ = std::make_unique( + *rackManagerServer_, + configMgr_->getStateDbHost(), + configMgr_->getStateDbPort(), + configMgr_->getStateDbIndex()); + if (rackManagerReceiver_->initialize()) + { + LOG_INFO("Rack Manager Receiver initialized"); + if (objectMapper_) + { + objectMapper_->registerObject( + RACK_MANAGER_OBJ_PATH, + {RACK_MANAGER_IFACE}); + } + } + else + { + LOG_WARNING("Rack Manager Receiver failed to initialize"); + } + // Initialize user management (non-fatal if it fails) initializeUserManager(); @@ -203,6 +229,22 @@ bool BridgeApp::connectDbus() stateConn_->request_name(STATE_HOST_BUSNAME); stateServer_ = std::make_unique(stateConn_); + // Rack Manager Receiver connection + LOG_INFO("Requesting D-Bus name: %s", RACK_MANAGER_BUSNAME); + bus = nullptr; + r = sd_bus_open_system(&bus); + if (r < 0) + { + LOG_ERROR("Failed to open system bus for Rack Manager: %s", + strerror(-r)); + return false; + } + rackManagerConn_ = + std::make_shared(io_, bus); + rackManagerConn_->request_name(RACK_MANAGER_BUSNAME); + rackManagerServer_ = + std::make_unique(rackManagerConn_); + LOG_INFO("Connected to D-Bus successfully (5 connections)"); return true; } diff --git a/sonic-dbus-bridge/src/rack_manager_receiver.cpp b/sonic-dbus-bridge/src/rack_manager_receiver.cpp new file mode 100644 index 0000000..af48b91 --- /dev/null +++ b/sonic-dbus-bridge/src/rack_manager_receiver.cpp @@ -0,0 +1,368 @@ +/////////////////////////////////////// +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Nexthop AI +// Copyright (C) 2026 SONiC Project +// Author: Nexthop AI +// Author: SONiC Project +// License file: sonic-redfish/LICENSE +/////////////////////////////////////// + +#include "rack_manager_receiver.hpp" +#include "logger.hpp" + +#include + +#include + +namespace sonic::dbus_bridge +{ + +// --------------------------------------------------------------------------- +// JSON path resolver: "a.b.c" -> root["a"]["b"]["c"] +// --------------------------------------------------------------------------- +static Json::Value resolveJsonPath(const Json::Value& root, + const std::string& path) +{ + Json::Value current = root; + std::istringstream iss(path); + std::string token; + while (std::getline(iss, token, '.')) + { + if (!current.isObject() || !current.isMember(token)) + { + return Json::nullValue; + } + current = current[token]; + } + return current; +} + +// --------------------------------------------------------------------------- +// Serialise a Json::Value to a string suitable for Redis storage +// --------------------------------------------------------------------------- +static std::string valueToString(const Json::Value& val, FieldType type) +{ + if (val.isNull()) + { + return ""; + } + + switch (type) + { + case FieldType::String: + return val.isString() ? val.asString() : val.toStyledString(); + + case FieldType::Number: + if (val.isDouble()) + { + return std::to_string(val.asDouble()); + } + if (val.isIntegral()) + { + return std::to_string(val.asInt64()); + } + return ""; + + case FieldType::Integer: + return val.isIntegral() ? std::to_string(val.asInt64()) : ""; + + case FieldType::Boolean: + return val.isBool() ? (val.asBool() ? "true" : "false") : ""; + } + return ""; +} + +// --------------------------------------------------------------------------- +// Construction / destruction +// --------------------------------------------------------------------------- +RackManagerReceiver::RackManagerReceiver( + sdbusplus::asio::object_server& server, + const std::string& redisHost, int redisPort, int redisDbIndex) + : server_(server), + redisHost_(redisHost), + redisPort_(redisPort), + redisDbIndex_(redisDbIndex), + redisCtx_(nullptr) +{} + +RackManagerReceiver::~RackManagerReceiver() +{ + // Tell the worker to stop and wait for it to drain. + { + std::lock_guard lk(queueMutex_); + stopping_ = true; + } + queueCv_.notify_all(); + if (worker_.joinable()) + { + worker_.join(); + } + if (redisCtx_) + { + redisFree(redisCtx_); + redisCtx_ = nullptr; + } +} + +// --------------------------------------------------------------------------- +// Initialise: register D-Bus methods + start worker thread. +// Redis is connected lazily on the worker so initialize() never blocks +// the caller if the database is down at boot. +// --------------------------------------------------------------------------- +bool RackManagerReceiver::initialize() +{ + iface_ = server_.add_interface(RACK_MANAGER_OBJ_PATH, RACK_MANAGER_IFACE); + + iface_->register_method( + "SubmitAlert", + [this](const std::string& jsonStr) -> bool { + jobsReceived_.fetch_add(1, std::memory_order_relaxed); + LOG_INFO("RackManagerReceiver: SubmitAlert received (%zu bytes)", + jsonStr.size()); + Job j{&getAlertMappings(), {}}; + if (!buildJob(jsonStr, *j.mappings, j)) + { + return false; + } + std::lock_guard lk(queueMutex_); + if (queue_.size() >= kMaxQueueDepth) + { + jobsDroppedQueueFull_.fetch_add(1, std::memory_order_relaxed); + LOG_WARNING("RackManagerReceiver: queue full (%zu); " + "dropping SubmitAlert", queue_.size()); + return false; + } + queue_.emplace_back(std::move(j)); + queueCv_.notify_one(); + return true; + }); + + iface_->register_method( + "SubmitTelemetry", + [this](const std::string& jsonStr) -> bool { + jobsReceived_.fetch_add(1, std::memory_order_relaxed); + LOG_INFO("RackManagerReceiver: SubmitTelemetry received (%zu bytes)", + jsonStr.size()); + Job j{&getTelemetryMappings(), {}}; + if (!buildJob(jsonStr, *j.mappings, j)) + { + return false; + } + std::lock_guard lk(queueMutex_); + if (queue_.size() >= kMaxQueueDepth) + { + jobsDroppedQueueFull_.fetch_add(1, std::memory_order_relaxed); + LOG_WARNING("RackManagerReceiver: queue full (%zu); " + "dropping SubmitTelemetry", queue_.size()); + return false; + } + queue_.emplace_back(std::move(j)); + queueCv_.notify_one(); + return true; + }); + + iface_->initialize(); + + worker_ = std::thread(&RackManagerReceiver::workerLoop, this); + + LOG_INFO("RackManagerReceiver: D-Bus interface registered at %s", + RACK_MANAGER_OBJ_PATH); + return true; +} + +// --------------------------------------------------------------------------- +// Redis connection (STATE_DB index from config; worker-thread only) +// --------------------------------------------------------------------------- +bool RackManagerReceiver::connectRedis() +{ + struct timeval timeout = {2, 0}; + + redisCtx_ = redisConnectWithTimeout(redisHost_.c_str(), redisPort_, + timeout); + if (redisCtx_ && redisCtx_->err) + { + LOG_WARNING("RackManagerReceiver: TCP connect failed: %s", + redisCtx_->errstr); + redisFree(redisCtx_); + redisCtx_ = nullptr; + } + + if (!redisCtx_) + { + const char* sockets[] = { + "/var/run/redis/redis.sock", + "/run/redis/redis.sock", + "/var/run/redis.sock", + nullptr}; + for (int i = 0; sockets[i] && !redisCtx_; ++i) + { + redisCtx_ = redisConnectUnixWithTimeout(sockets[i], timeout); + if (redisCtx_ && redisCtx_->err) + { + redisFree(redisCtx_); + redisCtx_ = nullptr; + } + } + } + + if (!redisCtx_) + { + LOG_ERROR("RackManagerReceiver: All Redis connection attempts failed"); + return false; + } + + redisReply* reply = static_cast( + redisCommand(redisCtx_, "SELECT %d", redisDbIndex_)); + if (!reply || reply->type == REDIS_REPLY_ERROR) + { + LOG_ERROR("RackManagerReceiver: Failed to SELECT STATE_DB (%d)", + redisDbIndex_); + if (reply) { freeReplyObject(reply); } + redisFree(redisCtx_); + redisCtx_ = nullptr; + return false; + } + freeReplyObject(reply); + + LOG_INFO("RackManagerReceiver: Connected to Redis STATE_DB (idx=%d)", + redisDbIndex_); + return true; +} + +// --------------------------------------------------------------------------- +// Parse JSON + walk mapping table on the dispatch thread. Cheap; the +// I/O is what we keep off this thread. +// --------------------------------------------------------------------------- +bool RackManagerReceiver::buildJob(const std::string& jsonStr, + const std::vector& mappings, + Job& out) +{ + Json::Value root; + Json::CharReaderBuilder builder; + std::istringstream iss(jsonStr); + std::string errors; + if (!Json::parseFromStream(builder, iss, &root, &errors)) + { + LOG_ERROR("RackManagerReceiver: JSON parse error: %s", + errors.c_str()); + return false; + } + + out.writes.reserve(mappings.size()); + for (const auto& m : mappings) + { + Json::Value val = resolveJsonPath(root, m.jsonPath); + if (val.isNull()) { continue; } + std::string strVal = valueToString(val, m.type); + if (strVal.empty()) { continue; } + out.writes.emplace_back(m.redisKey, m.redisField, std::move(strVal)); + } + return true; +} + +// --------------------------------------------------------------------------- +// Worker thread: pop jobs, pipeline HSETs to Redis. +// --------------------------------------------------------------------------- +void RackManagerReceiver::workerLoop() +{ + while (true) + { + Job job; + { + std::unique_lock lk(queueMutex_); + queueCv_.wait(lk, [this] { + return stopping_ || !queue_.empty(); + }); + if (stopping_ && queue_.empty()) { return; } + job = std::move(queue_.front()); + queue_.pop_front(); + } + drainOne(job); + } +} + +void RackManagerReceiver::drainOne(const Job& job) +{ + if (!redisCtx_ && !connectRedis()) + { + jobsDroppedNoRedis_.fetch_add(1, std::memory_order_relaxed); + LOG_WARNING("RackManagerReceiver: dropping %zu writes (no Redis)", + job.writes.size()); + return; + } + + // Pipeline: queue all commands, then read all replies. + for (const auto& [key, field, value] : job.writes) + { + if (redisAppendCommand(redisCtx_, "HSET %s %s %s", + key.c_str(), field.c_str(), + value.c_str()) != REDIS_OK) + { + redisCommandFailures_.fetch_add(1, std::memory_order_relaxed); + LOG_ERROR("RackManagerReceiver: redisAppendCommand failed: %s", + redisCtx_->errstr); + redisFree(redisCtx_); + redisCtx_ = nullptr; + return; + } + } + + int stored = 0; + for (std::size_t i = 0; i < job.writes.size(); ++i) + { + redisReply* reply = nullptr; + if (redisGetReply(redisCtx_, + reinterpret_cast(&reply)) != REDIS_OK) + { + redisCommandFailures_.fetch_add(1, std::memory_order_relaxed); + LOG_ERROR("RackManagerReceiver: redisGetReply failed: %s", + redisCtx_->errstr); + redisFree(redisCtx_); + redisCtx_ = nullptr; + return; + } + if (reply && reply->type != REDIS_REPLY_ERROR) { ++stored; } + if (reply) { freeReplyObject(reply); } + } + + jobsPersisted_.fetch_add(1, std::memory_order_relaxed); + fieldsPersisted_.fetch_add(static_cast(stored), + std::memory_order_relaxed); + + LOG_INFO("RackManagerReceiver: persisted %d/%zu fields", + stored, job.writes.size()); + + logSummaryIfDue(); +} + +// --------------------------------------------------------------------------- +// Counter summary: emit a single INFO line every kSummaryEveryNJobs jobs so +// operators get a steady heartbeat without having to grep every "persisted" +// line. Cheaper than exposing each counter via D-Bus and good enough for +// the bridge's current low-rate workload. +// --------------------------------------------------------------------------- +void RackManagerReceiver::logSummaryIfDue() +{ + const std::uint64_t persisted = + jobsPersisted_.load(std::memory_order_relaxed); + if (persisted == 0 || (persisted % kSummaryEveryNJobs) != 0) + { + return; + } + LOG_INFO("RackManagerReceiver: summary " + "received=%lu persisted=%lu fields=%lu " + "dropped_queue_full=%lu dropped_no_redis=%lu redis_failures=%lu", + static_cast( + jobsReceived_.load(std::memory_order_relaxed)), + static_cast(persisted), + static_cast( + fieldsPersisted_.load(std::memory_order_relaxed)), + static_cast( + jobsDroppedQueueFull_.load(std::memory_order_relaxed)), + static_cast( + jobsDroppedNoRedis_.load(std::memory_order_relaxed)), + static_cast( + redisCommandFailures_.load(std::memory_order_relaxed))); +} + +} // namespace sonic::dbus_bridge diff --git a/tests/README.md b/tests/README.md index ec4fe02..8858f44 100644 --- a/tests/README.md +++ b/tests/README.md @@ -9,6 +9,8 @@ License file: sonic-redfish/LICENSE # Tests +> <- Back to [sonic-redfish root README](../README.md) + Two independent test suites live under this directory: | Suite | Type | Runner | @@ -17,20 +19,42 @@ Two independent test suites live under this directory: | [`unit-tests/`](unit-tests/) | C++ gtest unit tests | `make unit-test` | The integration suite spins up the **whole** Redfish stack -(dbus-daemon → redis → sonic-dbus-bridge → bmcweb) inside a Docker +(dbus-daemon -> redis -> sonic-dbus-bridge -> bmcweb) inside a Docker container and hits the live HTTPS API. The unit suite is for -pure-logic C++ classes in `sonic-dbus-bridge/` — no Redis, no D-Bus, +pure-logic C++ classes in `sonic-dbus-bridge/` - no Redis, no D-Bus, no network. If a test needs Redis or D-Bus, it belongs in the integration suite. If it doesn't, it belongs in the unit suite. There is no middle tier on purpose. +## Table of contents + +1. [Integration tests (`redfish-api/`)](#1-integration-tests-redfish-api) + 1. [Running](#11-running) + 2. [Running a single test](#12-running-a-single-test) + 3. [Connection details](#13-connection-details) + 4. [Fixtures (all session-scoped)](#14-fixtures-all-session-scoped) + 5. [Test data - single source of truth](#15-test-data---single-source-of-truth) + 6. [Self-contained tests (Option B)](#16-self-contained-tests-option-b) + 7. [JSON case schema](#17-json-case-schema) + 8. [What's currently covered](#18-whats-currently-covered) + 9. [Adding a new integration test](#19-adding-a-new-integration-test) + 10. [Debugging](#110-debugging) +2. [Unit tests (`unit-tests/`)](#2-unit-tests-unit-tests) + 1. [Running](#21-running) + 2. [Running a single test ad-hoc](#22-running-a-single-test-ad-hoc) + 3. [File layout & convention](#23-file-layout--convention) + 4. [Adding a new unit test](#24-adding-a-new-unit-test) + 5. [Debugging](#25-debugging) + 6. [Current coverage](#26-current-coverage) +3. [References](#3-references) + --- -## Integration tests (`redfish-api/`) +## 1. Integration tests (`redfish-api/`) -### Running +### 1.1. Running ```bash make build # build the .deb packages first @@ -43,7 +67,7 @@ through [scripts/format_pytest_output.py](../scripts/format_pytest_output.py) for an aligned `[PASS]/[FAIL]/[SKIP]` table; the container exits non-zero if anything failed, so CI catches regressions automatically. -### Running a single test +### 1.2. Running a single test ```bash docker run --rm --cap-add SYS_ADMIN --tmpfs /run/dbus sonic-redfish-test:latest bash -c \ @@ -51,9 +75,11 @@ docker run --rm --cap-add SYS_ADMIN --tmpfs /run/dbus sonic-redfish-test:latest python3 -m pytest tests/redfish-api/ -k \"chassis\" -v" ``` -Replace `"chassis"` with whatever case file or specific test name you want. You can target an entire suite (`-k "service_root"`) or a single test case (`-k "chassis::test_chassis_type"`). +Replace `"chassis"` with whatever case file or specific test name you +want. You can target an entire suite (`-k "service_root"`) or a single +test case (`-k "chassis::test_chassis_type"`). -### Connection details +### 1.3. Connection details | Setting | Value | |-----------------|----------------------------------------| @@ -65,18 +91,20 @@ Replace `"chassis"` with whatever case file or specific test name you want. You Defined in [redfish-api/framework/conftest.py](redfish-api/framework/conftest.py). -### Fixtures (all session-scoped) +### 1.4. Fixtures (all session-scoped) | Fixture | What it gives you | |-------------|------------------------------------------------------------| -| `redfish` | `RedfishClient` — `requests.Session` with base URL + auth | +| `redfish` | `RedfishClient` - `requests.Session` with base URL + auth | | `state_db` | `redis.StrictRedis` connected to STATE_DB (db 6) | | `config_db` | `redis.StrictRedis` connected to CONFIG_DB (db 4) | -### Test data — single source of truth +### 1.5. Test data - single source of truth -Seed values live in [redfish-api/data/redis_seed.py](redfish-api/data/redis_seed.py) -as module-level constants. The framework resolves these dynamically in the JSON tests using templating: +Seed values live in +[redfish-api/data/redis_seed.py](redfish-api/data/redis_seed.py) as +module-level constants. The framework resolves these dynamically in +JSON tests using templating: ```json { @@ -89,11 +117,16 @@ as module-level constants. The framework resolves these dynamically in the JSON } ``` -Never hardcode expected values inside JSON files — the seeder and the expectation will drift. Use `{{SEED..}}` to link assertions directly to the seeded Redis data. +> **Note:** Never hardcode expected values inside JSON files - the +> seeder and the expectation will drift. Use `{{SEED..}}` to +> link assertions directly to the seeded Redis data. -### Self-Contained Tests (Option B) +### 1.6. Self-contained tests (Option B) -Tests are executed sequentially, but they do not rely on a globally shared state across files. Instead, tests use `prerequisite_calls` to fetch whatever local state they need (e.g. finding a valid Chassis URI) right before executing the main test: +Tests are executed sequentially, but they do not rely on a globally +shared state across files. Instead, tests use `prerequisite_calls` to +fetch whatever local state they need (e.g. finding a valid Chassis URI) +right before executing the main test: ```json "prerequisite_calls": [ @@ -104,34 +137,96 @@ Tests are executed sequentially, but they do not rely on a globally shared state ] ``` -### What's currently covered +### 1.7. JSON case schema + +Each entry in a `cases/*.json` file is an object with the keys below. +Validation lives in [framework/validator.py](redfish-api/framework/validator.py) +and runs at pytest collection time, so a bad case fails fast. + +**Required** + +| Key | Type | Notes | +|-------------------|--------|--------------------------------------------------------------------------------| +| `name` | string | Unique within the file. | +| `description` | string | Shown in the pytest summary as the cyan suffix on the test ID. | +| `method` | string | `GET` / `POST` / `PATCH` / `PUT` / `DELETE`. | +| `endpoint` | string | Path; supports `{{STATE.X}}` and `{{SEED.DEVICE_METADATA.X}}` templating. | +| `expected_status` | int | HTTP status to assert on the response. | + +**Optional - request shaping** + +| Key | Type | Notes | +|-----------------------|------------------|---------------------------------------------------------------------------------------------| +| `body` | object / array | Request body for `POST`/`PATCH`/`PUT`. Templating applied recursively. | +| `auth` | bool | Defaults to `true`. Set `false` to send the request without basic-auth (401-path testing). | +| `prerequisite_calls` | array | Pre-flight GETs whose responses populate `{{STATE.X}}` via per-call `extract` maps. | + +**Optional - response assertions** + +| Key | Type | Notes | +|-----------------------|------------------|---------------------------------------------------------------------------------------------| +| `expected_response` | object | Subset-match against the JSON body. **Forbidden when `expected_status` is 204.** | +| `validators` | array | Path-based validators: `exists`, `not_exists`, `length_gte`, `not_equals`, `equals_state`. | -| File | Scope | -|--------------------------------------------------------------------------|-------------------------------------------------------------------------| -| [cases/service_root.json](redfish-api/cases/service_root.json) | `/redfish/v1/`, `Product=SONiCBMC`, auth enforcement | -| [cases/chassis.json](redfish-api/cases/chassis.json) | inventory fields surfaced from CONFIG_DB `DEVICE_METADATA` | +**Optional - Redis pre/post hooks** -Skipped modules use `"skip": true` or similar logic in the framework if needed. +| Key | Type | Notes | +|----------------------|--------|------------------------------------------------------------------------------------------------------------------------| +| `redis_setup` | array | Pre-conditions applied before the request. Currently `{"db", "action": "delete", "key" | "keys"}` is supported. | +| `redis_validations` | array | Post-request DB checks: `{"db", "key", "expected_fields", "wait_for_field"?, "timeout"?, "exists"?, "not_exists"?}`. | -### Adding a new integration test +`expected_fields` values are either a bare scalar (string-compared) or +a typed expectation +`{"type": "string"|"int"|"float"|"bool", "equals": ...}` or +`{"exists": true|false}`. The `wait_for_field` knob polls one field +(default 5 s, 50 ms interval) before reading the rest - useful when the +bridge drains the write asynchronously. -The framework is JSON-driven, meaning you don't write Python code to add tests. +### 1.8. What's currently covered -1. Pick a Redfish resource you want to cover. -2. If the test needs new fixture data, add it to +| File | Scope | +|-----------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------| +| [cases/service_root.json](redfish-api/cases/service_root.json) | `/redfish/v1/`, `Product=SONiCBMC`, auth enforcement | +| [cases/chassis.json](redfish-api/cases/chassis.json) | inventory fields surfaced from CONFIG_DB `DEVICE_METADATA` | +| [cases/oem_manager.json](redfish-api/cases/oem_manager.json) | OEM RackManager: action discovery, `SubmitAlert` / `SubmitTelemetry` end-to-end into STATE_DB, negative paths (400/401/405) with Redfish error-body assertions | + +> **Note:** Skipped modules use `"skip": true` or similar logic in the +> framework if needed. + +### 1.9. Adding a new integration test + +The framework is JSON-driven; no Python is needed for the common cases +(GET assertions, POST actions, error paths, Redis end-to-end). + +1. Pick a Redfish resource or action you want to cover. +2. If the test needs new seed data, add it to [redfish-api/data/redis_seed.py](redfish-api/data/redis_seed.py) - as a module-level constant. -3. Create a new JSON file under `redfish-api/cases/` (or append to an existing one). -4. Define your `endpoint`, `method`, `expected_status` and validation criteria (`expected_response` for subset matching, or specific `validators`). -5. If the test needs dynamic runtime data (like a URI), add `prerequisite_calls` to fetch and store it. -6. Run `make test`. No registration step, no Makefile edit — the pytest runner (`test_runner.py`) discovers `.json` files automatically. + as a module-level constant and reference it with + `{{SEED.DEVICE_METADATA.}}`. +3. Create a new JSON file under `redfish-api/cases/` (or append to an + existing one). The runner discovers `*.json` automatically - no + registration step, no Makefile edit. +4. Pick the right shape for what you're asserting: + - **GET state**: `method`, `endpoint`, `expected_status`, + `expected_response` (subset match) or `validators`. + - **POST action (happy path, 204)**: add `body` for the payload and + `redis_validations` to confirm the bridge persisted it. + `expected_response` is forbidden on 204 - the validator rejects it. + - **Error paths (4xx/5xx)**: assert `expected_response` against the + Redfish error envelope (`error.code`, `@Message.ExtendedInfo[0].MessageId`, + etc.). Use `auth: false` to exercise the 401 path. + - **Dynamic URIs**: add `prerequisite_calls` to fetch and `extract` + state, then reference it with `{{STATE.}}`. + - **Stale-key isolation**: use `redis_setup` (`action: delete`) to + clear keys before the POST so a successful drain is unambiguous. +5. Run `make test`. When asserting on D-Bus state, prefer reading back through Redfish -(end-to-end). Only drop down to the Redis fixtures (by extending Python code) when the assertion -is about state that Redfish doesn't surface (e.g. `BMC_HOST_REQUEST` -after a reset). +(end-to-end). Drop down to `redis_validations` only when the assertion +is about state that Redfish doesn't surface (e.g. the raw +`RSCM_TELEMETRY|alarms` hash after a `SubmitTelemetry` POST). -### Debugging +### 1.10. Debugging `make test NODELETE=1` keeps the test container alive after pytest exits, named `sonic-redfish-test-debug`: @@ -151,28 +246,29 @@ redis-cli -n 6 keys '*' make clean-debug ``` -Common failure modes: +**Common failure modes** -| Symptom | First place to look | -|------------------------------------------|------------------------------------------------------------------| -| `make test` exits with 401 everywhere | bmcweb PAM user `bmcweb` not created — check `Dockerfile.test` | -| Bridge fails to claim D-Bus name | `sonic-dbus-bridge/dbus/*.conf` not installed under `/etc/dbus-1/system.d/` | -| bmcweb not listening on 443 | `/var/log/redfish-test/bmcweb.log` — TLS cert / port binding | -| Tests fail with stale data | `state_db.flushdb()` between tests, or restart the container | -| `start_services.sh` hangs on bridge | tail `/var/log/redfish-test/bridge.log` — usually a missing | -| | platform.json key or D-Bus policy denial | +| Symptom | First place to look | +|------------------------------------------|------------------------------------------------------------------------------| +| `make test` exits with 401 everywhere | bmcweb PAM user `bmcweb` not created - check `Dockerfile.test` | +| Bridge fails to claim D-Bus name | `sonic-dbus-bridge/dbus/*.conf` not installed under `/etc/dbus-1/system.d/` | +| bmcweb not listening on 443 | `/var/log/redfish-test/bmcweb.log` - TLS cert / port binding | +| Tests fail with stale data | `state_db.flushdb()` between tests, or restart the container | +| `start_services.sh` hangs on bridge | `/var/log/redfish-test/bridge.log` - usually a missing platform.json key or D-Bus policy denial | + +[^ Back to Table of Contents](#table-of-contents) --- -## Unit tests (`unit-tests/`) +## 2. Unit tests (`unit-tests/`) Small, dependency-free tests for pure-logic classes in `sonic-dbus-bridge/`. Covers translation, fallback-precedence, and -comparison helpers — anything that does not touch Redis, D-Bus, the +comparison helpers - anything that does not touch Redis, D-Bus, the filesystem, or the network. I/O paths are exercised by the integration suite, not here. -### Running +### 2.1. Running ```bash make unit-test @@ -183,7 +279,7 @@ That spins up the existing `sonic-redfish-builder:latest` container and runs it. If the builder image predates `libgtest-dev`, the target installs it on demand once per run. -### Running a single test ad-hoc +### 2.2. Running a single test ad-hoc ```bash docker run --rm -v "$PWD:/workspace" -w /workspace \ @@ -202,24 +298,24 @@ docker run --rm -v "$PWD:/workspace" -w /workspace \ `--gtest_filter=Suite.*` runs a single test suite; `--gtest_filter=Suite.Case` runs a single case. -### File layout & convention +### 2.3. File layout & convention -``` +```text tests/unit-tests/ └── _test.cpp # one test file per bridge source ``` `tests/unit-tests/foo_test.cpp` is built with `sonic-dbus-bridge/src/foo.cpp` (nothing else). The Makefile rule is a -one-liner that loops over `*_test.cpp` — no per-test configuration. +one-liner that loops over `*_test.cpp` - no per-test configuration. -If a class needs linker deps on *other* source files, that's a signal -to either (a) keep the test in the integration suite or (b) refactor -the class to break the dep. +> **Note:** If a class needs linker deps on *other* source files, that's +> a signal to either (a) keep the test in the integration suite or +> (b) refactor the class to break the dep. -### Adding a new unit test +### 2.4. Adding a new unit test -1. Pick a **pure-logic** class — no Redis client, no sdbusplus, no +1. Pick a **pure-logic** class - no Redis client, no sdbusplus, no file I/O. If it needs those, don't unit-test it here. 2. Create `tests/unit-tests/_test.cpp` matching the filename of the source file under `sonic-dbus-bridge/src/.cpp`. @@ -242,7 +338,7 @@ the class to break the dep. No extra wiring, no Makefile edits, no framework config. -### Debugging +### 2.5. Debugging The unit-test target compiles with `-g -O0` so binaries are gdb-friendly: @@ -263,19 +359,23 @@ g++ -std=c++20 -g -O0 -pthread \ gdb /tmp/t ``` -For sanitizer runs, add `-fsanitize=address,undefined`. +> **Tip:** For sanitizer runs, add `-fsanitize=address,undefined`. -### Current coverage +### 2.6. Current coverage -| Test file | Target | -|--------------------------------------------------------------------------|--------------------------------------------------------------| -| [inventory_model_test.cpp](unit-tests/inventory_model_test.cpp) | `InventoryModelBuilder::build` precedence + `hasChanged` | +| Test file | Target | +|-----------------------------------------------------------------|--------------------------------------------------------------| +| [inventory_model_test.cpp](unit-tests/inventory_model_test.cpp) | `InventoryModelBuilder::build` precedence + `hasChanged` | + +[^ Back to Table of Contents](#table-of-contents) --- -## References +## 3. References + +1. [Google Test primer](https://google.github.io/googletest/primer.html) +2. [gtest assertion reference](https://google.github.io/googletest/reference/assertions.html) +3. [pytest fixtures](https://docs.pytest.org/en/stable/how-to/fixtures.html) +4. [Redfish specification](https://www.dmtf.org/standards/redfish) -- [Google Test primer](https://google.github.io/googletest/primer.html) -- [gtest assertion reference](https://google.github.io/googletest/reference/assertions.html) -- [pytest fixtures](https://docs.pytest.org/en/stable/how-to/fixtures.html) -- [Redfish specification](https://www.dmtf.org/standards/redfish) +[^ Back to Table of Contents](#table-of-contents) diff --git a/tests/redfish-api/cases/oem_manager.json b/tests/redfish-api/cases/oem_manager.json new file mode 100644 index 0000000..d1fc561 --- /dev/null +++ b/tests/redfish-api/cases/oem_manager.json @@ -0,0 +1,449 @@ +[ + { + "name": "test_managers_collection_returns_200", + "description": "Validates the Managers collection is reachable", + "method": "GET", + "endpoint": "/redfish/v1/Managers/", + "expected_status": 200 + }, + { + "name": "test_bmc_manager_returns_200", + "description": "Validates the BMC Manager resource is reachable", + "method": "GET", + "endpoint": "/redfish/v1/Managers/bmc", + "expected_status": 200 + }, + { + "name": "test_bmc_manager_exposes_sonic_oem_block", + "description": "Validates the BMC Manager response carries the SONiC OEM RackManager sub-resource", + "method": "GET", + "endpoint": "/redfish/v1/Managers/bmc", + "expected_status": 200, + "validators": [ + { + "type": "exists", + "path": "Oem.SONiC.RackManager" + } + ] + }, + { + "name": "test_sonic_oem_identity_fields", + "description": "Validates the SONiC OEM RackManager identity payload", + "method": "GET", + "endpoint": "/redfish/v1/Managers/bmc", + "expected_status": 200, + "expected_response": { + "Oem": { + "SONiC": { + "RackManager": { + "@odata.type": "#SonicManager.v1_0_0.RackManager", + "@odata.id": "/redfish/v1/Managers/bmc/Oem/SONiC/RackManager", + "Id": "RackManager", + "Name": "SONiC Rack Manager Interface" + } + } + } + } + }, + { + "name": "test_sonic_oem_advertises_submit_alert_action", + "description": "Validates the SubmitAlert action target is advertised under the SONiC OEM block", + "method": "GET", + "endpoint": "/redfish/v1/Managers/bmc", + "expected_status": 200, + "expected_response": { + "Oem": { + "SONiC": { + "RackManager": { + "Actions": { + "#SONiC.SubmitAlert": { + "target": "/redfish/v1/Managers/bmc/Oem/SONiC/RackManager/Actions/SONiC.SubmitAlert" + } + } + } + } + } + } + }, + { + "name": "test_sonic_oem_advertises_submit_telemetry_action", + "description": "Validates the SubmitTelemetry action target is advertised under the SONiC OEM block", + "method": "GET", + "endpoint": "/redfish/v1/Managers/bmc", + "expected_status": 200, + "expected_response": { + "Oem": { + "SONiC": { + "RackManager": { + "Actions": { + "#SONiC.SubmitTelemetry": { + "target": "/redfish/v1/Managers/bmc/Oem/SONiC/RackManager/Actions/SONiC.SubmitTelemetry" + } + } + } + } + } + } + }, + { + "name": "test_submit_alert_unauthenticated_returns_401", + "description": "Validates SubmitAlert rejects unauthenticated POSTs", + "method": "POST", + "endpoint": "/redfish/v1/Managers/bmc/Oem/SONiC/RackManager/Actions/SONiC.SubmitAlert", + "auth": false, + "expected_status": 401 + }, + { + "name": "test_submit_telemetry_unauthenticated_returns_401", + "description": "Validates SubmitTelemetry rejects unauthenticated POSTs", + "method": "POST", + "endpoint": "/redfish/v1/Managers/bmc/Oem/SONiC/RackManager/Actions/SONiC.SubmitTelemetry", + "auth": false, + "expected_status": 401 + }, + { + "name": "test_submit_alert_empty_body_returns_400", + "description": "Validates SubmitAlert rejects POSTs with an empty body as malformed JSON", + "method": "POST", + "endpoint": "/redfish/v1/Managers/bmc/Oem/SONiC/RackManager/Actions/SONiC.SubmitAlert", + "expected_status": 400, + "expected_response": { + "error": { + "code": "Base.1.19.MalformedJSON" + } + } + }, + { + "name": "test_submit_telemetry_empty_body_returns_400", + "description": "Validates SubmitTelemetry rejects POSTs with an empty body as malformed JSON", + "method": "POST", + "endpoint": "/redfish/v1/Managers/bmc/Oem/SONiC/RackManager/Actions/SONiC.SubmitTelemetry", + "expected_status": 400, + "expected_response": { + "error": { + "code": "Base.1.19.MalformedJSON" + } + } + }, + { + "name": "test_submit_alert_missing_top_level_key_returns_400", + "description": "SubmitAlert: well-formed JSON missing the required 'Redfish' key triggers PropertyMissing", + "method": "POST", + "endpoint": "/redfish/v1/Managers/bmc/Oem/SONiC/RackManager/Actions/SONiC.SubmitAlert", + "body": { "NotRedfish": {} }, + "expected_status": 400, + "expected_response": { + "Redfish@Message.ExtendedInfo": [ + { + "MessageId": "Base.1.19.PropertyMissing", + "MessageArgs": ["Redfish"] + } + ] + } + }, + { + "name": "test_submit_telemetry_missing_top_level_key_returns_400", + "description": "SubmitTelemetry: well-formed JSON missing the required 'Alarms' key triggers PropertyMissing", + "method": "POST", + "endpoint": "/redfish/v1/Managers/bmc/Oem/SONiC/RackManager/Actions/SONiC.SubmitTelemetry", + "body": { "NotAlarms": {} }, + "expected_status": 400, + "expected_response": { + "Alarms@Message.ExtendedInfo": [ + { + "MessageId": "Base.1.19.PropertyMissing", + "MessageArgs": ["Alarms"] + } + ] + } + }, + { + "name": "test_submit_alert_get_method_not_allowed", + "description": "Validates the SubmitAlert action target is POST-only", + "method": "GET", + "endpoint": "/redfish/v1/Managers/bmc/Oem/SONiC/RackManager/Actions/SONiC.SubmitAlert", + "expected_status": 405 + }, + { + "name": "test_submit_telemetry_get_method_not_allowed", + "description": "Validates the SubmitTelemetry action target is POST-only", + "method": "GET", + "endpoint": "/redfish/v1/Managers/bmc/Oem/SONiC/RackManager/Actions/SONiC.SubmitTelemetry", + "expected_status": 405 + }, + { + "name": "test_json_schemas_collection_lists_sonic_manager", + "description": "Validates the JsonSchemas collection exposes the SonicManager OEM schema entry", + "method": "GET", + "endpoint": "/redfish/v1/JsonSchemas", + "expected_status": 200, + "validators": [ + { + "type": "exists", + "path": "Members" + } + ] + }, + { + "name": "test_sonic_manager_schema_resource_fetchable", + "description": "Validates the SonicManager OEM schema file resource is served by bmcweb", + "method": "GET", + "endpoint": "/redfish/v1/JsonSchemas/SonicManager", + "expected_status": 200, + "validators": [ + { + "type": "exists", + "path": "Location" + } + ] + }, + { + "name": "test_submit_telemetry_persists_to_state_db", + "description": "Valid SubmitTelemetry POST lands in RSCM_TELEMETRY|alarms (sensor flags + scalar + InletTempDeviation sub-object)", + "method": "POST", + "endpoint": "/redfish/v1/Managers/bmc/Oem/SONiC/RackManager/Actions/SONiC.SubmitTelemetry", + "body": { + "Alarms": { + "EnergyValveActive": true, + "FlowrateSensorActive": false, + "InletTempDeviation": { + "InletTemperature": 25.3, + "Severity": "Major" + }, + "RscmPosition": 1 + } + }, + "expected_status": 204, + "redis_validations": [ + { + "db": "state_db", + "key": "RSCM_TELEMETRY|alarms", + "wait_for_field": "inlet_temp_deviation_severity", + "timeout": 5.0, + "expected_fields": { + "energy_valve_active": "true", + "flowrate_sensor_active": "false", + "rscm_position": "1", + "inlet_temp_deviation_severity": "Major", + "inlet_temp_deviation_temperature": { "type": "float", "equals": 25.3 } + } + } + ] + }, + { + "name": "test_submit_telemetry_all_deviation_kinds_persist", + "description": "All three deviation sub-objects plus the leak block round-trip in one SubmitTelemetry POST", + "method": "POST", + "endpoint": "/redfish/v1/Managers/bmc/Oem/SONiC/RackManager/Actions/SONiC.SubmitTelemetry", + "body": { + "Alarms": { + "FlowRateDeviation": { "FlowRate": 28, "Severity": "Normal" }, + "InletTempDeviation": { "InletTemperature": 17, "Severity": "Normal" }, + "LiquidPressureDeviation": { "LiquidPressure": 2, "Severity": "Critical" }, + "LeakDetected": { + "LeakDetected": false, + "LeakRopeBreak": false, + "Severity": "Normal" + } + } + }, + "expected_status": 204, + "redis_validations": [ + { + "db": "state_db", + "key": "RSCM_TELEMETRY|alarms", + "wait_for_field": "leak_detected_severity", + "expected_fields": { + "flow_rate_deviation_flow_rate": { "type": "float", "equals": 28 }, + "inlet_temp_deviation_temperature": { "type": "float", "equals": 17 }, + "liquid_pressure_deviation_pressure": { "type": "float", "equals": 2 }, + "liquid_pressure_deviation_severity": "Critical", + "leak_detected": "false", + "leak_rope_break": "false", + "leak_detected_severity": "Normal" + } + } + ] + }, + { + "name": "test_submit_telemetry_minimal_payload_persists", + "description": "Parameter variation: a minimal SubmitTelemetry payload (single flag) still drains to STATE_DB", + "method": "POST", + "endpoint": "/redfish/v1/Managers/bmc/Oem/SONiC/RackManager/Actions/SONiC.SubmitTelemetry", + "body": { + "Alarms": { + "EnergyValveActive": true, + "RscmPosition": 2 + } + }, + "expected_status": 204, + "redis_validations": [ + { + "db": "state_db", + "key": "RSCM_TELEMETRY|alarms", + "wait_for_field": "rscm_position", + "expected_fields": { + "energy_valve_active": "true", + "rscm_position": "2" + } + } + ] + }, + { + "name": "test_submit_alert_flat_form_persists", + "description": "Flat-form SubmitAlert: each alert type lands under its own RSCM_ALERT| key", + "method": "POST", + "endpoint": "/redfish/v1/Managers/bmc/Oem/SONiC/RackManager/Actions/SONiC.SubmitAlert", + "body": { + "Redfish": { + "Alerts": { + "InletTemperature": 18, + "FlowRate": 58, + "Severity": "Minor", + "RscmPosition": 1 + }, + "LiquidPressureDeviation": { + "LiquidPressure": 68, + "Severity": "Major", + "RscmPosition": 1 + }, + "InletTemperatureDeviation": { + "InletTemperature": 46, + "Severity": "Critical", + "RscmPosition": 1 + }, + "LeakDetected": { "Severity": "Critical", "RscmPosition": 1 }, + "LeakRopeBreak": { "Severity": "Critical", "RscmPosition": 1 } + } + }, + "expected_status": 204, + "redis_validations": [ + { + "db": "state_db", + "key": "RSCM_ALERT|LeakRopeBreak", + "wait_for_field": "severity", + "expected_fields": { "severity": "Critical", "rscm_position": "1" } + }, + { + "db": "state_db", + "key": "RSCM_ALERT|Alerts", + "expected_fields": { + "severity": "Minor", + "rscm_position": "1", + "inlet_temperature": { "type": "float", "equals": 18 }, + "flow_rate": { "type": "float", "equals": 58 } + } + }, + { + "db": "state_db", + "key": "RSCM_ALERT|LiquidPressureDeviation", + "expected_fields": { + "severity": "Major", + "liquid_pressure": { "type": "float", "equals": 68 } + } + }, + { + "db": "state_db", + "key": "RSCM_ALERT|InletTemperatureDeviation", + "expected_fields": { + "severity": "Critical", + "inlet_temperature": { "type": "float", "equals": 46 } + } + }, + { + "db": "state_db", + "key": "RSCM_ALERT|LeakDetected", + "expected_fields": { "rscm_position": "1" } + } + ] + }, + { + "name": "test_submit_alert_shutdown_wrapped_form_persists", + "description": "Wrapped-form SubmitAlert: leaves persist under RSCM_ALERT|ShutdownAlert|, wrapper holds the single RscmPosition", + "method": "POST", + "endpoint": "/redfish/v1/Managers/bmc/Oem/SONiC/RackManager/Actions/SONiC.SubmitAlert", + "body": { + "Redfish": { + "ShutdownAlert": { + "FlowRateDeviation": { "FlowRate": 58, "Severity": "Minor" }, + "TempDeviation": { "InletTemperature": 17, "Severity": "Normal" }, + "LiquidPressureDeviation": { "LiquidPressure": 68, "Severity": "Major" }, + "LeakDetected": { "Severity": "Critical" }, + "LeakRopeBreak": { "Severity": "Critical" }, + "RscmPosition": 3 + } + } + }, + "expected_status": 204, + "redis_validations": [ + { + "db": "state_db", + "key": "RSCM_ALERT|ShutdownAlert", + "wait_for_field": "rscm_position", + "expected_fields": { "rscm_position": "3" } + }, + { + "db": "state_db", + "key": "RSCM_ALERT|ShutdownAlert|FlowRateDeviation", + "expected_fields": { + "severity": "Minor", + "flow_rate": { "type": "float", "equals": 58 } + } + }, + { + "db": "state_db", + "key": "RSCM_ALERT|ShutdownAlert|TempDeviation", + "expected_fields": { + "severity": "Normal", + "inlet_temperature": { "type": "float", "equals": 17 } + } + }, + { + "db": "state_db", + "key": "RSCM_ALERT|ShutdownAlert|LiquidPressureDeviation", + "expected_fields": { + "severity": "Major", + "liquid_pressure": { "type": "float", "equals": 68 } + } + }, + { + "db": "state_db", + "key": "RSCM_ALERT|ShutdownAlert|LeakDetected", + "expected_fields": { "severity": "Critical" } + }, + { + "db": "state_db", + "key": "RSCM_ALERT|ShutdownAlert|LeakRopeBreak", + "expected_fields": { "severity": "Critical" } + } + ] + }, + { + "name": "test_submit_alert_individual_leak_only", + "description": "Parameter variation: single-alert SubmitAlert (LeakDetected only) populates only the matching key", + "method": "POST", + "endpoint": "/redfish/v1/Managers/bmc/Oem/SONiC/RackManager/Actions/SONiC.SubmitAlert", + "body": { + "Redfish": { + "LeakDetected": { "Severity": "Critical", "RscmPosition": 2 } + } + }, + "expected_status": 204, + "redis_validations": [ + { + "db": "state_db", + "key": "RSCM_ALERT|LeakDetected", + "wait_for_field": "severity", + "expected_fields": { + "severity": "Critical", + "rscm_position": "2" + } + }, + { + "db": "state_db", + "key": "RSCM_ALERT|LeakRopeBreak", + "not_exists": true + } + ] + } +] diff --git a/tests/redfish-api/framework/test_runner.py b/tests/redfish-api/framework/test_runner.py index a453bbe..6238ed2 100644 --- a/tests/redfish-api/framework/test_runner.py +++ b/tests/redfish-api/framework/test_runner.py @@ -9,6 +9,7 @@ import os import json +import time import pytest import requests from pathlib import Path @@ -18,6 +19,10 @@ CASES_DIR = Path(__file__).parent.parent / "cases" +# Defaults for async-drain polling on redis_validations +DEFAULT_REDIS_TIMEOUT_SEC = 5.0 +REDIS_POLL_INTERVAL_SEC = 0.05 + import logging # Setup a dedicated file logger for test_report.log @@ -52,18 +57,139 @@ def load_cases(): cases.append(pytest.param(case, id=case_name)) return cases +def _apply_redis_setup(setup_list, redis_dbs): + """Run pre-conditions (currently: delete keys) before the request.""" + for entry in setup_list: + db_name = entry["db"] + action = entry["action"] + db = redis_dbs[db_name] + if action == "delete": + keys = list(entry.get("keys") or [entry["key"]]) + deleted = db.delete(*keys) + test_logger.info( + f"[REDIS-SETUP] {db_name} DELETE {keys} -> {deleted} key(s) removed" + ) + else: + raise ValueError(f"Unknown redis_setup action: {action}") + + +def _wait_for_field(db, key, field, timeout): + """Poll an HGET until the field is present or timeout elapses.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + v = db.hget(key, field) + if v is not None: + return v + time.sleep(REDIS_POLL_INTERVAL_SEC) + return None + + +def _field_error(actual, expected, db_name, key, field): + """Compare a single hash field against an expectation. + + `expected` is either a scalar (str-compared) or a typed object: + {"type": "float"|"int"|"bool"|"string", "equals": } + {"exists": true|false} + + Returns None on success, or a human-readable error string. + """ + if isinstance(expected, dict): + if "exists" in expected: + want = bool(expected["exists"]) + present = actual is not None + if want and not present: + return f"{db_name}|{key}.{field}: expected to exist, missing" + if not want and present: + return f"{db_name}|{key}.{field}: expected absent, got '{actual}'" + return None + etype = expected.get("type", "string") + eval_ = expected.get("equals") + if actual is None: + return f"{db_name}|{key}.{field}: missing (expected {expected})" + try: + if etype == "float" and float(actual) != float(eval_): + return f"{db_name}|{key}.{field}: float mismatch, expected {eval_}, got '{actual}'" + if etype == "int" and int(actual) != int(eval_): + return f"{db_name}|{key}.{field}: int mismatch, expected {eval_}, got '{actual}'" + except (TypeError, ValueError): + return f"{db_name}|{key}.{field}: cannot parse '{actual}' as {etype}" + if etype == "bool": + want = "true" if eval_ else "false" + if actual != want: + return f"{db_name}|{key}.{field}: bool mismatch, expected {want}, got '{actual}'" + elif etype == "string": + if str(eval_) != actual: + return f"{db_name}|{key}.{field}: mismatch, expected '{eval_}', got '{actual}'" + return None + # bare scalar -> string equality (decode_responses=True everywhere) + if actual != str(expected): + return f"{db_name}|{key}.{field}: mismatch, expected '{expected}', got '{actual}'" + return None + + +def _run_redis_validations(validations, redis_dbs): + """Validate Redis state after the request (key existence, async-drained fields).""" + for v in validations: + db_name = v["db"] + db = redis_dbs[db_name] + key = v["key"] + timeout = float(v.get("timeout", DEFAULT_REDIS_TIMEOUT_SEC)) + + if v.get("exists") is True: + assert db.exists(key), \ + f"[REDIS-VALIDATE] expected key '{db_name}|{key}' to exist, missing" + test_logger.info(f"[REDIS-VALIDATE] {db_name}|{key} exists: OK") + if v.get("not_exists") is True: + assert not db.exists(key), \ + f"[REDIS-VALIDATE] expected key '{db_name}|{key}' to NOT exist" + test_logger.info(f"[REDIS-VALIDATE] {db_name}|{key} not_exists: OK") + + # Drain anchor: poll for one specific field before reading the rest. + wait_field = v.get("wait_for_field") + if wait_field is not None: + val = _wait_for_field(db, key, wait_field, timeout) + test_logger.info( + f"[REDIS-VALIDATE] wait_for_field {db_name}|{key}.{wait_field} " + f"(timeout={timeout}s) -> {val!r}" + ) + assert val is not None, ( + f"[REDIS-VALIDATE] timed out after {timeout}s waiting for field " + f"'{wait_field}' on '{db_name}|{key}'" + ) + + expected_fields = v.get("expected_fields") or {} + if expected_fields: + actual_fields = db.hgetall(key) + test_logger.info( + f"[REDIS-VALIDATE] HGETALL {db_name}|{key} -> " + f"{json.dumps(actual_fields, indent=2, sort_keys=True)}" + ) + errors = [] + for field, expected in expected_fields.items(): + err = _field_error(actual_fields.get(field), expected, + db_name, key, field) + if err: + errors.append(err) + assert not errors, \ + "[REDIS-VALIDATE] field check(s) failed:\n " + "\n ".join(errors) + + @pytest.mark.parametrize("case", load_cases()) -def test_redfish_api(case, redfish): +def test_redfish_api(case, redfish, state_db, config_db): """Generic JSON-driven test runner.""" state = {} + redis_dbs = {"state_db": state_db, "config_db": config_db} - def _log_req(method, url, resp): + def _log_req(method, url, body, resp): test_logger.info(f"[REQUEST] {method.upper()} {url}") + if body is not None: + test_logger.info(f"[REQUEST] Body: {json.dumps(body, indent=2)}") test_logger.info(f"[RESPONSE] Status: {resp.status_code}") try: test_logger.info(f"[RESPONSE] Body: {json.dumps(resp.json(), indent=2)}") except Exception: - test_logger.info(f"[RESPONSE] Body: {resp.text}") + text = (resp.text or "").strip() + test_logger.info(f"[RESPONSE] Body: {text!r}") test_logger.info(f"\n========== STARTING TEST: {case['name']} ==========") @@ -73,7 +199,7 @@ def _log_req(method, url, resp): pre_method = prereq.get("method", "GET").lower() req_func = getattr(redfish, pre_method) pre_resp = req_func(pre_endpoint) - _log_req(pre_method, pre_endpoint, pre_resp) + _log_req(pre_method, pre_endpoint, None, pre_resp) assert pre_resp.status_code == prereq.get("expected_status", 200) if "extract" in prereq: @@ -81,8 +207,16 @@ def _log_req(method, url, resp): for state_key, json_path in prereq["extract"].items(): state[state_key] = extract_path(actual_resp, json_path) + # Redis pre-conditions (delete keys, etc.) applied AFTER the autouse + # conftest reseed and BEFORE the request. + if "redis_setup" in case: + _apply_redis_setup(case["redis_setup"], redis_dbs) + endpoint = resolve_template(case["endpoint"], DEVICE_METADATA, state) method = case.get("method", "GET").lower() + body = case.get("body") + if body is not None: + body = resolve_dict(body, DEVICE_METADATA, state) # Handle unauthenticated requests auth_enabled = case.get("auth", True) @@ -91,14 +225,16 @@ def _log_req(method, url, resp): resp = requests.request( method, full_url, + json=body, verify=False, - timeout=10 + timeout=10, ) - _log_req(method, endpoint, resp) + _log_req(method, endpoint, body, resp) else: req_func = getattr(redfish, method) - resp = req_func(endpoint) - _log_req(method, endpoint, resp) + kwargs = {"json": body} if body is not None else {} + resp = req_func(endpoint, **kwargs) + _log_req(method, endpoint, body, resp) assert resp.status_code == case.get("expected_status", 200), \ f"Expected status {case.get('expected_status')}, got {resp.status_code}. Response: {resp.text}" @@ -117,3 +253,8 @@ def _log_req(method, url, resp): actual_resp = resp.json() for state_key, json_path in case["extract"].items(): state[state_key] = extract_path(actual_resp, json_path) + + # Post-request Redis assertions (run last so request/response logs land + # in the report first, then the DB drain log). + if "redis_validations" in case: + _run_redis_validations(case["redis_validations"], redis_dbs) diff --git a/tests/redfish-api/framework/validator.py b/tests/redfish-api/framework/validator.py index 5cbdd9d..9705fe6 100644 --- a/tests/redfish-api/framework/validator.py +++ b/tests/redfish-api/framework/validator.py @@ -20,11 +20,16 @@ ALLOWED_METHODS = {"GET", "POST", "PATCH", "PUT", "DELETE"} ALLOWED_KEYS = { - "name", "description", "method", "endpoint", - "expected_status", "expected_response", "validators", - "prerequisite_calls", "auth" + "name", "description", "method", "endpoint", + "expected_status", "expected_response", "validators", + "prerequisite_calls", "auth", + # POST/PATCH/PUT request body + Redis pre/post hooks + "body", "redis_setup", "redis_validations", } REQUIRED_KEYS = {"name", "description", "method", "endpoint", "expected_status"} +ALLOWED_REDIS_DBS = {"state_db", "config_db"} +ALLOWED_REDIS_ACTIONS = {"delete"} +ALLOWED_FIELD_TYPES = {"string", "int", "float", "bool"} def validate_test_file(filepath: Path) -> list[str]: """ @@ -71,6 +76,14 @@ def validate_test_file(filepath: Path) -> list[str]: if "expected_status" in case and not isinstance(case["expected_status"], int): errors.append(f"Case '{name}': 'expected_status' must be an integer.") + # HTTP 204 No Content forbids a response body (RFC 7230 §3.3.3). + # Asserting a body against it is always a bug. + if case.get("expected_status") == 204 and "expected_response" in case: + errors.append( + f"Case '{name}': 'expected_response' is not allowed when " + f"'expected_status' is 204 (No Content has no body)." + ) + # Extract logic and prerequisite validation extracted_states = set() if "prerequisite_calls" in case: @@ -114,4 +127,60 @@ def validate_test_file(filepath: Path) -> list[str]: if missing_states: errors.append(f"Case '{name}': Uses {{STATE.*}} variables not extracted by any prerequisite: {', '.join(missing_states)}") + # body (request payload for POST/PATCH/PUT) + if "body" in case: + method = case.get("method", "GET") + if method not in {"POST", "PATCH", "PUT"}: + errors.append(f"Case '{name}': 'body' is only valid for POST/PATCH/PUT (got '{method}').") + if not isinstance(case["body"], (dict, list)): + errors.append(f"Case '{name}': 'body' must be a JSON object or array.") + + # redis_setup: list of pre-condition ops applied before the request + if "redis_setup" in case: + if not isinstance(case["redis_setup"], list): + errors.append(f"Case '{name}': 'redis_setup' must be an array.") + else: + for s_idx, entry in enumerate(case["redis_setup"]): + loc = f"Case '{name}', redis_setup[{s_idx}]" + if not isinstance(entry, dict): + errors.append(f"{loc}: Must be an object.") + continue + if entry.get("db") not in ALLOWED_REDIS_DBS: + errors.append(f"{loc}: 'db' must be one of {sorted(ALLOWED_REDIS_DBS)}.") + if entry.get("action") not in ALLOWED_REDIS_ACTIONS: + errors.append(f"{loc}: 'action' must be one of {sorted(ALLOWED_REDIS_ACTIONS)}.") + if entry.get("action") == "delete": + if "key" not in entry and "keys" not in entry: + errors.append(f"{loc}: delete requires 'key' or 'keys'.") + if "keys" in entry and not isinstance(entry["keys"], list): + errors.append(f"{loc}: 'keys' must be an array.") + + # redis_validations: list of post-request DB checks + if "redis_validations" in case: + if not isinstance(case["redis_validations"], list): + errors.append(f"Case '{name}': 'redis_validations' must be an array.") + else: + for r_idx, rv in enumerate(case["redis_validations"]): + loc = f"Case '{name}', redis_validations[{r_idx}]" + if not isinstance(rv, dict): + errors.append(f"{loc}: Must be an object.") + continue + if rv.get("db") not in ALLOWED_REDIS_DBS: + errors.append(f"{loc}: 'db' must be one of {sorted(ALLOWED_REDIS_DBS)}.") + if "key" not in rv or not isinstance(rv["key"], str): + errors.append(f"{loc}: 'key' (string) is required.") + if "expected_fields" in rv and not isinstance(rv["expected_fields"], dict): + errors.append(f"{loc}: 'expected_fields' must be an object.") + if "timeout" in rv and not isinstance(rv["timeout"], (int, float)): + errors.append(f"{loc}: 'timeout' must be a number.") + if "wait_for_field" in rv and not isinstance(rv["wait_for_field"], str): + errors.append(f"{loc}: 'wait_for_field' must be a string.") + for fname, fexp in (rv.get("expected_fields") or {}).items(): + if isinstance(fexp, dict) and "type" in fexp \ + and fexp["type"] not in ALLOWED_FIELD_TYPES: + errors.append( + f"{loc}: field '{fname}' has unsupported type " + f"'{fexp['type']}' (allowed: {sorted(ALLOWED_FIELD_TYPES)})." + ) + return errors diff --git a/tests/unit-tests/field_mapping_test.cpp b/tests/unit-tests/field_mapping_test.cpp new file mode 100644 index 0000000..d15e308 --- /dev/null +++ b/tests/unit-tests/field_mapping_test.cpp @@ -0,0 +1,152 @@ +/////////////////////////////////////// +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Nexthop AI +// Copyright (C) 2026 SONiC Project +// Author: Nexthop AI +// Author: SONiC Project +// License file: sonic-redfish/LICENSE +/////////////////////////////////////// + +#include +#include "field_mapping.hpp" + +#include +#include +#include + +namespace sonic::dbus_bridge::test { + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// All Redis keys for telemetry must live under this prefix (single hash). +constexpr const char* TELEMETRY_REDIS_KEY_PREFIX = "RSCM_TELEMETRY|"; + +// Alert keys are split per alert kind: RSCM_ALERT|. +constexpr const char* ALERT_REDIS_KEY_PREFIX = "RSCM_ALERT|"; + +static bool isValidFieldType(FieldType t) +{ + switch (t) + { + case FieldType::String: + case FieldType::Number: + case FieldType::Integer: + case FieldType::Boolean: + return true; + } + return false; +} + +// --------------------------------------------------------------------------- +// Table stability +// --------------------------------------------------------------------------- + +TEST(FieldMappingTables, ReturnSameStaticInstanceAcrossCalls) +{ + EXPECT_EQ(&getTelemetryMappings(), &getTelemetryMappings()); + EXPECT_EQ(&getAlertMappings(), &getAlertMappings()); +} + +TEST(FieldMappingTables, BothTablesAreNonEmpty) +{ + EXPECT_FALSE(getTelemetryMappings().empty()); + EXPECT_FALSE(getAlertMappings().empty()); +} + +// --------------------------------------------------------------------------- +// Entry-level invariants (apply to every row in every table) +// --------------------------------------------------------------------------- + +static void checkPathSyntax(const std::string& path, const std::string& ctx, + const std::string& which) +{ + EXPECT_EQ(path.find(' '), std::string::npos) + << ctx << ": " << which << " contains a space: '" << path << "'"; + EXPECT_NE(path.front(), '.') << ctx << ": " << which << " starts with '.'"; + EXPECT_NE(path.back(), '.') << ctx << ": " << which << " ends with '.'"; +} + +static void checkEntryInvariants(const FieldMapping& m, const std::string& ctx) +{ + EXPECT_FALSE(m.jsonPath.empty()) << ctx << ": jsonPath is empty"; + EXPECT_FALSE(m.redisKey.empty()) << ctx << ": redisKey is empty"; + EXPECT_FALSE(m.redisField.empty()) << ctx << ": redisField is empty"; + + // JSON paths use dot notation; spaces or leading/trailing dots are bugs. + checkPathSyntax(m.jsonPath, ctx, "jsonPath"); + + EXPECT_TRUE(isValidFieldType(m.type)) << ctx << ": invalid FieldType"; +} + +TEST(FieldMappingTables, TelemetryEntriesSatisfyInvariants) +{ + for (const auto& m : getTelemetryMappings()) + { + checkEntryInvariants(m, "telemetry[" + m.jsonPath + "]"); + } +} + +TEST(FieldMappingTables, AlertEntriesSatisfyInvariants) +{ + for (const auto& m : getAlertMappings()) + { + checkEntryInvariants(m, "alert[" + m.jsonPath + "]"); + } +} + +// --------------------------------------------------------------------------- +// Redis key namespacing -- telemetry vs alert tables are kept distinct +// to prevent cross-table collisions in STATE_DB. +// --------------------------------------------------------------------------- + +TEST(FieldMappingTables, TelemetryKeysSharePrefix) +{ + for (const auto& m : getTelemetryMappings()) + { + EXPECT_EQ(m.redisKey.rfind(TELEMETRY_REDIS_KEY_PREFIX, 0), 0u) + << "telemetry key '" << m.redisKey + << "' does not start with '" << TELEMETRY_REDIS_KEY_PREFIX << "'"; + } +} + +TEST(FieldMappingTables, AlertKeysSharePrefix) +{ + for (const auto& m : getAlertMappings()) + { + EXPECT_EQ(m.redisKey.rfind(ALERT_REDIS_KEY_PREFIX, 0), 0u) + << "alert key '" << m.redisKey + << "' does not start with '" << ALERT_REDIS_KEY_PREFIX << "'"; + } +} + +// --------------------------------------------------------------------------- +// Duplicate detection: two rows must never target the same (key, field) -- +// the second HSET would silently overwrite the first. +// --------------------------------------------------------------------------- + +static void checkNoDuplicateKeyField(const std::vector& table, + const std::string& tableName) +{ + std::set> seen; + for (const auto& m : table) + { + auto inserted = seen.emplace(m.redisKey, m.redisField); + EXPECT_TRUE(inserted.second) + << tableName << ": duplicate (" << m.redisKey << ", " + << m.redisField << ")"; + } +} + +TEST(FieldMappingTables, NoDuplicateRedisTargetsInTelemetry) +{ + checkNoDuplicateKeyField(getTelemetryMappings(), "telemetry"); +} + +TEST(FieldMappingTables, NoDuplicateRedisTargetsInAlerts) +{ + checkNoDuplicateKeyField(getAlertMappings(), "alerts"); +} + +} // namespace sonic::dbus_bridge::test