Skip to content

Commit 47e304c

Browse files
authored
feat: bmc-mock telemetry service support (#2887)
Added telemetry service support for BMC Mock ## Related issues <!-- Refer to existing GitHub issues here --> ## Type of Change <!-- Check one that best describes this PR --> - [ ] **Add** - New feature or capability - [ ] **Change** - Changes in existing functionality - [ ] **Fix** - Bug fixes - [ ] **Remove** - Removed features or deprecated functionality - [x] **Internal** - Internal changes (refactoring, tests, docs, etc.) ## Breaking Changes <!-- If checked, describe the breaking changes and migration steps --> <!-- Breaking changes are not generally permitted, please discuss on a GitHub discussion or with the development team if you believe you need to break a backward compatibility guarantee --> - [ ] **This PR contains breaking changes** ## Testing <!-- How was this tested? Check all that apply --> - [x] Unit tests added/updated - [ ] Integration tests added/updated - [ ] Manual testing performed - [ ] No testing required (docs, internal refactor, etc.) ## Additional Notes <!-- Any additional context, deployment notes, or reviewer guidance --> --------- Signed-off-by: ianisimov <ianisimov@nvidia.com>
1 parent f4ca40b commit 47e304c

6 files changed

Lines changed: 248 additions & 1 deletion

File tree

crates/bmc-mock/src/mock_machine_router.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ pub fn machine_router(
8282
.add_routes(crate::redfish::manager::add_routes)
8383
.add_routes(crate::redfish::update_service::add_routes)
8484
.add_routes(crate::redfish::task_service::add_routes)
85+
.add_routes(crate::redfish::telemetry_service::add_routes)
8586
.add_routes(crate::redfish::account_service::add_routes)
8687
.add_routes(crate::redfish::session_service::add_routes)
8788
.add_routes(|routes| crate::redfish::computer_system::add_routes(routes, bmc_vendor))

crates/bmc-mock/src/redfish/chassis.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,10 @@ impl ChassisState {
195195
.iter()
196196
.find(|c| c.config.id.as_ref() == chassis_id)
197197
}
198+
199+
pub fn iter(&self) -> impl Iterator<Item = &SingleChassisState> {
200+
self.chassis.iter()
201+
}
198202
}
199203

200204
pub struct SingleChassisState {

crates/bmc-mock/src/redfish/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ pub mod session_service;
4343
pub mod software_inventory;
4444
pub mod storage;
4545
pub mod task_service;
46+
pub mod telemetry_service;
4647
pub mod thermal_subsystem;
4748
pub mod update_service;
4849

crates/bmc-mock/src/redfish/service_root.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ async fn get_service_root(State(state): State<BmcState>) -> Response {
6767
.system_collection(&redfish::computer_system::collection())
6868
.manager_collection(&redfish::manager::collection())
6969
.update_service(&redfish::update_service::resource())
70+
.telemetry_service(&redfish::telemetry_service::resource())
7071
.build()
7172
.into_ok_response()
7273
}
@@ -123,4 +124,8 @@ impl ServiceRootBuilder {
123124
pub fn update_service(self, v: &redfish::Resource<'_>) -> Self {
124125
self.apply_patch(v.nav_property("UpdateService"))
125126
}
127+
128+
pub fn telemetry_service(self, v: &redfish::Resource<'_>) -> Self {
129+
self.apply_patch(v.nav_property("TelemetryService"))
130+
}
126131
}
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
/*
2+
* SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
use std::borrow::Cow;
19+
20+
use axum::Router;
21+
use axum::extract::{Path, State};
22+
use axum::response::Response;
23+
use axum::routing::get;
24+
use serde_json::json;
25+
26+
use crate::bmc_state::BmcState;
27+
use crate::json::{JsonExt, JsonPatch};
28+
use crate::{http, redfish};
29+
30+
/// Id of the single aggregated report the mock publishes.
31+
pub const REPORT_ID: &str = "PlatformEnvironmentMetrics";
32+
33+
pub fn resource() -> redfish::Resource<'static> {
34+
redfish::Resource {
35+
odata_id: Cow::Borrowed("/redfish/v1/TelemetryService"),
36+
odata_type: Cow::Borrowed("#TelemetryService.v1_3_1.TelemetryService"),
37+
id: Cow::Borrowed("TelemetryService"),
38+
name: Cow::Borrowed("Telemetry Service"),
39+
}
40+
}
41+
42+
pub fn metric_reports_collection() -> redfish::Collection<'static> {
43+
redfish::Collection {
44+
odata_id: Cow::Borrowed("/redfish/v1/TelemetryService/MetricReports"),
45+
odata_type: Cow::Borrowed("#MetricReportCollection.MetricReportCollection"),
46+
name: Cow::Borrowed("Metric Report Collection"),
47+
}
48+
}
49+
50+
pub fn metric_report_resource<'a>(report_id: &'a str) -> redfish::Resource<'a> {
51+
let odata_id = format!("{}/{report_id}", metric_reports_collection().odata_id);
52+
redfish::Resource {
53+
odata_id: Cow::Owned(odata_id),
54+
odata_type: Cow::Borrowed("#MetricReport.v1_5_0.MetricReport"),
55+
id: Cow::Borrowed(report_id),
56+
name: Cow::Borrowed("Metric Report"),
57+
}
58+
}
59+
60+
pub fn add_routes(r: Router<BmcState>) -> Router<BmcState> {
61+
const REPORT_ID_PARAM: &str = "{report_id}";
62+
r.route(&resource().odata_id, get(get_telemetry_service))
63+
.route(
64+
&metric_reports_collection().odata_id,
65+
get(get_metric_reports),
66+
)
67+
.route(
68+
&metric_report_resource(REPORT_ID_PARAM).odata_id,
69+
get(get_metric_report),
70+
)
71+
}
72+
73+
async fn get_telemetry_service() -> Response {
74+
resource()
75+
.json_patch()
76+
.patch(json!({
77+
"Status": redfish::resource::Status::Ok.into_json(),
78+
"ServiceEnabled": true,
79+
}))
80+
.patch(metric_reports_collection().nav_property("MetricReports"))
81+
.into_ok_response()
82+
}
83+
84+
async fn get_metric_reports() -> Response {
85+
let members = [metric_report_resource(REPORT_ID).entity_ref()];
86+
metric_reports_collection()
87+
.with_members(&members)
88+
.into_ok_response()
89+
}
90+
91+
async fn get_metric_report(
92+
State(state): State<BmcState>,
93+
Path(report_id): Path<String>,
94+
) -> Response {
95+
if report_id != REPORT_ID {
96+
return http::not_found();
97+
}
98+
99+
let timestamp = chrono::Utc::now().to_rfc3339();
100+
let metric_values: Vec<_> = sensor_metric_values(&state, &timestamp).collect();
101+
102+
metric_report_resource(&report_id)
103+
.json_patch()
104+
.patch(json!({
105+
"Timestamp": timestamp,
106+
"MetricValues@odata.count": metric_values.len(),
107+
"MetricValues": metric_values,
108+
}))
109+
.into_ok_response()
110+
}
111+
112+
fn sensor_metric_values<'a>(
113+
state: &'a BmcState,
114+
timestamp: &'a str,
115+
) -> impl Iterator<Item = serde_json::Value> + 'a {
116+
state
117+
.chassis_state
118+
.iter()
119+
.flat_map(|chassis| {
120+
let id = chassis.config.id.as_ref();
121+
chassis
122+
.config
123+
.sensors
124+
.iter()
125+
.flatten()
126+
.map(move |s| (id, s))
127+
})
128+
.filter_map(move |(chassis_id, sensor)| {
129+
let reading = sensor.to_json().get("Reading")?.as_f64()?;
130+
let odata_id = redfish::sensor::chassis_resource(chassis_id, &sensor.id).odata_id;
131+
Some(json!({
132+
"MetricId": sensor.id,
133+
"MetricValue": reading.to_string(),
134+
"MetricProperty": odata_id,
135+
"Timestamp": timestamp,
136+
}))
137+
})
138+
}
139+
140+
#[cfg(test)]
141+
mod tests {
142+
use std::sync::Arc;
143+
144+
use axum::Router;
145+
use nv_redfish::bmc_http::{BmcCredentials, HttpClient};
146+
use serde_json::json;
147+
use url::Url;
148+
149+
use super::REPORT_ID;
150+
use crate::test_support::axum_http_client::AxumRouterHttpClient;
151+
use crate::test_support::{NoopCallbacks, TEST_MAC_POOL};
152+
use crate::{
153+
DpuMachineInfo, DpuSettings, HostHardwareType, HostMachineInfo, MachineInfo, machine_router,
154+
};
155+
156+
fn test_host_mock() -> Router {
157+
let mut mac_pool = TEST_MAC_POOL.lock().unwrap();
158+
let hw_type = HostHardwareType::DellPowerEdgeR750;
159+
let ranges_config = mac_pool.allocate_range_config().unwrap();
160+
161+
machine_router(
162+
&MachineInfo::Host(HostMachineInfo::new(
163+
hw_type,
164+
vec![DpuMachineInfo::new(
165+
hw_type,
166+
&mut mac_pool,
167+
DpuSettings::default(),
168+
)],
169+
&mut mac_pool,
170+
ranges_config,
171+
)),
172+
Arc::new(NoopCallbacks),
173+
"test-host-id".to_string(),
174+
false,
175+
)
176+
.0
177+
}
178+
179+
async fn get(
180+
client: &AxumRouterHttpClient,
181+
path: &str,
182+
) -> Result<serde_json::Value, impl std::error::Error> {
183+
let url = Url::parse(&format!("https://bmc-mock.local{path}")).expect("valid URL");
184+
client
185+
.get(
186+
url,
187+
&BmcCredentials::new("root".to_string(), "password".to_string()),
188+
None,
189+
&axum::http::HeaderMap::new(),
190+
)
191+
.await
192+
}
193+
194+
#[tokio::test]
195+
async fn telemetry_service_serves_sensor_readings_as_metric_report() {
196+
let router = test_host_mock();
197+
let client = AxumRouterHttpClient::new(router);
198+
199+
let reports = "/redfish/v1/TelemetryService/MetricReports";
200+
201+
// Service root advertises the service, which links the reports collection.
202+
let root = get(&client, "/redfish/v1").await.unwrap();
203+
assert_eq!(
204+
root["TelemetryService"]["@odata.id"],
205+
"/redfish/v1/TelemetryService"
206+
);
207+
let service = get(&client, "/redfish/v1/TelemetryService").await.unwrap();
208+
assert_eq!(service["ServiceEnabled"], true);
209+
assert_eq!(service["MetricReports"]["@odata.id"], reports);
210+
211+
// The collection lists the single aggregated report.
212+
let collection = get(&client, reports).await.unwrap();
213+
assert_eq!(
214+
collection["Members"],
215+
json!([{ "@odata.id": format!("{reports}/{REPORT_ID}") }])
216+
);
217+
218+
// Every value mirrors a chassis sensor reading.
219+
let report = get(&client, &format!("{reports}/{REPORT_ID}"))
220+
.await
221+
.unwrap();
222+
let values = report["MetricValues"].as_array().expect("MetricValues");
223+
assert!(!values.is_empty(), "report should mirror chassis sensors");
224+
assert_eq!(report["MetricValues@odata.count"], values.len());
225+
for value in values {
226+
value["MetricValue"]
227+
.as_str()
228+
.expect("MetricValue string")
229+
.parse::<f64>()
230+
.expect("numeric reading");
231+
}
232+
233+
// Unknown report ids 404.
234+
assert!(get(&client, &format!("{reports}/Nope")).await.is_err());
235+
}
236+
}

crates/bmc-mock/src/test_support/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ use crate::{
3535
pub mod axum_http_client;
3636

3737
#[derive(Debug)]
38-
struct NoopCallbacks;
38+
pub struct NoopCallbacks;
3939

4040
impl Callbacks for NoopCallbacks {
4141
fn get_power_state(&self) -> MockPowerState {

0 commit comments

Comments
 (0)