diff --git a/crates/dpf/src/sdk.rs b/crates/dpf/src/sdk.rs index f9a1803798..71c2a8dc69 100644 --- a/crates/dpf/src/sdk.rs +++ b/crates/dpf/src/sdk.rs @@ -2736,6 +2736,12 @@ impl DpfSdk spec_dpu_node_name: d.spec.dpu_node_name.clone(), status_phase: d.status.as_ref().map(|s| format!("{:?}", s.phase)), status_bfb_file: d.status.as_ref().and_then(|s| s.bfb_file.clone()), + status_conditions: d.status.as_ref().and_then(|s| s.conditions.clone()), + status_operational_conditions: d + .status + .as_ref() + .and_then(|s| s.operational_conditions.clone()), + status_agent_status: d.status.as_ref().and_then(|s| s.agent_status.clone()), }); } } diff --git a/crates/dpf/src/test/mod.rs b/crates/dpf/src/test/mod.rs index 7110fd61de..e49ab87725 100644 --- a/crates/dpf/src/test/mod.rs +++ b/crates/dpf/src/test/mod.rs @@ -18,6 +18,7 @@ mod helpers; mod maintenance_flow; mod sdk_device_registration; +mod sdk_host_snapshot; mod sdk_initialization; mod sdk_join_set; mod sdk_maintenance_hold; diff --git a/crates/dpf/src/test/sdk_host_snapshot.rs b/crates/dpf/src/test/sdk_host_snapshot.rs new file mode 100644 index 0000000000..01e2d040a7 --- /dev/null +++ b/crates/dpf/src/test/sdk_host_snapshot.rs @@ -0,0 +1,362 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +//! `DpfSdk::snapshot_host`, the ad-hoc dump behind `nico-admin-cli dpf +//! snapshot`. The status fields it carries are the point of the command, so +//! these cover what survives the walk from DPU CR to `DpuSummary` — including +//! a DPU with no status at all, which is what a freshly created CR looks like. + +use std::collections::BTreeMap; +use std::sync::Arc; + +use async_trait::async_trait; +use dashmap::DashMap; +use kube::core::ObjectMeta; + +use crate::crds::dpudevices_generated::DPUDevice; +use crate::crds::dpunodes_generated::DPUNode; +use crate::crds::dpus_generated::DPU; +use crate::error::DpfError; +use crate::repository::{ + DpuDeviceRepository, DpuNodeRepository, DpuRepository, K8sConfigRepository, +}; +use crate::sdk::DpfSdkBuilder; +use crate::types::HostDpfSnapshot; + +const TEST_NS: &str = "test-namespace"; +const NODE_ID: &str = "aabbccddeeff"; +const DEVICE_ID: &str = "0011"; + +fn node_cr_name() -> String { + format!("node-{NODE_ID}") +} + +fn device_cr_name() -> String { + format!("device-{DEVICE_ID}") +} + +fn dpu_cr_name() -> String { + format!("node-{NODE_ID}-device-{DEVICE_ID}") +} + +#[derive(Default)] +struct SnapshotMock { + nodes: Arc>, + devices: Arc>, + dpus: Arc>, +} + +impl SnapshotMock { + /// A node pointing at one device, that device, and `dpu` under the CR name + /// `snapshot_host` derives from the two. + fn with(dpu: DPU) -> Self { + let mock = Self::default(); + mock.nodes.insert(node_cr_name(), node()); + mock.devices.insert(device_cr_name(), device()); + mock.dpus.insert(dpu_cr_name(), dpu); + mock + } +} + +#[async_trait] +impl DpuNodeRepository for SnapshotMock { + async fn get(&self, name: &str, _ns: &str) -> Result, DpfError> { + Ok(self.nodes.get(name).map(|n| n.clone())) + } + async fn list(&self, _ns: &str) -> Result, DpfError> { + Ok(self.nodes.iter().map(|n| n.clone()).collect()) + } + async fn create(&self, node: &DPUNode) -> Result { + Ok(node.clone()) + } + async fn patch(&self, _: &str, _: &str, _: serde_json::Value) -> Result<(), DpfError> { + Ok(()) + } + async fn delete(&self, _: &str, _: &str) -> Result<(), DpfError> { + Ok(()) + } +} + +#[async_trait] +impl DpuDeviceRepository for SnapshotMock { + async fn get(&self, name: &str, _ns: &str) -> Result, DpfError> { + Ok(self.devices.get(name).map(|d| d.clone())) + } + async fn list(&self, _ns: &str) -> Result, DpfError> { + Ok(self.devices.iter().map(|d| d.clone()).collect()) + } + async fn create(&self, device: &DPUDevice) -> Result { + Ok(device.clone()) + } + async fn patch(&self, _: &str, _: &str, _: serde_json::Value) -> Result<(), DpfError> { + Ok(()) + } + async fn delete(&self, _: &str, _: &str) -> Result<(), DpfError> { + Ok(()) + } +} + +#[async_trait] +impl DpuRepository for SnapshotMock { + async fn get(&self, name: &str, _ns: &str) -> Result, DpfError> { + Ok(self.dpus.get(name).map(|d| d.clone())) + } + async fn list(&self, _ns: &str, _selector: Option<&str>) -> Result, DpfError> { + Ok(self.dpus.iter().map(|d| d.clone()).collect()) + } + async fn patch_status(&self, _: &str, _: &str, _: serde_json::Value) -> Result<(), DpfError> { + Ok(()) + } + async fn delete(&self, _: &str, _: &str) -> Result<(), DpfError> { + Ok(()) + } + fn watch( + &self, + _namespace: &str, + _label_selector: Option<&str>, + _handler: F, + ) -> impl std::future::Future + Send + 'static + where + F: Fn(Arc) -> Fut + Send + Sync + 'static, + Fut: std::future::Future> + Send + 'static, + { + std::future::ready(()) + } +} + +#[async_trait] +impl K8sConfigRepository for SnapshotMock { + async fn get_configmap( + &self, + _: &str, + _: &str, + ) -> Result>, DpfError> { + Ok(None) + } + async fn create_configmap( + &self, + _: &str, + _: &str, + _: BTreeMap, + ) -> Result { + Ok(true) + } + async fn apply_configmap( + &self, + _: &str, + _: &str, + _: BTreeMap, + ) -> Result<(), DpfError> { + Ok(()) + } + async fn get_secret( + &self, + _: &str, + _: &str, + ) -> Result>>, DpfError> { + Ok(None) + } + async fn apply_secret( + &self, + _: &str, + _: &str, + _: BTreeMap>, + ) -> Result<(), DpfError> { + Ok(()) + } +} + +fn node() -> DPUNode { + DPUNode { + metadata: ObjectMeta { + name: Some(node_cr_name()), + namespace: Some(TEST_NS.to_string()), + ..Default::default() + }, + spec: serde_json::from_value(serde_json::json!({ + "dpus": [{ "name": device_cr_name() }], + })) + .expect("valid DpuNodeSpec"), + status: None, + } +} + +fn device() -> DPUDevice { + DPUDevice { + metadata: ObjectMeta { + name: Some(device_cr_name()), + namespace: Some(TEST_NS.to_string()), + ..Default::default() + }, + spec: serde_json::from_value(serde_json::json!({ + "bmcIp": "192.0.2.10", + "bmcPort": 443, + "serialNumber": "MT2000X00000", + })) + .expect("valid DpuDeviceSpec"), + status: None, + } +} + +/// `status` is passed through verbatim so a test can hand it exactly the shape +/// the CRD produces, rather than assembling generated structs field by field. +fn dpu(status: Option) -> DPU { + DPU { + metadata: ObjectMeta { + name: Some(dpu_cr_name()), + namespace: Some(TEST_NS.to_string()), + ..Default::default() + }, + spec: serde_json::from_value(serde_json::json!({ + "bfb": "bf-bundle-abc", + "dpuFlavor": "test-flavor", + "dpuDeviceName": device_cr_name(), + "dpuNodeName": node_cr_name(), + "nodeEffect": { "noEffect": true }, + "serialNumber": "MT2000X00000", + })) + .expect("valid DpuSpec"), + status: status.map(|s| serde_json::from_value(s).expect("valid DpuStatus")), + } +} + +async fn snapshot(mock: SnapshotMock) -> HostDpfSnapshot { + DpfSdkBuilder::new(mock, TEST_NS, String::new()) + .build_without_resources() + .await + .expect("sdk") + .snapshot_host(&node_cr_name()) + .await + .expect("snapshot") +} + +/// A ready DPU reporting all three status surfaces at once. `phase` alone says +/// where a DPU is, not why, so the snapshot is only useful if these survive. +#[tokio::test] +async fn a_dpus_conditions_operational_conditions_and_agent_status_are_all_reported() { + let mock = SnapshotMock::with(dpu(Some(serde_json::json!({ + "phase": "Ready", + "bfbFile": "/bfb/test-namespace-bf-bundle-abc.bfb", + "conditions": [{ + "type": "Ready", + "status": "True", + "reason": "DPUReady", + "message": "DPU is ready", + "lastTransitionTime": "2026-08-25T00:00:00Z", + "observedGeneration": 3, + }], + "operationalConditions": [{ + "type": "NVConfigUpToDate", + "status": "False", + "reason": "PendingReboot", + "message": "nvconfig applied, awaiting reboot", + "lastTransitionTime": "2026-08-25T01:00:00Z", + }], + "agentStatus": { + "kubeletVersion": "v1.31.4", + "initialBootId": "boot-abc", + "rebootSequenceCount": 2, + "conditions": [{ + "type": "AgentReady", + "status": "True", + "reason": "Running", + "message": "agent is running", + "lastTransitionTime": "2026-08-25T02:00:00Z", + }], + }, + })))); + + let snapshot = snapshot(mock).await; + + let [dpu] = &snapshot.dpus[..] else { + panic!("expected exactly one DPU, got {}", snapshot.dpus.len()); + }; + assert_eq!(dpu.status_phase.as_deref(), Some("Ready")); + + let conditions = dpu.status_conditions.as_ref().expect("conditions reported"); + assert_eq!(conditions.len(), 1); + assert_eq!(conditions[0].type_, "Ready"); + assert_eq!(conditions[0].status, "True"); + assert_eq!(conditions[0].reason, "DPUReady"); + assert_eq!(conditions[0].observed_generation, Some(3)); + + let operational = dpu + .status_operational_conditions + .as_ref() + .expect("operational conditions reported"); + assert_eq!(operational.len(), 1); + assert_eq!(operational[0].r#type, "NVConfigUpToDate"); + assert_eq!(operational[0].reason, "PendingReboot"); + + let agent = dpu + .status_agent_status + .as_ref() + .expect("agent status reported"); + assert_eq!(agent.kubelet_version.as_deref(), Some("v1.31.4")); + assert_eq!(agent.reboot_sequence_count, Some(2)); + // The agent keeps its own conditions, distinct from the DPU's above. + assert_eq!( + agent + .conditions + .as_ref() + .map(|c| c.iter().map(|c| c.type_.as_str()).collect::>()), + Some(vec!["AgentReady"]) + ); +} + +/// The three fields are independently optional on the CRD, so a DPU reporting +/// only some of them must not drag the others into existence. +#[tokio::test] +async fn absent_status_fields_are_reported_as_absent_rather_than_empty() { + let mock = SnapshotMock::with(dpu(Some(serde_json::json!({ + "phase": "Initializing", + "conditions": [{ + "type": "Initialized", + "status": "False", + "reason": "Pending", + "message": "waiting on BFB", + "lastTransitionTime": "2026-08-25T00:00:00Z", + }], + })))); + + let snapshot = snapshot(mock).await; + + let [dpu] = &snapshot.dpus[..] else { + panic!("expected exactly one DPU, got {}", snapshot.dpus.len()); + }; + assert!(dpu.status_conditions.is_some()); + assert!(dpu.status_operational_conditions.is_none()); + assert!(dpu.status_agent_status.is_none()); +} + +/// A DPU CR exists before the operator writes any status to it. Reporting the +/// DPU with empty status beats omitting it, which would read as "no such DPU". +#[tokio::test] +async fn a_dpu_without_status_is_still_reported() { + let mock = SnapshotMock::with(dpu(None)); + + let snapshot = snapshot(mock).await; + + let [dpu] = &snapshot.dpus[..] else { + panic!("expected exactly one DPU, got {}", snapshot.dpus.len()); + }; + assert_eq!(dpu.name, dpu_cr_name()); + assert!(dpu.status_phase.is_none()); + assert!(dpu.status_conditions.is_none()); + assert!(dpu.status_operational_conditions.is_none()); + assert!(dpu.status_agent_status.is_none()); +} diff --git a/crates/dpf/src/types.rs b/crates/dpf/src/types.rs index df07792b9e..af526c574c 100644 --- a/crates/dpf/src/types.rs +++ b/crates/dpf/src/types.rs @@ -20,10 +20,13 @@ use std::collections::BTreeMap; use std::net::IpAddr; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; use k8s_openapi::apimachinery::pkg::util::intstr::IntOrString; use serde::{Deserialize, Serialize}; -use crate::crds::dpus_generated::DpuStatusPhase; +use crate::crds::dpus_generated::{ + DpuStatusAgentStatus, DpuStatusOperationalConditions, DpuStatusPhase, +}; /// Async provider for BMC passwords used to create and refresh the K8s BMC /// secret. Implement this trait to supply credentials dynamically (e.g. from @@ -885,6 +888,16 @@ pub struct DpuSummary { pub spec_dpu_node_name: String, pub status_phase: Option, pub status_bfb_file: Option, + /// `status.conditions`, verbatim. `phase` alone says where a DPU is, not + /// why it is stuck there; the conditions carry the reason and message. + pub status_conditions: Option>, + /// `status.operationalConditions`, verbatim. Separate from `conditions`: + /// these describe the DPU's health once provisioned, rather than the + /// progress of provisioning itself. + pub status_operational_conditions: Option>, + /// `status.agentStatus`, verbatim. What the DPU-side agent reports about + /// itself, including its own conditions, kubelet version, and reboot state. + pub status_agent_status: Option, } /// Service version resolved from a DPUDeployment's services and their DPUServiceTemplate CRs.