diff --git a/Cargo.lock b/Cargo.lock index 76633dafbf..c7f5abd49e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6759,7 +6759,7 @@ dependencies = [ [[package]] name = "librms" version = "0.0.14" -source = "git+https://github.com/NVIDIA/nv-rms-client.git?tag=v0.9.1#d6d1c3545bdea108c27ec2d0be67fe22c5069a72" +source = "git+https://github.com/NVIDIA/nv-rms-client.git?tag=v0.10.0#0769455549f85e64c4fcc471bd24aa7fa6622609" dependencies = [ "async-trait", "chrono", @@ -11776,7 +11776,7 @@ dependencies = [ [[package]] name = "tonic-client-wrapper" version = "1.0.0" -source = "git+https://github.com/NVIDIA/nv-rms-client.git?tag=v0.9.1#d6d1c3545bdea108c27ec2d0be67fe22c5069a72" +source = "git+https://github.com/NVIDIA/nv-rms-client.git?tag=v0.10.0#0769455549f85e64c4fcc471bd24aa7fa6622609" dependencies = [ "async-trait", "heck", diff --git a/Cargo.toml b/Cargo.toml index 02996d1993..6e38ca58c0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ authors = ["NVIDIA Carbide Engineering "] [workspace.dependencies] clap = { version = "4", features = ["derive", "env"] } libredfish = { git = "https://github.com/NVIDIA/libredfish.git", tag = "v0.44.22" } -librms = { git = "https://github.com/NVIDIA/nv-rms-client.git", tag = "v0.9.1" } +librms = { git = "https://github.com/NVIDIA/nv-rms-client.git", tag = "v0.10.0" } ansi-to-html = "0.2.2" tokio = { version = "1", features = ["full", "tracing"] } diff --git a/book/src/configuration/configurability.md b/book/src/configuration/configurability.md index 00c013f368..555c549f87 100644 --- a/book/src/configuration/configurability.md +++ b/book/src/configuration/configurability.md @@ -190,20 +190,32 @@ explicitly enabled in the TOML. | `[machine_identity]` | SPIFFE JWT-SVID issuance for machine (host) identity | Per-org JWT signing. See [Day 0 Machine Identity](../../../docs/getting-started/installation-options/day0-machine-identity.md) and [Machine Identity (Day 1)](../../../docs/configuration/machine_identity.md). | | `[measured_boot_collector]` | TPM-based attestation metrics | | | `[machine_validation_config]` | Pre-ingestion validation tests | | -| `[component_manager]` | Compute tray, NvLink switch, and power shelf management | RMS backends require rack profile data for node type resolution. | +| `[component_manager]` | Compute tray, NvLink switch, and power shelf management | RMS backends require rack profile data for node descriptors. | | `[vmaas_config]` | VM system integration / VM-aware traffic intercept | Requires `public_prefixes`. | | `[rms]` | Rack Manager Service (mTLS connectivity to external RMS) | | | `[dpf]` | DPU Platform Framework — Kubernetes DPU workload deployment | Requires the DPF operator deployed in-cluster. | | `rack_management_enabled` | Standalone infrastructure manager mode (GB200/GB300/VR144) | Top-level boolean, not a sub-section. | -For RMS component-manager backends, NICo resolves the RMS node type from the -rack profile. The rack profile provides two facts: +For RMS component-manager backends, NICo builds RMS node descriptors from rack +profiles. Each descriptor contains three attributes: -- Product family from `product_family`, which is required for RMS-backed - operations and currently accepts `gb200` or `gb300`. +- Role from the component-manager operation: `compute`, `switch`, or + `power_shelf`. +- Product family from `product_family`, which must be non-empty for RMS-backed + operations. NICo passes other non-empty product-family identifiers to RMS + without a local hardware mapping. - Vendor from `rack_capabilities..vendor` for each role using an RMS backend. +NICo always sends these attributes in descriptor-based RMS requests. For exact +role, vendor, and product-family combinations represented by the current RMS +`NodeType` enum, NICo also sends that enum and legacy firmware-filter entries +for compatibility with older RMS servers. Other combinations leave `NodeType` +unset and require RMS support for `NodeDescriptor`. This best-effort legacy +mapping does not participate in startup validation. In particular, VRNVL72 +power shelves use their configured VRNVL72 descriptor because no matching +legacy `NodeType` exists. + NICo validates configured rack profiles at startup when any component-manager backend is set to `rms`. The component-manager backend fields default to `rms`, so deployments that only want one RMS role must explicitly set the other backend @@ -213,22 +225,16 @@ the vendor fields for enabled RMS roles. For example, if only values, then only `rack_capabilities.power_shelf.vendor` is required as a vendor field. -Use these canonical vendor names in config: +NICo trims outer whitespace from `product_family` and vendor values and requires +both to be non-empty. It does not validate either value against a fixed list. +RMS determines whether each role/vendor/product-family combination is supported +when a request is made. See +[Supported RMS descriptor combinations](../../../docs/configuration/component-manager-rms.md#supported-rms-descriptor-combinations), +including VRNVL72. -| Role | Canonical values | -| --- | --- | -| Compute, when `compute_tray_backend = "rms"` | `NVIDIA`, `Lenovo` | -| Switch, when `nv_switch_backend = "rms"` | `NVIDIA` | -| Power shelf, when `power_shelf_backend = "rms"` | `LiteOn`, `Delta` | - -The `product_family` value is not normalized. It must exactly match one of the -accepted lowercase values, such as `gb200` or `gb300`; values like `GB200` are -rejected. Vendor matching is more forgiving. Vendor values are trimmed, -case-insensitive, and ignore spaces, hyphens, and underscores, so `NVIDIA`, -`nvidia`, `LiteOn`, `liteon`, `Lite-On`, and `lite_on` all work. Common company -suffix text also works when the normalized value starts with the canonical -vendor, but the canonical values above are preferred for operator-supplied -config. +For product families other than `gb200` and `gb300`, the `GetRackProfile` +`product_family` enum is `UNSPECIFIED`. The configured string remains available +to descriptor-based RMS operations. The examples below only show the component-manager and rack-profile fields. Configure `[rms]` separately when NICo needs to call RMS. @@ -306,16 +312,16 @@ still checked when an RMS operation runs. | Field | Accepted values | | --- | --- | -| `product_family`, when an RMS-backed operation uses the profile | Exact match: `gb200`, `gb300` | -| `rack_hardware_topology` | `gb200_nvl36r1_c2g4_topology`, `gb200_nvl72r1_c2g4_topology`, `gb300_nvl36r1_c2g4_topology`, `gb300_nvl72r1_c2g4_topology` | -| Compute profile vendor, when `compute_tray_backend = "rms"` | `nvidia`, `lenovo` after normalization | -| Switch profile vendor, when `nv_switch_backend = "rms"` | `nvidia` after normalization | -| Power shelf profile vendor, when `power_shelf_backend = "rms"` | `liteon`, `delta` after normalization | +| `product_family`, when an RMS-backed operation uses the profile | Non-empty string; RMS validates support at request time | +| `rack_hardware_topology` | `gb200_nvl36r1_c2g4_topology`, `gb200_nvl72r1_c2g4_topology`, `gb300_nvl36r1_c2g4_topology`, `gb300_nvl72r1_c2g4_topology`, `vr_nvl8r1_c2g4_rtf_topology`, `vr_nvl72r1_c2g4_topology` | +| Compute profile vendor, when `compute_tray_backend = "rms"` | Non-empty string; RMS validates support at request time | +| Switch profile vendor, when `nv_switch_backend = "rms"` | Non-empty string; RMS validates support at request time | +| Power shelf profile vendor, when `power_shelf_backend = "rms"` | Non-empty string; RMS validates support at request time | The separate site-explorer machine-ingestion RMS slot/tray lookup also uses the -rack profile for RMS node type resolution. If that path is enabled for machines -with rack IDs, the profile also needs compute product-family and vendor data even -when `compute_tray_backend` is not `rms`. +rack profile to build a compute node descriptor. If that path is enabled for +machines with rack IDs, the profile also needs compute product-family and vendor +data even when `compute_tray_backend` is not `rms`. ### State-controller timing @@ -1226,7 +1232,7 @@ on or off. | Site Explorer machine auto-creation | siteConfig | `[site_explorer].create_machines` | on | Disable for manual-onboarding environments. | | Site Explorer switch / power shelf auto-creation | siteConfig | `[site_explorer].create_switches` / `[site_explorer].create_power_shelves` | on | Ingests only declared hardware (`expected_switches` / `expected_power_shelves` records). Disable to pause switch or power shelf ingestion site-wide. | | Firmware autoupdate | siteConfig | `[firmware_global].autoupdate` | off | Enable once the fleet's firmware baseline is stable. | -| Component Manager (compute trays / NvLink switches / power shelves) | siteConfig | `[component_manager]` present | off | GB200/GB300 sites with managed compute, power, and switch fabric. RMS backends require rack profile data for node type resolution. | +| Component Manager (compute trays / NvLink switches / power shelves) | siteConfig | `[component_manager]` present | off | GB200/GB300 sites with managed compute, power, and switch fabric. RMS backends require rack profile data for node descriptors. | | Auto-repair plugin | siteConfig | `[auto_machine_repair_plugin]` | off | Enable per fault class as fleet maturity grows. | | BOM / SKU validation | siteConfig | `[bom_validation]` present | off | Validate ingested hardware against expected BOM before `Ready`. | | Network Security Groups | siteConfig | `[network_security_group]` | default | Touch only for non-default direction policy or scale-out limits. | diff --git a/crates/api-core/src/cfg/README.md b/crates/api-core/src/cfg/README.md index a153c5991d..47c8a3947a 100644 --- a/crates/api-core/src/cfg/README.md +++ b/crates/api-core/src/cfg/README.md @@ -122,16 +122,28 @@ applicable. --- -### Component Manager RMS Node Type Resolution +### Component Manager RMS Node Descriptors -When `[component_manager]` uses RMS backends, NICo resolves RMS node types from -rack profiles. The rack profile provides two facts: +When `[component_manager]` uses RMS backends, NICo builds RMS node descriptors +from rack profiles. Each descriptor contains three attributes: -- Product family from `product_family`, which is required for RMS-backed - operations and currently accepts `gb200` or `gb300`. +- Role from the component-manager operation: `compute`, `switch`, or + `power_shelf`. +- Product family from `product_family`, which must be non-empty for RMS-backed + operations. NICo passes other non-empty product-family identifiers to RMS + without a local hardware mapping. - Vendor from `rack_capabilities..vendor` for each role using an RMS backend. +NICo always sends these attributes in descriptor-based RMS requests. For exact +role, vendor, and product-family combinations represented by the current RMS +`NodeType` enum, NICo also sends that enum and legacy firmware-filter entries +for compatibility with older RMS servers. Other combinations leave `NodeType` +unset and require RMS support for `NodeDescriptor`. This best-effort legacy +mapping does not participate in startup validation. In particular, VRNVL72 +power shelves use their configured VRNVL72 descriptor because no matching +legacy `NodeType` exists. + NICo validates configured rack profiles at startup when any component-manager backend is set to `rms`. The component-manager backend fields default to `rms`, so deployments that only want one RMS role must explicitly set the other backend @@ -141,22 +153,16 @@ the vendor fields for enabled RMS roles. For example, if only values, then only `rack_capabilities.power_shelf.vendor` is required as a vendor field. -Use these canonical vendor names in config: - -| Role | Canonical values | -|------|------------------| -| Compute, when `compute_tray_backend = "rms"` | `NVIDIA`, `Lenovo` | -| Switch, when `nv_switch_backend = "rms"` | `NVIDIA` | -| Power shelf, when `power_shelf_backend = "rms"` | `LiteOn`, `Delta` | +NICo trims outer whitespace from `product_family` and vendor values and requires +both to be non-empty. It does not validate either value against a fixed list. +RMS determines whether each role/vendor/product-family combination is supported +when a request is made. See +[Supported RMS descriptor combinations](../../../../docs/configuration/component-manager-rms.md#supported-rms-descriptor-combinations), +including VRNVL72. -The `product_family` value is not normalized. It must exactly match one of the -accepted lowercase values, such as `gb200` or `gb300`; values like `GB200` are -rejected. Vendor matching is more forgiving. Vendor values are trimmed, -case-insensitive, and ignore spaces, hyphens, and underscores, so `NVIDIA`, -`nvidia`, `LiteOn`, `liteon`, `Lite-On`, and `lite_on` all work. Common company -suffix text also works when the normalized value starts with the canonical -vendor, but the canonical values above are preferred for operator-supplied -config. +For product families other than `gb200` and `gb300`, the `GetRackProfile` +`product_family` enum is `UNSPECIFIED`. The configured string remains available +to descriptor-based RMS operations. The examples below only show the component-manager and rack-profile fields. Configure `[rms]` separately when NICo needs to call RMS. @@ -206,9 +212,8 @@ vendor = "delta" ``` Example: only the component-manager power shelf backend uses RMS. The compute -and switch component-manager backends are explicitly set to real non-RMS values -so component-manager startup validation only requires the power shelf vendor -field: +and switch component-manager backends are explicitly set to non-RMS values, so +component-manager startup validation only requires the power shelf vendor field: ```toml [component_manager] @@ -233,13 +238,13 @@ existing rack database rows, so missing or unknown per-rack profile IDs are still checked when an RMS operation runs. The separate site-explorer machine-ingestion RMS slot/tray lookup also uses the -rack profile for RMS node type resolution. If that path is enabled for machines -with rack IDs, the profile also needs compute product-family and vendor data even -when `compute_tray_backend` is not `rms`. +rack profile to build a compute node descriptor. If that path is enabled for +machines with rack IDs, the profile also needs compute product-family and vendor +data even when `compute_tray_backend` is not `rms`. -Supported RMS product-family values are exact-match `gb200` and `gb300`. The -optional `rack_hardware_topology` field remains available for topology-specific -flows. +NICo accepts non-empty product-family strings. RMS evaluates descriptor support +when an operation runs. The optional `rack_hardware_topology` field remains +available for topology-specific flows. --- diff --git a/crates/api-core/src/setup.rs b/crates/api-core/src/setup.rs index c24d681980..09e92637db 100644 --- a/crates/api-core/src/setup.rs +++ b/crates/api-core/src/setup.rs @@ -2043,8 +2043,9 @@ mod tests { let error = format!("{error:?}"); assert!( - error.contains("rack_capabilities.switch.vendor"), - "error message should name the missing vendor field: {error}" + error.contains("rack profile NVL72 cannot build RMS switch node descriptor") + && error.contains("rack profile does not identify an RMS switch vendor"), + "error message should identify the rack profile and missing role vendor: {error}" ); Ok(()) diff --git a/crates/api-core/src/tests/rack_state_controller/handler.rs b/crates/api-core/src/tests/rack_state_controller/handler.rs index 38503ced54..872f037c3d 100644 --- a/crates/api-core/src/tests/rack_state_controller/handler.rs +++ b/crates/api-core/src/tests/rack_state_controller/handler.rs @@ -2435,14 +2435,8 @@ async fn test_configure_nmx_cluster_disable_scale_up_fabric_state_runs_on_all_sw .as_slice(); assert_eq!(devices.len(), switch_ids.len()); - for device in devices { - let host_endpoint = device - .host_endpoint - .as_ref() - .ok_or_else(|| eyre::eyre!("disable request should include host endpoints"))?; + assert!(devices.iter().all(|device| device.host_endpoint.is_some())); - assert!(host_endpoint.dangerously_accept_invalid_certs); - } let node_ids = devices .iter() .map(|device| device.node_id.clone()) @@ -2603,13 +2597,7 @@ async fn test_configure_nmx_cluster_configure_selects_persists_and_configures_pr .ok_or_else(|| eyre::eyre!("configure request should include a primary switch"))?; assert_eq!(configure_node.node_id, primary_switch_id.to_string()); - assert!( - configure_node - .host_endpoint - .as_ref() - .ok_or_else(|| eyre::eyre!("configure request should include a host endpoint"))? - .dangerously_accept_invalid_certs - ); + assert!(configure_node.host_endpoint.is_some()); let mut txn = pool.acquire().await?; let primary_switch = db_switch::find_by_id(&mut txn, &primary_switch_id) @@ -2919,14 +2907,13 @@ async fn test_configure_nmx_cluster_runs_start_disable_configure_to_wait_for_fab .as_slice(); assert_eq!(disable_devices.len(), switch_ids.len()); - for device in disable_devices { - let host_endpoint = device - .host_endpoint - .as_ref() - .ok_or_else(|| eyre::eyre!("disable request should include host endpoints"))?; - assert!(host_endpoint.dangerously_accept_invalid_certs); - } + assert!( + disable_devices + .iter() + .all(|device| device.host_endpoint.is_some()) + ); + let disabled_node_ids = disable_devices .iter() .map(|device| device.node_id.clone()) @@ -2999,13 +2986,7 @@ async fn test_configure_nmx_cluster_runs_start_disable_configure_to_wait_for_fab .ok_or_else(|| eyre::eyre!("configure request should include a primary switch"))?; assert_eq!(configure_node.node_id, primary_switch_id.to_string()); - assert!( - configure_node - .host_endpoint - .as_ref() - .ok_or_else(|| eyre::eyre!("configure request should include a host endpoint"))? - .dangerously_accept_invalid_certs - ); + assert!(configure_node.host_endpoint.is_some()); let mut txn = pool.acquire().await?; let primary_switch = db_switch::find_by_id(&mut txn, &primary_switch_id) @@ -3359,13 +3340,7 @@ async fn test_configure_nmx_cluster_configure_failure_advances_to_wait_for_fabri .ok_or_else(|| eyre::eyre!("configure request should include a primary switch"))?; assert_eq!(configure_node.node_id, primary_switch_id.to_string()); - assert!( - configure_node - .host_endpoint - .as_ref() - .ok_or_else(|| eyre::eyre!("configure request should include a host endpoint"))? - .dangerously_accept_invalid_certs - ); + assert!(configure_node.host_endpoint.is_some()); let mut txn = pool.acquire().await?; let primary_switch = db_switch::find_by_id(&mut txn, &primary_switch_id) diff --git a/crates/api-db/src/machine.rs b/crates/api-db/src/machine.rs index 60e4d52fb5..a6b6f9119f 100644 --- a/crates/api-db/src/machine.rs +++ b/crates/api-db/src/machine.rs @@ -2795,7 +2795,7 @@ impl<'r> FromRow<'r, PgRow> for _HealthReportWrapper { } /// RMS identity for a compute tray machine, including rack profile context for -/// node type resolution. +/// node descriptor construction. #[derive(Debug, sqlx::FromRow)] pub struct MachineRmsIdentity { pub id: String, diff --git a/crates/api-db/src/power_shelf.rs b/crates/api-db/src/power_shelf.rs index 2be96de823..dec1c5f74a 100644 --- a/crates/api-db/src/power_shelf.rs +++ b/crates/api-db/src/power_shelf.rs @@ -523,8 +523,8 @@ pub async fn find_ids_by_bmc_macs( .map_err(|err| DatabaseError::new("power_shelf::find_ids_by_bmc_macs", err)) } -/// RMS identity for a power shelf, including rack profile context for node type -/// resolution. +/// RMS identity for a power shelf, including rack profile context for node +/// descriptor construction. #[derive(Debug, sqlx::FromRow)] pub struct PowerShelfRmsIdentity { pub id: String, diff --git a/crates/api-db/src/switch.rs b/crates/api-db/src/switch.rs index bf05531606..af23b2b1ca 100644 --- a/crates/api-db/src/switch.rs +++ b/crates/api-db/src/switch.rs @@ -784,8 +784,8 @@ pub async fn find_ids_by_bmc_macs( .map_err(|err| DatabaseError::new("switch::find_ids_by_bmc_macs", err)) } -/// RMS identity for a switch, including rack profile context for node type -/// resolution. +/// RMS identity for a switch, including rack profile context for node descriptor +/// construction. #[derive(Debug, sqlx::FromRow)] pub struct SwitchRmsIdentity { pub id: String, diff --git a/crates/api-model/src/rack_type.rs b/crates/api-model/src/rack_type.rs index 3f0f7d27c2..be93b27d08 100644 --- a/crates/api-model/src/rack_type.rs +++ b/crates/api-model/src/rack_type.rs @@ -17,8 +17,10 @@ use std::collections::HashMap; use std::fmt; +use std::str::FromStr; -use serde::{Deserialize, Serialize}; +use serde::de::Error as _; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; /// RackHardwareType identifies the hardware type of a rack. /// This is a flexible string-based type to allow new hardware types @@ -65,23 +67,114 @@ impl From<&str> for RackHardwareType { } } -/// RackProductFamily identifies the product family shared by rack components. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] -#[sqlx(type_name = "text", rename_all = "snake_case")] -#[serde(rename_all = "snake_case")] +/// Identifies the product family shared by rack components. +/// +/// String parsing trims outer whitespace, recognizes the lowercase +/// `gb200` and `gb300` values, and preserves other non-empty identifiers in +/// [`RackProductFamily::Other`]. Named variants retain existing API behavior; +/// the open-ended variant lets descriptor-based backends accept new product +/// families without a NICo release. +#[derive(Debug, Clone, PartialEq, Eq)] pub enum RackProductFamily { /// GB200 rack hardware. Gb200, + /// GB300 rack hardware. Gb300, + + /// A non-empty product-family identifier not represented by a named variant. + /// + /// The original spelling is preserved after outer whitespace is removed. + Other(String), +} + +impl RackProductFamily { + /// Returns the product-family identifier sent to descriptor-based backends. + /// + /// Named variants use their canonical lowercase value. Values stored in + /// [`RackProductFamily::Other`] retain their original spelling with outer + /// whitespace removed. + pub fn as_str(&self) -> &str { + match self { + Self::Gb200 => "gb200", + Self::Gb300 => "gb300", + Self::Other(value) => value.trim(), + } + } } impl fmt::Display for RackProductFamily { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - RackProductFamily::Gb200 => write!(f, "gb200"), - RackProductFamily::Gb300 => write!(f, "gb300"), + f.write_str(self.as_str()) + } +} + +/// Error returned for an empty rack product-family identifier. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[error("rack product family must not be empty")] +pub struct RackProductFamilyParseError; + +impl FromStr for RackProductFamily { + type Err = RackProductFamilyParseError; + + fn from_str(value: &str) -> Result { + let value = value.trim(); + + if value.is_empty() { + return Err(RackProductFamilyParseError); } + + Ok(match value { + "gb200" => Self::Gb200, + "gb300" => Self::Gb300, + value => Self::Other(value.to_string()), + }) + } +} + +impl Serialize for RackProductFamily { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for RackProductFamily { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + String::deserialize(deserializer)? + .parse() + .map_err(D::Error::custom) + } +} + +impl sqlx::Type for RackProductFamily { + fn type_info() -> sqlx::postgres::PgTypeInfo { + >::type_info() + } + + fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool { + >::compatible(ty) + } +} + +impl sqlx::Encode<'_, sqlx::Postgres> for RackProductFamily { + fn encode_by_ref( + &self, + buf: &mut sqlx::postgres::PgArgumentBuffer, + ) -> Result { + <&str as sqlx::Encode>::encode(self.as_str(), buf) + } +} + +impl sqlx::Decode<'_, sqlx::Postgres> for RackProductFamily { + fn decode(value: sqlx::postgres::PgValueRef<'_>) -> Result { + let value = <&str as sqlx::Decode>::decode(value)?; + value.parse().map_err(Into::into) } } @@ -365,7 +458,7 @@ count = 9 count = 8 [NVL36] -product_family = "gb200" +product_family = "test-product-family" [NVL36.rack_capabilities.compute] count = 9 @@ -388,7 +481,12 @@ count = 2 ); let nvl36 = config.get("NVL36").unwrap(); - assert_eq!(nvl36.product_family, Some(RackProductFamily::Gb200)); + + assert_eq!( + nvl36.product_family, + Some(RackProductFamily::Other("test-product-family".to_string())) + ); + assert_eq!(nvl36.rack_capabilities.compute.count, 9); assert_eq!(nvl36.rack_capabilities.switch.count, 9); assert_eq!(nvl36.rack_capabilities.power_shelf.count, 2); @@ -607,6 +705,13 @@ count = 2 "gb300 round-trips" { RackProductFamily::Gb300 => Yields(("\"gb300\"".to_string(), RackProductFamily::Gb300)), } + + "arbitrary family round-trips" { + RackProductFamily::Other("test-product-family".to_string()) => Yields(( + "\"test-product-family\"".to_string(), + RackProductFamily::Other("test-product-family".to_string()), + )), + } ); } @@ -621,6 +726,10 @@ count = 2 "gb300" { RackProductFamily::Gb300 => "gb300".to_string(), } + + "arbitrary family" { + RackProductFamily::Other(" test-product-family ".to_string()) => "test-product-family".to_string(), + } ); } @@ -636,12 +745,16 @@ count = 2 "\"gb300\"" => Yields(RackProductFamily::Gb300), } - "unknown product family" { - "\"gb400\"" => Fails, + "arbitrary product family" { + "\"test-product-family\"" => Yields(RackProductFamily::Other("test-product-family".to_string())), + } + + "arbitrary product family preserves case" { + "\"GB200\"" => Yields(RackProductFamily::Other("GB200".to_string())), } - "wrong case" { - "\"GB200\"" => Fails, + "empty product family" { + "\" \"" => Fails, } ); } diff --git a/crates/api-test-helper/src/mock_rms.rs b/crates/api-test-helper/src/mock_rms.rs index d71baf1bfa..440c8e0da7 100644 --- a/crates/api-test-helper/src/mock_rms.rs +++ b/crates/api-test-helper/src/mock_rms.rs @@ -204,10 +204,6 @@ pub struct MockRmsApi { Mutex>>, push_switch_firmware_calls: Mutex>, - upgrade_switch_firmware_responses: - Mutex>>, - upgrade_switch_firmware_calls: Mutex>, - // Switch certificate calls. configure_switch_certificate_responses: Mutex>>, @@ -224,10 +220,6 @@ pub struct MockRmsApi { Mutex>>, list_switch_system_images_calls: Mutex>, - poll_switch_firmware_job_status_responses: - Mutex>>, - poll_switch_firmware_job_status_calls: Mutex>, - get_switch_system_image_job_status_responses: Mutex>>, get_switch_system_image_job_status_calls: Mutex>, @@ -351,16 +343,12 @@ impl MockRmsApi { list_switch_firmware_calls: Default::default(), push_switch_firmware_responses: Default::default(), push_switch_firmware_calls: Default::default(), - upgrade_switch_firmware_responses: Default::default(), - upgrade_switch_firmware_calls: Default::default(), configure_switch_certificate_responses: Default::default(), configure_switch_certificate_calls: Default::default(), get_configure_switch_certificate_job_status_responses: Default::default(), get_configure_switch_certificate_job_status_calls: Default::default(), list_switch_system_images_responses: Default::default(), list_switch_system_images_calls: Default::default(), - poll_switch_firmware_job_status_responses: Default::default(), - poll_switch_firmware_job_status_calls: Default::default(), get_switch_system_image_job_status_responses: Default::default(), get_switch_system_image_job_status_calls: Default::default(), configure_scale_up_fabric_manager_responses: Default::default(), @@ -693,14 +681,6 @@ impl MockRmsApi { rms::PushSwitchFirmwareRequest, rms::PushSwitchFirmwareResponse ); - impl_enqueue_inspect!( - enqueue_upgrade_switch_firmware, - upgrade_switch_firmware_calls, - upgrade_switch_firmware_responses, - upgrade_switch_firmware_calls, - rms::UpgradeSwitchFirmwareRequest, - rms::UpgradeSwitchFirmwareResponse - ); // Switch certificate impl_enqueue_inspect!( @@ -729,14 +709,7 @@ impl MockRmsApi { rms::ListSwitchSystemImagesRequest, rms::ListSwitchSystemImagesResponse ); - impl_enqueue_inspect!( - enqueue_poll_switch_firmware_job_status, - poll_switch_firmware_job_status_calls, - poll_switch_firmware_job_status_responses, - poll_switch_firmware_job_status_calls, - rms::PollSwitchFirmwareJobStatusRequest, - rms::PollSwitchFirmwareJobStatusResponse - ); + impl_enqueue_inspect!( enqueue_get_switch_system_image_job_status, get_switch_system_image_job_status_calls, @@ -997,17 +970,6 @@ impl MockRmsApi { } } - /// Success response for `poll_switch_firmware_job_status`. - pub fn poll_switch_firmware_job_status_ok( - state: &str, - ) -> rms::PollSwitchFirmwareJobStatusResponse { - rms::PollSwitchFirmwareJobStatusResponse { - status: rms::ReturnCode::Success as i32, - state: state.to_owned(), - ..Default::default() - } - } - /// Success response for `get_switch_system_image_job_status`. pub fn switch_system_image_job_status_ok( state: &str, @@ -1437,13 +1399,7 @@ impl RmsApi for MockRmsApi { self.push_switch_firmware_calls.lock().await.push(cmd); pop_or_err(&mut self.push_switch_firmware_responses.lock().await) } - async fn upgrade_switch_firmware( - &self, - cmd: rms::UpgradeSwitchFirmwareRequest, - ) -> Result { - self.upgrade_switch_firmware_calls.lock().await.push(cmd); - pop_or_err(&mut self.upgrade_switch_firmware_responses.lock().await) - } + async fn configure_switch_certificate( &self, cmd: rms::ConfigureSwitchCertificateRequest, @@ -1476,16 +1432,6 @@ impl RmsApi for MockRmsApi { self.list_switch_system_images_calls.lock().await.push(cmd); pop_or_err(&mut self.list_switch_system_images_responses.lock().await) } - async fn poll_switch_firmware_job_status( - &self, - cmd: rms::PollSwitchFirmwareJobStatusRequest, - ) -> Result { - self.poll_switch_firmware_job_status_calls - .lock() - .await - .push(cmd); - pop_or_err(&mut self.poll_switch_firmware_job_status_responses.lock().await) - } async fn get_switch_system_image_job_status( &self, diff --git a/crates/component-manager/src/component_manager.rs b/crates/component-manager/src/component_manager.rs index fa67b37f6a..91f7948253 100644 --- a/crates/component-manager/src/component_manager.rs +++ b/crates/component-manager/src/component_manager.rs @@ -510,9 +510,9 @@ impl ComponentManager { /// The factory inspects the configured nv-switch, power-shelf, and compute-tray /// backend selectors to decide which concrete implementations to instantiate. /// Unknown backend names are rejected at config-deserialization time by the -/// backend enums. When any backend uses RMS, `rack_profiles` must contain enough -/// product-family and vendor data to resolve RMS node types before startup -/// continues. +/// backend enums. When any backend uses RMS, `rack_profiles` must contain the +/// product-family and vendor data required to build RMS node descriptors before +/// startup continues. pub async fn build_component_manager( config: &ComponentManagerConfig, rack_profiles: RackProfileConfig, @@ -1172,31 +1172,7 @@ mod tests { assert_eq!( error.to_string(), - "invalid argument: rack profile NVL72 rack_capabilities.power_shelf.vendor is required when power_shelf_backend is 'rms'" - ); - } - - #[tokio::test] - async fn build_validates_rms_backend_vendor_value() { - let mut profile = rms_rack_profile(); - profile.rack_capabilities.switch.vendor = Some("Other".to_string()); - - let rack_profiles = rms_rack_profiles(profile); - let config = ComponentManagerConfig { - nv_switch_backend: NvSwitchBackend::Rms, - power_shelf_backend: PowerShelfBackend::Mock, - compute_tray_backend: ComputeBackend::Mock, - ..Default::default() - }; - - let result = build_component_manager(&config, rack_profiles, None, None, None, None).await; - let Err(error) = result else { - panic!("unsupported RMS vendor should be rejected"); - }; - - assert_eq!( - error.to_string(), - "invalid argument: rack profile NVL72 cannot resolve RMS switch node type: RMS does not support switch vendor Other" + "invalid argument: rack profile NVL72 cannot build RMS power shelf node descriptor: rack profile does not identify an RMS power shelf vendor" ); } } diff --git a/crates/component-manager/src/rms.rs b/crates/component-manager/src/rms.rs index 1d6f4dbef5..770333fb69 100644 --- a/crates/component-manager/src/rms.rs +++ b/crates/component-manager/src/rms.rs @@ -22,7 +22,9 @@ use std::sync::{Arc, Mutex}; use carbide_instrument::red; use carbide_rack::firmware_object::rms_access_token_or_noauth; use carbide_rack::rms_node_type::{ - compute_node_type_for_profile, power_shelf_node_type_for_profile, switch_node_type_for_profile, + RmsNodeIdentity, compute_node_identity_for_profile, + firmware_object_component_filters_for_node_identities, power_shelf_node_identity_for_profile, + switch_node_identity_for_profile, }; use carbide_secrets::credentials::Credentials; use carbide_uuid::rack::RackProfileId; @@ -66,7 +68,7 @@ struct RmsIdentity { struct ResolvedRmsNode<'a> { identity: &'a RmsIdentity, - node_type: rms::NodeType, + node_identity: RmsNodeIdentity, } /// Role for MAC-keyed switch and power shelf lookups. @@ -95,12 +97,9 @@ const RMS_IDENTITY_LOOKUP_ERROR: &str = "could not resolve RMS identity from dat /// Validates rack profile fields required by RMS component-manager backends. /// -/// RMS requests use concrete node type variants, such as the product family -/// and vendor-specific compute, switch, or power shelf type. NICo resolves -/// those variants from rack profile product-family and vendor data. When an RMS -/// backend is enabled, invalid or incomplete rack profile data would otherwise -/// surface later as per-device power or firmware operation failures, so -/// validate all configured profiles during startup. +/// Descriptor-based RMS requests require a product family and the per-role +/// vendor string. Startup validation checks those descriptor inputs without +/// matching the hardware against a fixed enum. pub fn validate_rms_backend_rack_profiles( config: &ComponentManagerConfig, rack_profiles: &RackProfileConfig, @@ -122,38 +121,20 @@ pub fn validate_rms_backend_rack_profiles( for (profile_id, profile) in &rack_profiles.rack_profiles { if compute_uses_rms { - require_rms_vendor( - profile_id, - "rack_capabilities.compute.vendor", - profile.rack_capabilities.compute.vendor.as_deref(), - "compute_tray_backend", - )?; - compute_node_type_for_profile(profile).map_err(|error| { - rms_node_type_config_error(profile_id, "compute", error.to_string()) + compute_node_identity_for_profile(profile).map_err(|error| { + rms_node_descriptor_config_error(profile_id, "compute", error.to_string()) })?; } if switch_uses_rms { - require_rms_vendor( - profile_id, - "rack_capabilities.switch.vendor", - profile.rack_capabilities.switch.vendor.as_deref(), - "nv_switch_backend", - )?; - switch_node_type_for_profile(profile).map_err(|error| { - rms_node_type_config_error(profile_id, "switch", error.to_string()) + switch_node_identity_for_profile(profile).map_err(|error| { + rms_node_descriptor_config_error(profile_id, "switch", error.to_string()) })?; } if power_shelf_uses_rms { - require_rms_vendor( - profile_id, - "rack_capabilities.power_shelf.vendor", - profile.rack_capabilities.power_shelf.vendor.as_deref(), - "power_shelf_backend", - )?; - power_shelf_node_type_for_profile(profile).map_err(|error| { - rms_node_type_config_error(profile_id, "power shelf", error.to_string()) + power_shelf_node_identity_for_profile(profile).map_err(|error| { + rms_node_descriptor_config_error(profile_id, "power shelf", error.to_string()) })?; } } @@ -161,31 +142,13 @@ pub fn validate_rms_backend_rack_profiles( Ok(()) } -fn require_rms_vendor( - profile_id: &str, - field: &str, - vendor: Option<&str>, - backend_field: &str, -) -> Result<(), ComponentManagerError> { - if vendor - .map(str::trim) - .is_some_and(|vendor| !vendor.is_empty()) - { - return Ok(()); - } - - Err(ComponentManagerError::InvalidArgument(format!( - "rack profile {profile_id} {field} is required when {backend_field} is 'rms'" - ))) -} - -fn rms_node_type_config_error( +fn rms_node_descriptor_config_error( profile_id: &str, role: &str, error: String, ) -> ComponentManagerError { ComponentManagerError::InvalidArgument(format!( - "rack profile {profile_id} cannot resolve RMS {role} node type: {error}" + "rack profile {profile_id} cannot build RMS {role} node descriptor: {error}" )) } @@ -249,7 +212,7 @@ impl RmsBackend { ) -> Result<&'a RackProfile, ComponentManagerError> { let Some(rack_profile_id) = &identity.rack_profile_id else { return Err(ComponentManagerError::InvalidArgument(format!( - "rack {} has no rack_profile_id for RMS node type resolution", + "rack {} has no rack_profile_id for RMS node descriptor resolution", identity.rack_id ))); }; @@ -258,7 +221,7 @@ impl RmsBackend { .get(rack_profile_id.as_str()) .ok_or_else(|| { ComponentManagerError::InvalidArgument(format!( - "rack profile {} is not configured for RMS node type resolution", + "rack profile {} is not configured for RMS node descriptor resolution", rack_profile_id )) }) @@ -277,15 +240,16 @@ impl RmsBackend { let profile = self .rack_profile(identity) .map_err(|error| error.to_string())?; - let node_type = match role { - SwitchOrPowerShelfRole::PowerShelf => power_shelf_node_type_for_profile(profile), - SwitchOrPowerShelfRole::Switch => switch_node_type_for_profile(profile), + + let node_identity = match role { + SwitchOrPowerShelfRole::PowerShelf => power_shelf_node_identity_for_profile(profile), + SwitchOrPowerShelfRole::Switch => switch_node_identity_for_profile(profile), } .map_err(|error| error.to_string())?; Ok(ResolvedRmsNode { identity, - node_type, + node_identity, }) } @@ -296,12 +260,13 @@ impl RmsBackend { let profile = self .rack_profile(&identity.identity) .map_err(|error| error.to_string())?; - let node_type = - compute_node_type_for_profile(profile).map_err(|error| error.to_string())?; + + let node_identity = + compute_node_identity_for_profile(profile).map_err(|error| error.to_string())?; Ok(ResolvedRmsNode { identity: &identity.identity, - node_type, + node_identity, }) } } @@ -480,13 +445,12 @@ const POWER_SHELF_BMC_PORT: u32 = 443; /// RMS's inventory; power shelves do not expose a host endpoint. fn build_power_shelf_node_info( ep: &PowerShelfEndpoint, - identity: &RmsIdentity, - node_type: rms::NodeType, + resolved: &ResolvedRmsNode<'_>, ) -> rms::NodeInfo { - rms::NodeInfo { - node_id: identity.node_id.clone(), - rack_id: identity.rack_id.clone(), - r#type: Some(node_type as i32), + let mut node = rms::NodeInfo { + node_id: resolved.identity.node_id.clone(), + rack_id: resolved.identity.rack_id.clone(), + r#type: None, bmc_endpoint: Some(rms::Endpoint { interface: Some(rms::NetworkInterface { ip_address: ep.pmc_ip.to_string(), @@ -495,10 +459,14 @@ fn build_power_shelf_node_info( }), port: POWER_SHELF_BMC_PORT, credentials: Some(credentials_to_rms(&ep.pmc_credentials)), - dangerously_accept_invalid_certs: true, }), host_endpoint: None, - } + node_descriptor: None, + }; + + resolved.node_identity.apply_to_node_info(&mut node); + + node } #[async_trait::async_trait] @@ -539,7 +507,8 @@ impl PowerShelfManager for RmsBackend { } }; - let device = build_power_shelf_node_info(ep, resolved.identity, resolved.node_type); + let device = build_power_shelf_node_info(ep, &resolved); + let request = rms::BatchSetPowerStateRequest { nodes: Some(rms::NodeSet { nodes: vec![device], @@ -612,14 +581,14 @@ impl PowerShelfManager for RmsBackend { } }; - let device = build_power_shelf_node_info(ep, resolved.identity, resolved.node_type); + let device = build_power_shelf_node_info(ep, &resolved); + let request = match apply_firmware_object_request( device, - resolved.identity, + &resolved, target_version, options, - resolved.node_type, - component_filters.clone(), + &component_filters, ) { Ok(request) => request, Err(e) => { @@ -867,7 +836,8 @@ impl PowerShelfManager for RmsBackend { } }; - let device = build_power_shelf_node_info(ep, resolved.identity, resolved.node_type); + let device = build_power_shelf_node_info(ep, &resolved); + let observed = query_rms_power_state( self.client.as_ref(), device, @@ -929,14 +899,13 @@ fn credentials_to_rms(creds: &Credentials) -> rms::Credentials { /// details instead of RMS inventory. fn build_switch_node_info( ep: &SwitchEndpoint, - identity: &RmsIdentity, - node_type: rms::NodeType, + resolved: &ResolvedRmsNode<'_>, nvos_host_name: Option, ) -> rms::NodeInfo { - rms::NodeInfo { - node_id: identity.node_id.clone(), - rack_id: identity.rack_id.clone(), - r#type: Some(node_type as i32), + let mut node = rms::NodeInfo { + node_id: resolved.identity.node_id.clone(), + rack_id: resolved.identity.rack_id.clone(), + r#type: None, bmc_endpoint: Some(rms::Endpoint { interface: Some(rms::NetworkInterface { ip_address: ep.bmc_ip.to_string(), @@ -945,7 +914,6 @@ fn build_switch_node_info( }), port: SWITCH_BMC_PORT, credentials: Some(credentials_to_rms(&ep.bmc_credentials)), - dangerously_accept_invalid_certs: true, }), host_endpoint: Some(rms::Endpoint { interface: Some(rms::NetworkInterface { @@ -955,9 +923,13 @@ fn build_switch_node_info( }), port: 0, credentials: Some(credentials_to_rms(&ep.nvos_credentials)), - dangerously_accept_invalid_certs: true, }), - } + node_descriptor: None, + }; + + resolved.node_identity.apply_to_node_info(&mut node); + + node } /// Builds the host-only endpoint description required for password rotation. @@ -966,11 +938,10 @@ fn build_switch_node_info( /// its credentials from crossing this backend boundary. fn build_switch_password_rotation_node_info( ep: &SwitchEndpoint, - identity: &RmsIdentity, - node_type: rms::NodeType, + resolved: &ResolvedRmsNode<'_>, nvos_host_name: Option, ) -> rms::NodeInfo { - let node = build_switch_node_info(ep, identity, node_type, nvos_host_name); + let node = build_switch_node_info(ep, resolved, nvos_host_name); rms::NodeInfo { bmc_endpoint: None, @@ -1119,11 +1090,10 @@ async fn query_rms_power_state( fn apply_firmware_object_request( device: rms::NodeInfo, - identity: &RmsIdentity, + resolved: &ResolvedRmsNode<'_>, config_json: &str, options: &FirmwareUpdateOptions, - node_type: rms::NodeType, - components: Vec, + components: &[String], ) -> Result { let access_token = Some(rms_access_token_or_noauth(options.access_token.as_deref())); @@ -1133,14 +1103,14 @@ fn apply_firmware_object_request( )); } - let mut component_filters = HashMap::with_capacity(1); - component_filters.insert( - node_type as i32, - rms::FirmwareObjectComponentFilter { components }, - ); + let (component_filters, node_descriptor_component_filters) = + firmware_object_component_filters_for_node_identities( + components, + [&resolved.node_identity], + ); Ok(rms::ApplyFirmwareObjectRequest { - rack_id: identity.rack_id.clone(), + rack_id: resolved.identity.rack_id.clone(), config_json: config_json.to_owned(), access_token, firmware_type: RMS_FIRMWARE_OBJECT_FIRMWARE_TYPE.to_owned(), @@ -1150,6 +1120,7 @@ fn apply_firmware_object_request( }), force_update: options.force_update, component_filters, + node_descriptor_component_filters, }) } @@ -1229,18 +1200,15 @@ fn compute_tray_firmware_object_component_filters( } } -/// Build the `rms::NodeInfo` describing a compute tray for inclusion in an -/// RMS batch request. Compute trays expose only a BMC endpoint. fn build_compute_tray_node_info( ep: &ComputeTrayEndpoint, - identity: &RmsIdentity, + resolved: &ResolvedRmsNode<'_>, bmc_mac: MacAddress, - node_type: rms::NodeType, ) -> rms::NodeInfo { - rms::NodeInfo { - node_id: identity.node_id.clone(), - rack_id: identity.rack_id.clone(), - r#type: Some(node_type as i32), + let mut node = rms::NodeInfo { + node_id: resolved.identity.node_id.clone(), + rack_id: resolved.identity.rack_id.clone(), + r#type: None, bmc_endpoint: Some(rms::Endpoint { interface: Some(rms::NetworkInterface { ip_address: ep.bmc_ip.to_string(), @@ -1249,10 +1217,14 @@ fn build_compute_tray_node_info( }), port: COMPUTE_TRAY_BMC_PORT, credentials: Some(credentials_to_rms(&ep.bmc_credentials)), - dangerously_accept_invalid_certs: true, }), host_endpoint: None, - } + node_descriptor: None, + }; + + resolved.node_identity.apply_to_node_info(&mut node); + + node } fn summarize_firmware_object_apply_response( @@ -1469,12 +1441,9 @@ impl NvSwitchManager for RmsBackend { } }; - let device = build_switch_node_info( - ep, - resolved.identity, - resolved.node_type, - hostnames.get(&ep.nvos_mac).cloned(), - ); + let device = + build_switch_node_info(ep, &resolved, hostnames.get(&ep.nvos_mac).cloned()); + let request = rms::BatchSetPowerStateRequest { nodes: Some(rms::NodeSet { nodes: vec![device], @@ -1556,19 +1525,13 @@ impl NvSwitchManager for RmsBackend { let mut tracked_jobs = Vec::new(); if include_firmware_object { - let device = build_switch_node_info( - ep, - resolved.identity, - resolved.node_type, - nvos_host_name.clone(), - ); + let device = build_switch_node_info(ep, &resolved, nvos_host_name.clone()); match apply_firmware_object_request( device, - resolved.identity, + &resolved, bundle_version, options, - resolved.node_type, - component_filters.clone(), + &component_filters, ) { Ok(request) => match red::instrumented( "rms", @@ -1620,12 +1583,7 @@ impl NvSwitchManager for RmsBackend { } if include_system_image { - let device = build_switch_node_info( - ep, - resolved.identity, - resolved.node_type, - nvos_host_name, - ); + let device = build_switch_node_info(ep, &resolved, nvos_host_name); match apply_switch_system_image_request( device, resolved.identity, @@ -1789,12 +1747,9 @@ impl NvSwitchManager for RmsBackend { } }; - let device = build_switch_node_info( - ep, - resolved.identity, - resolved.node_type, - hostnames.get(&ep.nvos_mac).cloned(), - ); + let device = + build_switch_node_info(ep, &resolved, hostnames.get(&ep.nvos_mac).cloned()); + let observed = query_rms_power_state( self.client.as_ref(), device, @@ -1841,12 +1796,9 @@ impl NvSwitchManager for RmsBackend { } }; - let device = build_switch_node_info( - ep, - resolved.identity, - resolved.node_type, - hostnames.get(&ep.nvos_mac).cloned(), - ); + let device = + build_switch_node_info(ep, &resolved, hostnames.get(&ep.nvos_mac).cloned()); + let request = rms::BatchGetNodeDeviceInfoRequest { nodes: Some(rms::NodeSet { nodes: vec![device], @@ -1936,11 +1888,6 @@ impl NvSwitchManager for RmsBackend { return Err(ComponentManagerError::Internal(error)); } }; - let Some(identity) = ids.get(&endpoint.bmc_mac) else { - return Err(ComponentManagerError::Internal( - "could not resolve RMS identity from database".into(), - )); - }; let hostnames = resolve_switch_machine_interface_hostnames(&self.db, std::slice::from_ref(endpoint)) @@ -1948,8 +1895,7 @@ impl NvSwitchManager for RmsBackend { let device = build_switch_node_info( endpoint, - identity, - resolved.node_type, + &resolved, hostnames.get(&endpoint.nvos_mac).cloned(), ); rms_configure_switch_certificate(self.client.as_ref(), device, domain_name, services).await @@ -1993,8 +1939,7 @@ impl NvSwitchManager for RmsBackend { let device = build_switch_password_rotation_node_info( endpoint, - resolved.identity, - resolved.node_type, + &resolved, hostnames.get(&endpoint.nvos_mac).cloned(), ); @@ -2407,12 +2352,8 @@ impl ComputeTrayManager for RmsBackend { } }; - let device = build_compute_tray_node_info( - ep, - resolved.identity, - identity.bmc_mac, - resolved.node_type, - ); + let device = build_compute_tray_node_info(ep, &resolved, identity.bmc_mac); + let request = rms::BatchSetPowerStateRequest { nodes: Some(rms::NodeSet { nodes: vec![device], @@ -2489,19 +2430,14 @@ impl ComputeTrayManager for RmsBackend { } }; - let device = build_compute_tray_node_info( - ep, - resolved.identity, - identity.bmc_mac, - resolved.node_type, - ); + let device = build_compute_tray_node_info(ep, &resolved, identity.bmc_mac); + let request = match apply_firmware_object_request( device, - resolved.identity, + &resolved, target_version, options, - resolved.node_type, - component_filters.clone(), + &component_filters, ) { Ok(request) => request, Err(e) => { @@ -2681,6 +2617,11 @@ mod tests { use crate::config::SwitchMtlsService; use crate::power_shelf_manager::PowerShelfVendor; + const KEY_ROLE: &str = "role"; + const ROLE_COMPUTE: &str = "compute"; + const ROLE_POWER_SHELF: &str = "power_shelf"; + const ROLE_SWITCH: &str = "switch"; + #[async_trait::async_trait] impl RmsSwitchSystemImageStatusApi for MockRmsApi { async fn get_switch_system_image_job_status( @@ -3130,6 +3071,46 @@ mod tests { } } + #[test] + fn validation_requires_product_family_and_enabled_role_vendors() { + let mut arbitrary_values = test_rms_profile(); + arbitrary_values.product_family = + Some(RackProductFamily::Other("test-product-family".to_string())); + + arbitrary_values.rack_capabilities.compute.vendor = Some("test-compute-vendor".to_string()); + arbitrary_values.rack_capabilities.switch.vendor = Some("test-switch-vendor".to_string()); + arbitrary_values.rack_capabilities.power_shelf.vendor = + Some("test-power-shelf-vendor".to_string()); + + let mut missing_vendor = arbitrary_values.clone(); + missing_vendor.rack_capabilities.power_shelf.vendor = None; + + let mut missing_product_family = arbitrary_values.clone(); + missing_product_family.product_family = None; + + let mut blank_product_family = arbitrary_values.clone(); + blank_product_family.product_family = Some(RackProductFamily::Other(" \t ".to_string())); + + value_scenarios!(run = |profile: RackProfile| { + let profiles = RackProfileConfig { + rack_profiles: [(TEST_RACK_PROFILE_ID.to_string(), profile)] + .into_iter() + .collect(), + }; + + validate_rms_backend_rack_profiles(&ComponentManagerConfig::default(), &profiles) + .is_ok() + }; + + "descriptor validation" { + arbitrary_values => true, + missing_vendor => false, + missing_product_family => false, + blank_product_family => false, + } + ); + } + /// Create a backend with a real DB pool seeded with test data. async fn make_backend( pool: &sqlx::PgPool, @@ -3203,82 +3184,138 @@ mod tests { } } - fn component_filters_for( - request: &rms::ApplyFirmwareObjectRequest, - node_type: rms::NodeType, - ) -> &[String] { - &request - .component_filters - .get(&(node_type as i32)) - .expect("component filters for node type") - .components + fn test_rms_identity() -> RmsIdentity { + RmsIdentity { + node_id: "node-1".to_string(), + rack_id: "rack-1".to_string(), + rack_profile_id: None, + } } - fn single_batch_set_power_state_node_type( - calls: &[rms::BatchSetPowerStateRequest], - ) -> Option { - let [call] = calls else { - return None; + fn test_rms_profile() -> RackProfile { + let mut profile = RackProfile { + product_family: Some(RackProductFamily::Gb200), + ..Default::default() }; - let nodes = call.nodes.as_ref()?; - let [node] = nodes.nodes.as_slice() else { - return None; + + profile.rack_capabilities.compute.vendor = Some("NVIDIA".to_string()); + profile.rack_capabilities.switch.vendor = Some("NVIDIA".to_string()); + profile.rack_capabilities.power_shelf.vendor = Some("LiteOn".to_string()); + + profile + } + + fn component_filters_for(request: &rms::ApplyFirmwareObjectRequest) -> &[String] { + let [filter] = request.node_descriptor_component_filters.as_slice() else { + panic!("expected one descriptor component filter"); + }; + + let descriptor = filter + .node_descriptor + .as_ref() + .expect("filter node descriptor"); + + let role = descriptor + .attributes + .get(KEY_ROLE) + .expect("filter node role"); + + let node_type = match role.as_str() { + ROLE_COMPUTE => rms::NodeType::ComputeGb200Nvidia, + ROLE_SWITCH => rms::NodeType::SwitchGb200Nvidia, + ROLE_POWER_SHELF => rms::NodeType::PowershelfGb200Liteon, + role => panic!("unexpected RMS node role {role}"), }; - node.r#type + let descriptor_components = filter + .component_filter + .as_ref() + .expect("descriptor component filter") + .components + .as_slice(); + + assert_eq!( + request + .component_filters + .get(&(node_type as i32)) + .map(|filter| filter.components.as_slice()), + Some(descriptor_components) + ); + + descriptor_components } - #[test] - fn direct_rms_power_shelf_node_info_uses_concrete_node_type() { - let endpoint = make_ps_endpoint(PS_MAC_1); - let identity = RmsIdentity { - node_id: "node-1".to_string(), - rack_id: "rack-1".to_string(), - rack_profile_id: None, + fn assert_descriptor_node(node: &rms::NodeInfo, role: &str) { + let node_type = match role { + ROLE_COMPUTE => rms::NodeType::ComputeGb200Nvidia, + ROLE_SWITCH => rms::NodeType::SwitchGb200Nvidia, + ROLE_POWER_SHELF => rms::NodeType::PowershelfGb200Liteon, + role => panic!("unexpected RMS node role {role}"), }; - let node = - build_power_shelf_node_info(&endpoint, &identity, rms::NodeType::PowershelfGb300Delta); + assert_eq!(node.r#type, Some(node_type as i32)); + + let descriptor = node.node_descriptor.as_ref().expect("node descriptor"); assert_eq!( - node.r#type, - Some(rms::NodeType::PowershelfGb300Delta as i32) + descriptor.attributes.get(KEY_ROLE).map(String::as_str), + Some(role) ); } #[test] - fn direct_rms_switch_node_info_uses_concrete_node_type() { + fn direct_rms_power_shelf_node_info_uses_descriptor() { + let endpoint = make_ps_endpoint(PS_MAC_1); + let identity = test_rms_identity(); + let profile = test_rms_profile(); + + let node_identity = power_shelf_node_identity_for_profile(&profile).unwrap(); + + let resolved = ResolvedRmsNode { + identity: &identity, + node_identity, + }; + + let node = build_power_shelf_node_info(&endpoint, &resolved); + + assert_descriptor_node(&node, ROLE_POWER_SHELF); + } + + #[test] + fn direct_rms_switch_node_info_uses_descriptor() { let endpoint = make_sw_endpoint(SW_MAC_1); - let identity = RmsIdentity { - node_id: "node-1".to_string(), - rack_id: "rack-1".to_string(), - rack_profile_id: None, + let identity = test_rms_identity(); + let profile = test_rms_profile(); + + let node_identity = switch_node_identity_for_profile(&profile).unwrap(); + + let resolved = ResolvedRmsNode { + identity: &identity, + node_identity, }; - let node = - build_switch_node_info(&endpoint, &identity, rms::NodeType::SwitchGb300Nvidia, None); + let node = build_switch_node_info(&endpoint, &resolved, None); - assert_eq!(node.r#type, Some(rms::NodeType::SwitchGb300Nvidia as i32)); + assert_descriptor_node(&node, ROLE_SWITCH); } #[test] fn password_rotation_node_info_excludes_bmc_credentials() { let endpoint = make_sw_endpoint(SW_MAC_1); + let identity = test_rms_identity(); + let profile = test_rms_profile(); - let identity = RmsIdentity { - node_id: "node-1".to_string(), - rack_id: "rack-1".to_string(), - rack_profile_id: None, + let node_identity = switch_node_identity_for_profile(&profile).unwrap(); + + let resolved = ResolvedRmsNode { + identity: &identity, + node_identity, }; - let node = build_switch_password_rotation_node_info( - &endpoint, - &identity, - rms::NodeType::SwitchGb300Nvidia, - None, - ); + let node = build_switch_password_rotation_node_info(&endpoint, &resolved, None); assert!(node.bmc_endpoint.is_none()); + assert_descriptor_node(&node, ROLE_SWITCH); assert_eq!( node.host_endpoint @@ -3295,20 +3332,25 @@ mod tests { #[test] fn direct_rms_firmware_object_json_request_defaults_missing_access_token_to_noauth() { + let identity = test_rms_identity(); + let profile = test_rms_profile(); + + let node_identity = switch_node_identity_for_profile(&profile).unwrap(); + + let resolved = ResolvedRmsNode { + identity: &identity, + node_identity, + }; + let request = apply_firmware_object_request( rms::NodeInfo::default(), - &RmsIdentity { - node_id: "node-1".to_string(), - rack_id: "rack-1".to_string(), - rack_profile_id: None, - }, + &resolved, r#"{"Id":"fw-json"}"#, &FirmwareUpdateOptions { access_token: None, force_update: false, }, - rms::NodeType::SwitchGb200Nvidia, - Vec::new(), + &[], ) .unwrap(); @@ -3319,7 +3361,7 @@ mod tests { } #[carbide_macros::sqlx_test] - async fn power_shelf_power_control_request_uses_profile_vendor_node_type( + async fn power_shelf_power_control_request_uses_descriptor( pool: sqlx::PgPool, ) -> Result<(), Box> { let (mock, backend, _, ps1, _, _, _) = make_backend(&pool).await; @@ -3338,10 +3380,16 @@ mod tests { assert!(results[0].success); let calls = mock.batch_set_power_state_calls().await; - assert_eq!( - single_batch_set_power_state_node_type(&calls), - Some(rms::NodeType::PowershelfGb200Liteon as i32) - ); + + let [call] = calls.as_slice() else { + panic!("expected one BatchSetPowerState request"); + }; + + let [node] = call.nodes.as_ref().expect("request nodes").nodes.as_slice() else { + panic!("expected one node"); + }; + + assert_descriptor_node(node, ROLE_POWER_SHELF); Ok(()) } @@ -3398,10 +3446,7 @@ mod tests { let dev0 = &calls[0].nodes.as_ref().unwrap().nodes[0]; assert_eq!(dev0.node_id, ps1.to_string()); assert_eq!(dev0.rack_id, rack_id.to_string()); - assert_eq!( - dev0.r#type, - Some(rms::NodeType::PowershelfGb200Liteon as i32) - ); + assert_descriptor_node(dev0, ROLE_POWER_SHELF); assert!(dev0.bmc_endpoint.is_some()); assert!(dev0.host_endpoint.is_none()); let dev1 = &calls[1].nodes.as_ref().unwrap().nodes[0]; @@ -3516,15 +3561,12 @@ mod tests { assert_eq!(calls[0].firmware_type, "prod"); assert_eq!(calls[0].hardware_type, "any"); assert!(calls[0].force_update); - let filters = component_filters_for(&calls[0], rms::NodeType::PowershelfGb200Liteon); + let filters = component_filters_for(&calls[0]); assert_eq!(filters, ["PowerShelfFW"]); let dev0 = &calls[0].nodes.as_ref().unwrap().nodes[0]; assert_eq!(dev0.node_id, ps1.to_string()); assert_eq!(dev0.rack_id, rack_id.to_string()); - assert_eq!( - dev0.r#type, - Some(rms::NodeType::PowershelfGb200Liteon as i32) - ); + assert_descriptor_node(dev0, ROLE_POWER_SHELF); assert!(dev0.bmc_endpoint.is_some()); let jobs = backend.firmware_jobs.lock().unwrap(); @@ -3565,7 +3607,7 @@ mod tests { assert!(results[0].success); let calls = mock.apply_firmware_object_calls().await; - let filters = component_filters_for(&calls[0], rms::NodeType::PowershelfGb200Liteon); + let filters = component_filters_for(&calls[0]); assert_eq!(filters, ["PowerShelfFW"]); } @@ -4243,7 +4285,7 @@ mod tests { let dev0 = &calls[0].nodes.as_ref().unwrap().nodes[0]; assert_eq!(dev0.node_id, sw1.to_string()); assert_eq!(dev0.rack_id, rack_id.to_string()); - assert_eq!(dev0.r#type, Some(rms::NodeType::SwitchGb200Nvidia as i32)); + assert_descriptor_node(dev0, ROLE_SWITCH); assert!(dev0.bmc_endpoint.is_some()); let dev1 = &calls[1].nodes.as_ref().unwrap().nodes[0]; assert_eq!(dev1.node_id, sw2.to_string()); @@ -4296,11 +4338,11 @@ mod tests { assert_eq!(calls[0].config_json, r#"{"Id":"fw-json"}"#); assert_eq!(calls[0].access_token.as_deref(), Some("token")); assert!(calls[0].force_update); - let filters = component_filters_for(&calls[0], rms::NodeType::SwitchGb200Nvidia); + let filters = component_filters_for(&calls[0]); assert_eq!(filters, ["BMC", "BIOS"]); let dev0 = &calls[0].nodes.as_ref().unwrap().nodes[0]; assert_eq!(dev0.node_id, sw1.to_string()); - assert_eq!(dev0.r#type, Some(rms::NodeType::SwitchGb200Nvidia as i32)); + assert_descriptor_node(dev0, ROLE_SWITCH); assert!(dev0.bmc_endpoint.is_some()); assert!(dev0.host_endpoint.is_some()); @@ -4385,7 +4427,7 @@ mod tests { assert_eq!(calls[0].rack_id, rack_id.to_string()); let dev0 = &calls[0].nodes.as_ref().unwrap().nodes[0]; assert_eq!(dev0.node_id, sw1.to_string()); - assert_eq!(dev0.r#type, Some(rms::NodeType::SwitchGb200Nvidia as i32)); + assert_descriptor_node(dev0, ROLE_SWITCH); assert!(dev0.bmc_endpoint.is_some()); assert!(dev0.host_endpoint.is_some()); @@ -4636,7 +4678,7 @@ mod tests { let dev0 = &calls[0].nodes.as_ref().unwrap().nodes[0]; assert_eq!(dev0.node_id, ct1.to_string()); assert_eq!(dev0.rack_id, rack_id.to_string()); - assert_eq!(dev0.r#type, Some(rms::NodeType::ComputeGb200Nvidia as i32)); + assert_descriptor_node(dev0, ROLE_COMPUTE); assert!(dev0.bmc_endpoint.is_some()); assert!(dev0.host_endpoint.is_none()); } @@ -4666,10 +4708,10 @@ mod tests { let calls = mock.apply_firmware_object_calls().await; assert_eq!(calls.len(), 1); assert_eq!(calls[0].rack_id, rack_id.to_string()); - let filters = component_filters_for(&calls[0], rms::NodeType::ComputeGb200Nvidia); + let filters = component_filters_for(&calls[0]); assert_eq!(filters, &["BMC".to_owned()]); let dev0 = &calls[0].nodes.as_ref().unwrap().nodes[0]; - assert_eq!(dev0.r#type, Some(rms::NodeType::ComputeGb200Nvidia as i32)); + assert_descriptor_node(dev0, ROLE_COMPUTE); let jobs = backend.firmware_jobs.lock().unwrap(); assert_eq!( diff --git a/crates/rack-controller/src/fabric_manager.rs b/crates/rack-controller/src/fabric_manager.rs index 3258a601e0..aea4205f64 100644 --- a/crates/rack-controller/src/fabric_manager.rs +++ b/crates/rack-controller/src/fabric_manager.rs @@ -18,6 +18,7 @@ use std::collections::{HashMap, HashSet}; use carbide_rack::firmware_update::build_new_node_info; +use carbide_rack::rms_node_type::RmsNodeIdentity; use carbide_rack_controller::config::RmsConfig; use carbide_utils::none_if_empty::NoneIfEmpty; use carbide_uuid::rack::RackId; @@ -57,13 +58,13 @@ pub(super) fn validate_switch_inventory_for_nmx_cluster( fn build_scale_up_fabric_services_status_request( rack_id: &RackId, switches: &[FirmwareUpgradeDeviceInfo], - node_type: rms::NodeType, + node_identity: &RmsNodeIdentity, ) -> rms::BatchGetScaleUpFabricServiceStatusRequest { rms::BatchGetScaleUpFabricServiceStatusRequest { nodes: Some(rms::NodeSet { nodes: switches .iter() - .map(|switch| build_new_node_info(rack_id, switch, node_type)) + .map(|switch| build_new_node_info(rack_id, switch, node_identity)) .collect(), }), } @@ -73,7 +74,7 @@ pub(super) async fn batch_get_scale_up_fabric_service_status( rms_config: &RmsConfig, rack_id: &RackId, switches: &[FirmwareUpgradeDeviceInfo], - node_type: rms::NodeType, + node_identity: &RmsNodeIdentity, ) -> Result { let Some(url) = rms_config.api_url.as_deref().none_if_empty() else { return Err("RMS client not configured".to_string()); @@ -91,7 +92,9 @@ pub(super) async fn batch_get_scale_up_fabric_service_status( rms_client .client .batch_get_scale_up_fabric_service_status(build_scale_up_fabric_services_status_request( - rack_id, switches, node_type, + rack_id, + switches, + node_identity, )) .await .map_err(|error| format!("RMS BatchGetScaleUpFabricServiceStatus failed: {}", error)) @@ -328,7 +331,9 @@ pub(super) async fn persist_primary_switch( #[cfg(test)] mod tests { + use carbide_rack::rms_node_type::switch_node_identity_for_profile; use carbide_test_support::{Check, check_values}; + use model::rack_type::{RackProductFamily, RackProfile}; use super::*; @@ -347,6 +352,39 @@ mod tests { } } + #[test] + fn fabric_status_request_uses_descriptor_without_node_type() { + let mut profile = RackProfile { + product_family: Some(RackProductFamily::Gb300), + ..Default::default() + }; + + profile.rack_capabilities.switch.vendor = Some("test-switch-vendor".to_string()); + + let node_identity = switch_node_identity_for_profile(&profile).unwrap(); + let rack_id = RackId::from("rack-1"); + let switches = [switch("switch-1")]; + + let request = + build_scale_up_fabric_services_status_request(&rack_id, &switches, &node_identity); + + let [node] = request + .nodes + .expect("request nodes") + .nodes + .try_into() + .unwrap(); + + let descriptor = node.node_descriptor.expect("node descriptor"); + + assert_eq!(node.r#type, None); + + assert_eq!( + descriptor.attributes.get("role").map(String::as_str), + Some("switch") + ); + } + fn node_device_details( node_id: &str, tray_index: u32, diff --git a/crates/rack-controller/src/maintenance.rs b/crates/rack-controller/src/maintenance.rs index c8efe1bafb..42c78e923e 100644 --- a/crates/rack-controller/src/maintenance.rs +++ b/crates/rack-controller/src/maintenance.rs @@ -27,7 +27,10 @@ use carbide_rack::firmware_update::{ }; use carbide_rack::rack_manager_error; use carbide_rack::rms_client::SwitchSystemImageRmsClient; -use carbide_rack::rms_node_type::{compute_node_type_for_profile, switch_node_type_for_profile}; +use carbide_rack::rms_node_type::{ + RmsNodeIdentity, compute_node_identity_for_profile, + firmware_object_component_filters_for_node_identities, switch_node_identity_for_profile, +}; use carbide_rack_controller::config::RmsConfig; use carbide_rack_controller::context::RackStateHandlerContextObjects; use carbide_rack_controller::fabric_manager::{ @@ -512,13 +515,14 @@ async fn handle_configure_nmx_cluster_certificates( return transition_to_rack_error( id, state, - "rack profile is missing or unknown; cannot resolve RMS switch node type for ConfigureCertificates", + "rack profile is missing or unknown; cannot build RMS switch node descriptor for ConfigureCertificates", ctx, ) .await; }; - let switch_node_type = match switch_node_type_for_profile(profile) { - Ok(node_type) => node_type, + + let switch_node_identity = match switch_node_identity_for_profile(profile) { + Ok(identity) => identity, Err(error) => { return transition_to_rack_error(id, state, error.to_string(), ctx).await; } @@ -527,7 +531,7 @@ async fn handle_configure_nmx_cluster_certificates( .batch_get_node_device_info(build_switch_device_info_request( id, &switch_inventory.switches, - switch_node_type, + &switch_node_identity, )) .await { @@ -615,13 +619,13 @@ async fn handle_configure_nmx_cluster_certificates( fn build_switch_device_info_request( rack_id: &RackId, switches: &[FirmwareUpgradeDeviceInfo], - node_type: rms::NodeType, + node_identity: &RmsNodeIdentity, ) -> rms::BatchGetNodeDeviceInfoRequest { rms::BatchGetNodeDeviceInfoRequest { nodes: Some(rms::NodeSet { nodes: switches .iter() - .map(|switch| build_new_node_info(rack_id, switch, node_type)) + .map(|switch| build_new_node_info(rack_id, switch, node_identity)) .collect(), }), } @@ -642,35 +646,6 @@ fn build_nmx_configure_rms_client(rms_config: &RmsConfig) -> Option, - switch_node_type: Option, -) -> std::collections::HashMap { - if components.is_empty() { - return std::collections::HashMap::new(); - } - - let mut filters = std::collections::HashMap::new(); - if let Some(compute_node_type) = compute_node_type { - filters.insert( - compute_node_type as i32, - rms::FirmwareObjectComponentFilter { - components: components.to_vec(), - }, - ); - } - if let Some(switch_node_type) = switch_node_type { - filters.insert( - switch_node_type as i32, - rms::FirmwareObjectComponentFilter { - components: components.to_vec(), - }, - ); - } - filters -} - fn firmware_device_status( device: FirmwareUpgradeDeviceInfo, parent_job_id: Option, @@ -727,13 +702,13 @@ async fn rms_start_firmware_upgrade_from_json( let switch_count = request.switches.len(); let mut nodes = Vec::with_capacity(machine_count + switch_count); - // Resolve all required node types before constructing the RMS request so a - // mixed-device update fails before any partial firmware submission. - let compute_node_type = if machine_count > 0 { + // Resolve all required RMS identities before constructing the RMS request + // so a mixed-device update fails before any partial firmware submission. + let compute_node_identity = if machine_count > 0 { Some( - compute_node_type_for_profile(request.profile).map_err(|error| { + compute_node_identity_for_profile(request.profile).map_err(|error| { StateHandlerError::GenericError(eyre::eyre!( - "failed to resolve RMS compute node type: {}", + "failed to resolve RMS compute descriptor: {}", error )) })?, @@ -741,11 +716,12 @@ async fn rms_start_firmware_upgrade_from_json( } else { None }; - let switch_node_type = if switch_count > 0 { + + let switch_node_identity = if switch_count > 0 { Some( - switch_node_type_for_profile(request.profile).map_err(|error| { + switch_node_identity_for_profile(request.profile).map_err(|error| { StateHandlerError::GenericError(eyre::eyre!( - "failed to resolve RMS switch node type: {}", + "failed to resolve RMS switch descriptor: {}", error )) })?, @@ -754,24 +730,32 @@ async fn rms_start_firmware_upgrade_from_json( None }; - if let Some(node_type) = compute_node_type { + if let Some(node_identity) = &compute_node_identity { nodes.extend( request .machines .iter() - .map(|device| build_new_node_info(request.rack_id, device, node_type)), + .map(|device| build_new_node_info(request.rack_id, device, node_identity)), ); } - if let Some(node_type) = switch_node_type { + if let Some(node_identity) = &switch_node_identity { nodes.extend( request .switches .iter() - .map(|device| build_new_node_info(request.rack_id, device, node_type)), + .map(|device| build_new_node_info(request.rack_id, device, node_identity)), ); } + let (component_filters, node_descriptor_component_filters) = + firmware_object_component_filters_for_node_identities( + request.components, + compute_node_identity + .iter() + .chain(switch_node_identity.iter()), + ); + let response = rms_client .apply_firmware_object(rms::ApplyFirmwareObjectRequest { rack_id: request.rack_id.to_string(), @@ -781,11 +765,8 @@ async fn rms_start_firmware_upgrade_from_json( hardware_type: request.hardware_type.to_string(), nodes: Some(rms::NodeSet { nodes }), force_update: request.force_update, - component_filters: rms_component_filters_from_components( - request.components, - compute_node_type, - switch_node_type, - ), + component_filters, + node_descriptor_component_filters, }) .await .map_err(|error| { @@ -1057,13 +1038,13 @@ async fn rms_start_nvos_update( source: NvosUpdateSource<'_>, software_type: &str, hardware_type: &str, - switch_node_type: rms::NodeType, + switch_node_identity: &RmsNodeIdentity, switches: Vec, ) -> Result { let started_at = chrono::Utc::now(); let nodes: Vec<_> = switches .iter() - .map(|switch| build_new_node_info(rack_id, switch, switch_node_type)) + .map(|switch| build_new_node_info(rack_id, switch, switch_node_identity)) .collect(); let nodes = Some(rms::NodeSet { nodes }); let NvosUpdateSource { @@ -1426,7 +1407,7 @@ pub async fn handle_maintenance( return transition_to_rack_error( id, state, - "rack profile is missing or unknown; cannot resolve RMS node types", + "rack profile is missing or unknown; cannot build RMS node descriptors", ctx, ) .await; @@ -1770,13 +1751,14 @@ pub async fn handle_maintenance( return transition_to_rack_error( id, state, - "rack profile is missing or unknown; cannot resolve RMS switch node type", + "rack profile is missing or unknown; cannot build RMS switch node descriptor", ctx, ) .await; }; - let switch_node_type = match switch_node_type_for_profile(profile) { - Ok(node_type) => node_type, + + let switch_node_identity = match switch_node_identity_for_profile(profile) { + Ok(identity) => identity, Err(error) => { delete_rack_maintenance_access_token( ctx.services.credential_manager.as_ref(), @@ -1826,7 +1808,7 @@ pub async fn handle_maintenance( source, software_type, &rack_hardware_type, - switch_node_type, + &switch_node_identity, switch_inventory.switches, ) .await; @@ -2064,13 +2046,14 @@ pub async fn handle_maintenance( return transition_to_rack_error( id, state, - "rack profile is missing or unknown; cannot resolve RMS switch node type", + "rack profile is missing or unknown; cannot build RMS switch node descriptor", ctx, ) .await; }; - let switch_node_type = match switch_node_type_for_profile(profile) { - Ok(node_type) => node_type, + + let switch_node_identity = match switch_node_identity_for_profile(profile) { + Ok(identity) => identity, Err(error) => { return transition_to_rack_error(id, state, error.to_string(), ctx).await; } @@ -2087,7 +2070,9 @@ pub async fn handle_maintenance( nodes: switch_inventory .switches .iter() - .map(|switch| build_new_node_info(id, switch, switch_node_type)) + .map(|switch| { + build_new_node_info(id, switch, &switch_node_identity) + }) .collect(), }), enabled: false, @@ -2232,8 +2217,9 @@ pub async fn handle_maintenance( ) .await; }; - let switch_node_type = match switch_node_type_for_profile(profile) { - Ok(node_type) => node_type, + + let switch_node_identity = match switch_node_identity_for_profile(profile) { + Ok(identity) => identity, Err(error) => { return transition_to_rack_error(id, state, error.to_string(), ctx).await; } @@ -2243,7 +2229,7 @@ pub async fn handle_maintenance( .batch_get_node_device_info(build_switch_device_info_request( id, &switch_inventory.switches, - switch_node_type, + &switch_node_identity, )) .await { @@ -2286,7 +2272,7 @@ pub async fn handle_maintenance( node: Some(build_new_node_info( id, &primary_switch.device, - switch_node_type, + &switch_node_identity, )), topology_type: topology_type.clone(), }) @@ -2376,13 +2362,14 @@ pub async fn handle_maintenance( return transition_to_rack_error( id, state, - "rack profile is missing or unknown; cannot resolve RMS switch node type", + "rack profile is missing or unknown; cannot build RMS switch node descriptor", ctx, ) .await; }; - let switch_node_type = match switch_node_type_for_profile(profile) { - Ok(node_type) => node_type, + + let switch_node_identity = match switch_node_identity_for_profile(profile) { + Ok(identity) => identity, Err(error) => { return transition_to_rack_error(id, state, error.to_string(), ctx).await; } @@ -2392,7 +2379,7 @@ pub async fn handle_maintenance( &ctx.services.site_config.rms, id, &switch_inventory.switches, - switch_node_type, + &switch_node_identity, ) .await { @@ -2480,20 +2467,22 @@ pub async fn handle_maintenance( #[cfg(test)] mod tests { use carbide_rack::firmware_update::RackFirmwareInventory; + use carbide_rack::rms_node_type::switch_node_identity_for_profile; use carbide_test_support::{Check, check_values}; use carbide_uuid::machine::{MachineId, MachineIdSource, MachineType}; + use carbide_uuid::rack::RackId; use carbide_uuid::switch::{SwitchId, SwitchIdSource, SwitchType}; use model::rack::{ ConfigureNmxClusterState, FirmwareUpgradeDeviceInfo, FirmwareUpgradeState, MaintenanceActivity, MaintenanceScope, NvosUpdateState, RackMaintenanceState, RackPowerState, }; - use model::rack_type::{RackHardwareType, RackProfile}; + use model::rack_type::{RackHardwareType, RackProductFamily, RackProfile}; use super::{ - filter_inventory_by_scope, firmware_device_status, first_maintenance_state, - next_state_after_configure, next_state_after_firmware, next_state_after_nvos, - profile_hardware_type_or_any, + build_switch_device_info_request, filter_inventory_by_scope, firmware_device_status, + first_maintenance_state, next_state_after_configure, next_state_after_firmware, + next_state_after_nvos, profile_hardware_type_or_any, }; fn test_machine_id(seed: u8) -> MachineId { @@ -2537,6 +2526,42 @@ mod tests { } } + #[test] + fn switch_device_info_request_uses_descriptor_without_node_type() { + let mut profile = RackProfile { + product_family: Some(RackProductFamily::Gb200), + ..Default::default() + }; + + profile.rack_capabilities.switch.vendor = Some("test-switch-vendor".to_string()); + + let node_identity = switch_node_identity_for_profile(&profile).unwrap(); + let rack_id = RackId::from("rack-1"); + let switches = [test_device_info("switch-1")]; + + let request = build_switch_device_info_request(&rack_id, &switches, &node_identity); + + let [node] = request + .nodes + .expect("request nodes") + .nodes + .try_into() + .unwrap(); + + let descriptor = node.node_descriptor.expect("node descriptor"); + + assert_eq!(node.r#type, None); + + assert_eq!( + descriptor.attributes, + std::collections::HashMap::from([ + ("role".to_string(), "switch".to_string()), + ("vendor".to_string(), "test-switch-vendor".to_string()), + ("product_family".to_string(), "gb200".to_string()), + ]) + ); + } + #[test] fn profile_hardware_type_or_any_defaults_missing_values_to_any() { assert_eq!(profile_hardware_type_or_any(None), "any"); diff --git a/crates/rack/src/firmware_update.rs b/crates/rack/src/firmware_update.rs index 7d2e571ff6..4008a5d56d 100644 --- a/crates/rack/src/firmware_update.rs +++ b/crates/rack/src/firmware_update.rs @@ -28,7 +28,7 @@ use model::rack::FirmwareUpgradeDeviceInfo; use model::rack_type::{RackHardwareClass, RackProfile}; use sqlx::PgPool; -use crate::rms_node_type::is_switch_node_type; +use crate::rms_node_type::RmsNodeIdentity; #[derive(Debug, Clone)] pub struct RackFirmwareInventory { @@ -253,7 +253,7 @@ async fn fetch_nvos_credentials( pub fn build_new_node_info( rack_id: &RackId, device: &FirmwareUpgradeDeviceInfo, - node_type: rms::NodeType, + identity: &RmsNodeIdentity, ) -> rms::NodeInfo { let bmc_endpoint = if device.bmc_ip.is_empty() || device.mac.is_empty() { None @@ -266,13 +266,10 @@ pub fn build_new_node_info( }), port: 443, credentials: user_pass_credentials(&device.bmc_username, &device.bmc_password), - // TODO: we'll need to remove this from the RMS proto `Endpoint` field. This field - // should not be set by the caller, and should be owned by the RMS. - dangerously_accept_invalid_certs: true, }) }; - let host_endpoint = if is_switch_node_type(node_type) { + let host_endpoint = if identity.is_switch() { Some(rms::Endpoint { interface: build_host_interface(device), port: 0, @@ -280,21 +277,22 @@ pub fn build_new_node_info( device.os_username.as_deref().unwrap_or_default(), device.os_password.as_deref().unwrap_or_default(), ), - // TODO: we'll need to remove this from the RMS proto `Endpoint` field. This field - // should not be set by the caller, and should be owned by the RMS. - dangerously_accept_invalid_certs: true, }) } else { None }; - rms::NodeInfo { + let mut node = rms::NodeInfo { node_id: device.node_id.clone(), rack_id: rack_id.to_string(), - r#type: Some(node_type as i32), + r#type: None, bmc_endpoint, host_endpoint, - } + node_descriptor: None, + }; + + identity.apply_to_node_info(&mut node); + node } fn build_host_interface(device: &FirmwareUpgradeDeviceInfo) -> Option { diff --git a/crates/rack/src/rms_client.rs b/crates/rack/src/rms_client.rs index 3c0dda0547..4e7dc47e76 100644 --- a/crates/rack/src/rms_client.rs +++ b/crates/rack/src/rms_client.rs @@ -753,6 +753,7 @@ pub mod test_support { node_id: node.node_id.clone(), rack_id: node.rack_id.clone(), r#type: node.r#type.unwrap_or(0), + node_descriptor: node.node_descriptor.clone(), ..Default::default() }); } @@ -1003,12 +1004,7 @@ pub mod test_support { ) -> Result { Ok(rms::PushSwitchFirmwareResponse::default()) } - async fn upgrade_switch_firmware( - &self, - _cmd: rms::UpgradeSwitchFirmwareRequest, - ) -> Result { - Ok(rms::UpgradeSwitchFirmwareResponse::default()) - } + async fn configure_scale_up_fabric_manager( &self, cmd: rms::ConfigureScaleUpFabricManagerRequest, @@ -1124,12 +1120,7 @@ pub mod test_support { async fn get_version(&self) -> Result { Ok(rms::GetVersionResponse::default()) } - async fn poll_switch_firmware_job_status( - &self, - _cmd: rms::PollSwitchFirmwareJobStatusRequest, - ) -> Result { - Ok(rms::PollSwitchFirmwareJobStatusResponse::default()) - } + async fn update_firmware( &self, _cmd: rms::UpdateFirmwareRequest, diff --git a/crates/rack/src/rms_node_type.rs b/crates/rack/src/rms_node_type.rs index dd05b5e2d1..4c80ffd4b7 100644 --- a/crates/rack/src/rms_node_type.rs +++ b/crates/rack/src/rms_node_type.rs @@ -15,197 +15,250 @@ * limitations under the License. */ -use librms::protos::rack_manager as rms; -use model::rack_type::{RackProductFamily, RackProfile}; +//! Builds RMS node identity from NICo rack profiles. +//! +//! NICo validates only descriptor inputs: a non-empty role-specific vendor and +//! product family. RMS remains responsible for deciding whether a descriptor is +//! supported. A legacy [`rms::NodeType`] is included only for combinations known +//! to this NICo version and never limits descriptor construction. -/// Power shelf vendors represented by RMS node type variants. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum PowerShelfVendor { - /// LiteOn power shelf hardware. - Liteon, - /// Delta power shelf hardware. - Delta, -} +use std::collections::HashMap; -/// Error returned when local data cannot resolve an RMS node type. +use librms::protos::rack_manager as rms; +use model::rack_type::RackProfile; + +const KEY_ROLE: &str = "role"; +const KEY_VENDOR: &str = "vendor"; +const KEY_PRODUCT_FAMILY: &str = "product_family"; +const ROLE_COMPUTE: &str = "compute"; +const ROLE_SWITCH: &str = "switch"; +const ROLE_POWER_SHELF: &str = "power_shelf"; + +/// Error returned when a rack profile lacks data required for an RMS descriptor. +/// +/// These errors describe missing local inputs, not unsupported hardware. RMS +/// validates the resulting role, vendor, and product-family combination. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] -pub enum NodeTypeError { +pub enum NodeDescriptorError { /// The rack profile does not identify a product family needed by RMS. #[error("rack profile does not identify an RMS product family")] MissingProductFamily, - /// The configured vendor is not supported for the node role. - #[error("RMS does not support {role} vendor {vendor}")] - UnsupportedVendor { role: &'static str, vendor: String }, + + /// The rack profile does not identify a vendor for the node role. + #[error("rack profile does not identify an RMS {role} vendor")] + VendorMissing { role: &'static str }, } -/// Resolves the RMS compute node type for a rack profile. -pub fn compute_node_type_for_profile( - profile: &RackProfile, -) -> Result { - let product_family = profile - .product_family - .ok_or(NodeTypeError::MissingProductFamily)?; - let vendor = profile - .rack_capabilities - .compute - .vendor - .as_deref() - .map(str::trim) - .filter(|vendor| !vendor.is_empty()); - - compute_node_type(product_family, vendor) +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum RmsNodeRole { + Compute, + Switch, + PowerShelf, } -/// Resolves the RMS switch node type for a rack profile. -pub fn switch_node_type_for_profile(profile: &RackProfile) -> Result { - let product_family = profile - .product_family - .ok_or(NodeTypeError::MissingProductFamily)?; - let vendor = profile - .rack_capabilities - .switch - .vendor - .as_deref() - .map(str::trim) - .filter(|vendor| !vendor.is_empty()); - - let Some(vendor) = vendor else { - return Err(NodeTypeError::UnsupportedVendor { - role: "switch", - vendor: String::new(), - }); - }; +impl RmsNodeRole { + fn descriptor_value(self) -> &'static str { + match self { + Self::Compute => ROLE_COMPUTE, + Self::Switch => ROLE_SWITCH, + Self::PowerShelf => ROLE_POWER_SHELF, + } + } - if !is_vendor(vendor, "nvidia") { - return Err(NodeTypeError::UnsupportedVendor { - role: "switch", - vendor: vendor.to_string(), - }); + fn label(self) -> &'static str { + match self { + Self::Compute => "compute", + Self::Switch => "switch", + Self::PowerShelf => "power shelf", + } + } +} + +/// RMS node identity derived from a rack profile and component role. +/// +/// Every identity contains a descriptor with `role`, `vendor`, and +/// `product_family`. Known hardware may also carry a legacy enum override for +/// compatibility with RMS versions that predate descriptor dispatch. +#[derive(Clone, Debug, PartialEq)] +pub struct RmsNodeIdentity { + role: RmsNodeRole, + legacy_node_type: Option, + node_descriptor: rms::NodeDescriptor, +} + +impl RmsNodeIdentity { + /// Applies this identity to an RMS node request. + /// + /// The descriptor is always populated. The legacy `NodeType` is populated + /// only when NICo has an exact compatibility mapping; otherwise it remains + /// unspecified so RMS resolves the descriptor. + pub fn apply_to_node_info(&self, node: &mut rms::NodeInfo) { + node.r#type = self.legacy_node_type.map(|node_type| node_type as i32); + node.node_descriptor = Some(self.node_descriptor.clone()); } - Ok(switch_node_type(product_family)) + /// Returns whether NICo should include switch host endpoint data. + pub(crate) fn is_switch(&self) -> bool { + matches!(self.role, RmsNodeRole::Switch) + } } -/// Resolves the RMS power shelf node type for a rack profile. -pub fn power_shelf_node_type_for_profile( +/// Builds the RMS compute identity for a rack profile. +/// +/// # Errors +/// +/// Returns [`NodeDescriptorError`] when `product_family` or the compute vendor +/// is missing or empty. +pub fn compute_node_identity_for_profile( profile: &RackProfile, -) -> Result { - let product_family = profile - .product_family - .ok_or(NodeTypeError::MissingProductFamily)?; - let vendor = profile - .rack_capabilities - .power_shelf - .vendor - .as_deref() - .map(str::trim) - .filter(|vendor| !vendor.is_empty()); - - let Some(vendor) = vendor else { - return Err(NodeTypeError::UnsupportedVendor { - role: "power shelf", - vendor: String::new(), - }); - }; +) -> Result { + node_identity_for_profile(profile, RmsNodeRole::Compute) +} - let Some(power_shelf_vendor) = supported_power_shelf_vendor(vendor) else { - return Err(NodeTypeError::UnsupportedVendor { - role: "power shelf", - vendor: vendor.to_string(), - }); - }; +/// Builds the RMS switch identity for a rack profile. +/// +/// # Errors +/// +/// Returns [`NodeDescriptorError`] when `product_family` or the switch vendor +/// is missing or empty. +pub fn switch_node_identity_for_profile( + profile: &RackProfile, +) -> Result { + node_identity_for_profile(profile, RmsNodeRole::Switch) +} - Ok(power_shelf_node_type(product_family, power_shelf_vendor)) +/// Builds the RMS power-shelf identity for a rack profile. +/// +/// # Errors +/// +/// Returns [`NodeDescriptorError`] when `product_family` or the power-shelf +/// vendor is missing or empty. +pub fn power_shelf_node_identity_for_profile( + profile: &RackProfile, +) -> Result { + node_identity_for_profile(profile, RmsNodeRole::PowerShelf) } -/// Returns true when an RMS node type represents a switch. -pub(crate) fn is_switch_node_type(node_type: rms::NodeType) -> bool { - // Keep this exhaustive so new RMS node types must be classified when - // librms adds variants. - match node_type { - rms::NodeType::SwitchGb200Nvidia | rms::NodeType::SwitchGb300Nvidia => true, - rms::NodeType::Unspecified - | rms::NodeType::ComputeGb200Nvidia - | rms::NodeType::PowershelfGb200Liteon - | rms::NodeType::PowershelfGb200Delta - | rms::NodeType::ComputeGb300Nvidia - | rms::NodeType::PowershelfGb300Liteon - | rms::NodeType::PowershelfGb300Delta - | rms::NodeType::ComputeGb300Lenovo => false, +/// Builds firmware-object component filters for RMS node identities. +/// +/// The first tuple element contains compatibility filters keyed by legacy +/// `NodeType`; the second contains descriptor-keyed filters for every identity. +/// Empty `components` produces two empty collections. Unsupported legacy +/// combinations are omitted from the first collection without rejecting the +/// descriptor filter. +pub fn firmware_object_component_filters_for_node_identities<'a>( + components: &[String], + node_identities: impl IntoIterator, +) -> ( + HashMap, + Vec, +) { + if components.is_empty() { + return (HashMap::new(), Vec::new()); + } + + let mut component_filters = HashMap::new(); + let mut descriptor_component_filters = Vec::new(); + + for node_identity in node_identities { + let component_filter = rms::FirmwareObjectComponentFilter { + components: components.to_vec(), + }; + + if let Some(node_type) = node_identity.legacy_node_type { + component_filters.insert(node_type as i32, component_filter.clone()); + } + + descriptor_component_filters.push(rms::NodeDescriptorFirmwareObjectComponentFilter { + node_descriptor: Some(node_identity.node_descriptor.clone()), + component_filter: Some(component_filter), + }); } + + (component_filters, descriptor_component_filters) } -fn compute_node_type( - product_family: RackProductFamily, - vendor: Option<&str>, -) -> Result { - let nvidia_node_type = match product_family { - RackProductFamily::Gb200 => rms::NodeType::ComputeGb200Nvidia, - RackProductFamily::Gb300 => rms::NodeType::ComputeGb300Nvidia, +fn node_identity_for_profile( + profile: &RackProfile, + role: RmsNodeRole, +) -> Result { + let Some(product_family) = profile + .product_family + .as_ref() + .map(|family| family.as_str()) + .filter(|family| !family.is_empty()) + else { + return Err(NodeDescriptorError::MissingProductFamily); }; - let Some(vendor) = vendor else { - return Err(NodeTypeError::UnsupportedVendor { - role: "compute", - vendor: String::new(), - }); + let Some(vendor) = vendor_for_role(profile, role) else { + return Err(NodeDescriptorError::VendorMissing { role: role.label() }); }; - if is_vendor(vendor, "nvidia") { - return Ok(nvidia_node_type); - } + let node_descriptor = rms::NodeDescriptor { + attributes: HashMap::from([ + (KEY_ROLE.to_string(), role.descriptor_value().to_string()), + (KEY_VENDOR.to_string(), vendor.to_string()), + (KEY_PRODUCT_FAMILY.to_string(), product_family.to_string()), + ]), + }; - if matches!(product_family, RackProductFamily::Gb300) && is_vendor(vendor, "lenovo") { - return Ok(rms::NodeType::ComputeGb300Lenovo); - } + let legacy_node_type = legacy_node_type(role, product_family, vendor); - Err(NodeTypeError::UnsupportedVendor { - role: "compute", - vendor: vendor.to_string(), - }) -} + tracing::debug!( + role = role.descriptor_value(), + vendor, + product_family, + ?legacy_node_type, + "Built RMS node identity" + ); -fn switch_node_type(product_family: RackProductFamily) -> rms::NodeType { - match product_family { - RackProductFamily::Gb200 => rms::NodeType::SwitchGb200Nvidia, - RackProductFamily::Gb300 => rms::NodeType::SwitchGb300Nvidia, - } + Ok(RmsNodeIdentity { + role, + legacy_node_type, + node_descriptor, + }) } -fn power_shelf_node_type( - product_family: RackProductFamily, - vendor: PowerShelfVendor, -) -> rms::NodeType { - match (product_family, vendor) { - (RackProductFamily::Gb200, PowerShelfVendor::Liteon) => { - rms::NodeType::PowershelfGb200Liteon - } - (RackProductFamily::Gb200, PowerShelfVendor::Delta) => rms::NodeType::PowershelfGb200Delta, - (RackProductFamily::Gb300, PowerShelfVendor::Liteon) => { - rms::NodeType::PowershelfGb300Liteon - } - (RackProductFamily::Gb300, PowerShelfVendor::Delta) => rms::NodeType::PowershelfGb300Delta, +/// Resolves an enum override for RMS servers that predate descriptor dispatch. +/// +/// Unsupported combinations intentionally return `None`; this mapping must +/// never reject a rack profile or override descriptor-based dispatch. +fn legacy_node_type( + role: RmsNodeRole, + product_family: &str, + vendor: &str, +) -> Option { + let product_family = normalize_descriptor_value(product_family); + let vendor = normalize_descriptor_value(vendor); + + match (role, product_family.as_str(), vendor.as_str()) { + (RmsNodeRole::Compute, "gb200", "nvidia") => Some(rms::NodeType::ComputeGb200Nvidia), + (RmsNodeRole::Compute, "gb300", "nvidia") => Some(rms::NodeType::ComputeGb300Nvidia), + (RmsNodeRole::Compute, "gb300", "lenovo") => Some(rms::NodeType::ComputeGb300Lenovo), + (RmsNodeRole::Compute, "vrnvl72", "nvidia") => Some(rms::NodeType::ComputeVrnvl72Nvidia), + (RmsNodeRole::Switch, "gb200", "nvidia") => Some(rms::NodeType::SwitchGb200Nvidia), + (RmsNodeRole::Switch, "gb300", "nvidia") => Some(rms::NodeType::SwitchGb300Nvidia), + (RmsNodeRole::Switch, "vrnvl72", "nvidia") => Some(rms::NodeType::SwitchVrnvl72Nvidia), + (RmsNodeRole::PowerShelf, "gb200", "liteon") => Some(rms::NodeType::PowershelfGb200Liteon), + (RmsNodeRole::PowerShelf, "gb200", "delta") => Some(rms::NodeType::PowershelfGb200Delta), + (RmsNodeRole::PowerShelf, "gb300", "liteon") => Some(rms::NodeType::PowershelfGb300Liteon), + (RmsNodeRole::PowerShelf, "gb300", "delta") => Some(rms::NodeType::PowershelfGb300Delta), + _ => None, } } -fn supported_power_shelf_vendor(vendor: &str) -> Option { - if is_vendor(vendor, "liteon") { - Some(PowerShelfVendor::Liteon) - } else if is_vendor(vendor, "delta") { - Some(PowerShelfVendor::Delta) - } else { - None +fn vendor_for_role(profile: &RackProfile, role: RmsNodeRole) -> Option<&str> { + match role { + RmsNodeRole::Compute => profile.rack_capabilities.compute.vendor.as_deref(), + RmsNodeRole::Switch => profile.rack_capabilities.switch.vendor.as_deref(), + RmsNodeRole::PowerShelf => profile.rack_capabilities.power_shelf.vendor.as_deref(), } + .map(str::trim) + .filter(|vendor| !vendor.is_empty()) } -fn is_vendor(vendor: &str, expected: &str) -> bool { - // Vendors often include spaces, punctuation, or company suffixes. Match at - // the front after compacting those differences so embedded names do not - // classify unrelated vendors as supported. - normalize(vendor).starts_with(&normalize(expected)) -} - -fn normalize(value: &str) -> String { +fn normalize_descriptor_value(value: &str) -> String { value .trim() .to_ascii_lowercase() @@ -214,312 +267,315 @@ fn normalize(value: &str) -> String { #[cfg(test)] mod tests { - use carbide_test_support::Outcome::*; - use carbide_test_support::{Case, check_cases}; - use model::rack_type::RackHardwareTopology; + use model::rack_type::{RackHardwareType, RackProductFamily}; use super::*; - /// Which RMS node-type resolver a table row exercises. - #[derive(Clone, Copy, Debug)] - enum Role { - Compute, - Switch, - PowerShelf, + fn profile_with_product_family(product_family: RackProductFamily) -> RackProfile { + RackProfile { + product_family: Some(product_family), + ..Default::default() + } } - /// One row of the product-family x role x vendor resolution matrix: a profile - /// built from a product family and a per-role vendor string, resolved through - /// the `role`'s `*_for_profile` function. - struct ResolveRow { - product_family: Option, - role: Role, - vendor: Option<&'static str>, + #[test] + fn vendor_matching_trims_outer_whitespace() { + let mut profile = profile_with_product_family(RackProductFamily::Gb200); + profile.rack_capabilities.compute.vendor = Some("\tNVIDIA\n".to_string()); + + let identity = compute_node_identity_for_profile(&profile).unwrap(); + + assert_eq!( + identity.legacy_node_type, + Some(rms::NodeType::ComputeGb200Nvidia) + ); + + assert_eq!( + identity.node_descriptor.attributes.get(KEY_VENDOR), + Some(&"NVIDIA".to_string()) + ); } - /// Build a profile from a row and resolve it through the row's role function. - fn resolve(row: ResolveRow) -> Result { - let mut profile = RackProfile { - product_family: row.product_family, - ..Default::default() - }; - let vendor = row.vendor.map(str::to_string); - match row.role { - Role::Compute => { - profile.rack_capabilities.compute.vendor = vendor; - compute_node_type_for_profile(&profile) - } - Role::Switch => { - profile.rack_capabilities.switch.vendor = vendor; - switch_node_type_for_profile(&profile) - } - Role::PowerShelf => { - profile.rack_capabilities.power_shelf.vendor = vendor; - power_shelf_node_type_for_profile(&profile) - } - } + #[test] + fn arbitrary_product_family_and_vendor_are_descriptor_data() { + let mut profile = profile_with_product_family(RackProductFamily::Other( + " test-product-family ".to_string(), + )); + + profile.rack_capabilities.compute.vendor = Some("test-compute-vendor".to_string()); + + let identity = compute_node_identity_for_profile(&profile).unwrap(); + + assert_eq!(identity.legacy_node_type, None); + + assert_eq!( + identity.node_descriptor.attributes.get(KEY_VENDOR), + Some(&"test-compute-vendor".to_string()) + ); + + assert_eq!( + identity.node_descriptor.attributes.get(KEY_PRODUCT_FAMILY), + Some(&"test-product-family".to_string()) + ); } - /// Construct the `UnsupportedVendor` error a row is expected to fail with. - fn unsupported(role: &'static str, vendor: &str) -> NodeTypeError { - NodeTypeError::UnsupportedVendor { - role, - vendor: vendor.to_string(), - } + #[test] + fn compute_identity_uses_descriptor() { + let mut profile = profile_with_product_family(RackProductFamily::Gb200); + profile.rack_capabilities.compute.vendor = Some("NVIDIA".to_string()); + + let identity = compute_node_identity_for_profile(&profile).unwrap(); + + assert_eq!(identity.role, RmsNodeRole::Compute); + + assert_eq!( + identity.legacy_node_type, + Some(rms::NodeType::ComputeGb200Nvidia) + ); + + assert_eq!( + identity.node_descriptor.attributes.get(KEY_ROLE), + Some(&ROLE_COMPUTE.to_string()) + ); + + assert_eq!( + identity.node_descriptor.attributes.get(KEY_VENDOR), + Some(&"NVIDIA".to_string()) + ); + + assert_eq!( + identity.node_descriptor.attributes.get(KEY_PRODUCT_FAMILY), + Some(&"gb200".to_string()) + ); + + assert_eq!(identity.node_descriptor.attributes.len(), 3); } - fn profile_with_product_family(product_family: RackProductFamily) -> RackProfile { - RackProfile { - product_family: Some(product_family), + #[test] + fn switch_identity_uses_descriptor() { + let mut profile = profile_with_product_family(RackProductFamily::Gb300); + profile.rack_capabilities.switch.vendor = Some("test-switch-vendor".to_string()); + + let identity = switch_node_identity_for_profile(&profile).unwrap(); + + assert_eq!(identity.role, RmsNodeRole::Switch); + + assert_eq!( + identity.node_descriptor.attributes.get(KEY_ROLE), + Some(&ROLE_SWITCH.to_string()) + ); + + assert_eq!( + identity.node_descriptor.attributes.get(KEY_VENDOR), + Some(&"test-switch-vendor".to_string()) + ); + + assert_eq!( + identity.node_descriptor.attributes.get(KEY_PRODUCT_FAMILY), + Some(&"gb300".to_string()) + ); + } + + #[test] + fn apply_to_node_info_sets_descriptor_and_legacy_node_type() { + let mut profile = profile_with_product_family(RackProductFamily::Gb300); + profile.rack_capabilities.power_shelf.vendor = Some("Delta".to_string()); + let identity = power_shelf_node_identity_for_profile(&profile).unwrap(); + + let mut node = rms::NodeInfo { + r#type: Some(1), ..Default::default() - } + }; + + identity.apply_to_node_info(&mut node); + + assert_eq!( + node.r#type, + Some(rms::NodeType::PowershelfGb300Delta as i32) + ); + + assert_eq!(node.node_descriptor, Some(identity.node_descriptor)); } #[test] - fn resolves_the_product_family_role_vendor_matrix() { - use RackProductFamily::{Gb200, Gb300}; - - check_cases( - [ - // Compute: NVIDIA on every family; Lenovo only on GB300. - Case { - scenario: "compute gb200 nvidia", - input: ResolveRow { - product_family: Some(Gb200), - role: Role::Compute, - vendor: Some("NVIDIA"), - }, - expect: Yields(rms::NodeType::ComputeGb200Nvidia), - }, - Case { - scenario: "compute gb300 nvidia", - input: ResolveRow { - product_family: Some(Gb300), - role: Role::Compute, - vendor: Some("NVIDIA"), - }, - expect: Yields(rms::NodeType::ComputeGb300Nvidia), - }, - Case { - scenario: "compute gb300 lenovo", - input: ResolveRow { - product_family: Some(Gb300), - role: Role::Compute, - vendor: Some("Lenovo"), - }, - expect: Yields(rms::NodeType::ComputeGb300Lenovo), - }, - Case { - scenario: "compute gb200 lenovo is unsupported (lenovo is gb300-only)", - input: ResolveRow { - product_family: Some(Gb200), - role: Role::Compute, - vendor: Some("Lenovo"), - }, - expect: FailsWith(unsupported("compute", "Lenovo")), - }, - Case { - scenario: "compute missing vendor", - input: ResolveRow { - product_family: Some(Gb200), - role: Role::Compute, - vendor: None, - }, - expect: FailsWith(unsupported("compute", "")), - }, - Case { - scenario: "compute unsupported vendor", - input: ResolveRow { - product_family: Some(Gb200), - role: Role::Compute, - vendor: Some("Other"), - }, - expect: FailsWith(unsupported("compute", "Other")), - }, - Case { - scenario: "compute missing product family", - input: ResolveRow { - product_family: None, - role: Role::Compute, - vendor: Some("NVIDIA"), - }, - expect: FailsWith(NodeTypeError::MissingProductFamily), - }, - // Switch: NVIDIA on every family; anything else unsupported. - Case { - scenario: "switch gb200 nvidia", - input: ResolveRow { - product_family: Some(Gb200), - role: Role::Switch, - vendor: Some("NVIDIA"), - }, - expect: Yields(rms::NodeType::SwitchGb200Nvidia), - }, - Case { - scenario: "switch gb300 nvidia", - input: ResolveRow { - product_family: Some(Gb300), - role: Role::Switch, - vendor: Some("NVIDIA"), - }, - expect: Yields(rms::NodeType::SwitchGb300Nvidia), - }, - Case { - scenario: "switch missing vendor", - input: ResolveRow { - product_family: Some(Gb200), - role: Role::Switch, - vendor: None, - }, - expect: FailsWith(unsupported("switch", "")), - }, - Case { - scenario: "switch unsupported vendor", - input: ResolveRow { - product_family: Some(Gb200), - role: Role::Switch, - vendor: Some("Other"), - }, - expect: FailsWith(unsupported("switch", "Other")), - }, - Case { - scenario: "switch missing product family", - input: ResolveRow { - product_family: None, - role: Role::Switch, - vendor: Some("NVIDIA"), - }, - expect: FailsWith(NodeTypeError::MissingProductFamily), - }, - // Power shelf: LiteOn and Delta on every family; anything else unsupported. - Case { - scenario: "power shelf gb200 liteon", - input: ResolveRow { - product_family: Some(Gb200), - role: Role::PowerShelf, - vendor: Some("LiteOn"), - }, - expect: Yields(rms::NodeType::PowershelfGb200Liteon), - }, - Case { - scenario: "power shelf gb200 delta", - input: ResolveRow { - product_family: Some(Gb200), - role: Role::PowerShelf, - vendor: Some("Delta"), - }, - expect: Yields(rms::NodeType::PowershelfGb200Delta), - }, - Case { - scenario: "power shelf gb300 liteon", - input: ResolveRow { - product_family: Some(Gb300), - role: Role::PowerShelf, - vendor: Some("LiteOn"), - }, - expect: Yields(rms::NodeType::PowershelfGb300Liteon), - }, - Case { - scenario: "power shelf gb300 delta", - input: ResolveRow { - product_family: Some(Gb300), - role: Role::PowerShelf, - vendor: Some("Delta"), - }, - expect: Yields(rms::NodeType::PowershelfGb300Delta), - }, - Case { - scenario: "power shelf missing vendor", - input: ResolveRow { - product_family: Some(Gb200), - role: Role::PowerShelf, - vendor: None, - }, - expect: FailsWith(unsupported("power shelf", "")), - }, - Case { - scenario: "power shelf unsupported vendor", - input: ResolveRow { - product_family: Some(Gb200), - role: Role::PowerShelf, - vendor: Some("Other"), - }, - expect: FailsWith(unsupported("power shelf", "Other")), - }, - Case { - scenario: "power shelf missing product family", - input: ResolveRow { - product_family: None, - role: Role::PowerShelf, - vendor: Some("LiteOn"), - }, - expect: FailsWith(NodeTypeError::MissingProductFamily), - }, - ], - resolve, + fn component_filters_include_descriptor_and_legacy_node_type() { + let mut profile = profile_with_product_family(RackProductFamily::Gb300); + profile.rack_capabilities.switch.vendor = Some("NVIDIA".to_string()); + let identity = switch_node_identity_for_profile(&profile).unwrap(); + + let (component_filters, descriptor_filters) = + firmware_object_component_filters_for_node_identities( + &["BMC".to_string()], + [&identity], + ); + + assert_eq!(component_filters.len(), 1); + assert_eq!(descriptor_filters.len(), 1); + + assert_eq!( + component_filters + .get(&(rms::NodeType::SwitchGb300Nvidia as i32)) + .map(|filter| filter.components.as_slice()), + Some(["BMC".to_string()].as_slice()) + ); + + assert_eq!( + descriptor_filters[0] + .component_filter + .as_ref() + .map(|filter| filter.components.as_slice()), + Some(["BMC".to_string()].as_slice()) ); } #[test] - fn is_switch_node_type_matches_switch_variants_only() { - let switch_types = [ - rms::NodeType::SwitchGb200Nvidia, - rms::NodeType::SwitchGb300Nvidia, - ]; + fn vrnvl72_power_shelf_uses_descriptor_without_legacy_node_type() { + let mut profile = + profile_with_product_family(RackProductFamily::Other("vrnvl72".to_string())); - for node_type in switch_types { - assert!(is_switch_node_type(node_type)); - } + profile.rack_capabilities.power_shelf.vendor = Some("Delta".to_string()); + + let identity = power_shelf_node_identity_for_profile(&profile).unwrap(); - let non_switch_types = [ - rms::NodeType::Unspecified, - rms::NodeType::ComputeGb200Nvidia, - rms::NodeType::PowershelfGb200Liteon, - rms::NodeType::PowershelfGb200Delta, - rms::NodeType::ComputeGb300Nvidia, - rms::NodeType::PowershelfGb300Liteon, - rms::NodeType::PowershelfGb300Delta, - rms::NodeType::ComputeGb300Lenovo, + assert_eq!(identity.legacy_node_type, None); + + let mut node = rms::NodeInfo::default(); + identity.apply_to_node_info(&mut node); + + assert_eq!(node.r#type, None); + + assert_eq!( + node.node_descriptor.as_ref(), + Some(&identity.node_descriptor) + ); + + let (component_filters, descriptor_filters) = + firmware_object_component_filters_for_node_identities( + &["BMC".to_string()], + [&identity], + ); + + assert!(component_filters.is_empty()); + assert_eq!(descriptor_filters.len(), 1); + } + + #[test] + fn legacy_node_type_matches_exact_supported_matrix() { + let cases = [ + ( + RmsNodeRole::Compute, + "gb200", + "NVIDIA", + Some(rms::NodeType::ComputeGb200Nvidia), + ), + ( + RmsNodeRole::Compute, + "gb300", + "NVIDIA", + Some(rms::NodeType::ComputeGb300Nvidia), + ), + ( + RmsNodeRole::Compute, + "gb300", + "Lenovo", + Some(rms::NodeType::ComputeGb300Lenovo), + ), + ( + RmsNodeRole::Compute, + "vr_nvl72", + "NVIDIA", + Some(rms::NodeType::ComputeVrnvl72Nvidia), + ), + ( + RmsNodeRole::Switch, + "gb200", + "NVIDIA", + Some(rms::NodeType::SwitchGb200Nvidia), + ), + ( + RmsNodeRole::Switch, + "gb300", + "NVIDIA", + Some(rms::NodeType::SwitchGb300Nvidia), + ), + ( + RmsNodeRole::Switch, + "vr_nvl72", + "NVIDIA", + Some(rms::NodeType::SwitchVrnvl72Nvidia), + ), + ( + RmsNodeRole::PowerShelf, + "gb200", + "LiteOn", + Some(rms::NodeType::PowershelfGb200Liteon), + ), + ( + RmsNodeRole::PowerShelf, + "gb200", + "Delta", + Some(rms::NodeType::PowershelfGb200Delta), + ), + ( + RmsNodeRole::PowerShelf, + "gb300", + "LiteOn", + Some(rms::NodeType::PowershelfGb300Liteon), + ), + ( + RmsNodeRole::PowerShelf, + "gb300", + "Delta", + Some(rms::NodeType::PowershelfGb300Delta), + ), + (RmsNodeRole::PowerShelf, "vrnvl72", "Delta", None), + (RmsNodeRole::Compute, "vrnvl144", "NVIDIA", None), + (RmsNodeRole::Compute, "gb200", "NVIDIA Corp", None), ]; - for node_type in non_switch_types { - assert!(!is_switch_node_type(node_type)); + for (role, product_family, vendor, expected) in cases { + assert_eq!( + legacy_node_type(role, product_family, vendor), + expected, + "role={role:?}, product_family={product_family}, vendor={vendor}" + ); } } #[test] - fn product_family_not_topology_selects_node_type() { - let mut profile = profile_with_product_family(RackProductFamily::Gb300); - profile.rack_hardware_topology = Some(RackHardwareTopology::Gb200Nvl72r1C2g4Topology); - profile.rack_capabilities.switch.vendor = Some("NVIDIA".to_string()); + fn identity_requires_product_family_even_with_hardware_type() { + let mut profile = RackProfile { + rack_hardware_type: Some(RackHardwareType("test-hardware-type".to_string())), + ..Default::default() + }; - let node_type = switch_node_type_for_profile(&profile); + profile.rack_capabilities.compute.vendor = Some("test-compute-vendor".to_string()); - assert_eq!(node_type, Ok(rms::NodeType::SwitchGb300Nvidia)); + let err = compute_node_identity_for_profile(&profile); + + assert_eq!(err, Err(NodeDescriptorError::MissingProductFamily)); } #[test] - fn vendor_matching_trims_outer_whitespace() { - let mut profile = profile_with_product_family(RackProductFamily::Gb200); - profile.rack_capabilities.compute.vendor = Some("\tNVIDIA\n".to_string()); + fn identity_rejects_blank_programmatic_product_family() { + let mut profile = profile_with_product_family(RackProductFamily::Other(" \t ".to_string())); + profile.rack_capabilities.compute.vendor = Some("NVIDIA".to_string()); - let node_type = compute_node_type_for_profile(&profile); + let err = compute_node_identity_for_profile(&profile); - assert_eq!(node_type, Ok(rms::NodeType::ComputeGb200Nvidia)); + assert_eq!(err, Err(NodeDescriptorError::MissingProductFamily)); } #[test] - fn embedded_vendor_name_does_not_match() { - let mut profile = profile_with_product_family(RackProductFamily::Gb200); - profile.rack_capabilities.compute.vendor = Some("Not NVIDIA".to_string()); + fn identity_requires_role_vendor() { + let profile = profile_with_product_family(RackProductFamily::Gb200); - let err = compute_node_type_for_profile(&profile); + let err = power_shelf_node_identity_for_profile(&profile); assert_eq!( err, - Err(NodeTypeError::UnsupportedVendor { - role: "compute", - vendor: "Not NVIDIA".to_string() + Err(NodeDescriptorError::VendorMissing { + role: "power shelf", }) ); } diff --git a/crates/rpc/src/model/rack_type.rs b/crates/rpc/src/model/rack_type.rs index 66696fedb4..7d8841f67f 100644 --- a/crates/rpc/src/model/rack_type.rs +++ b/crates/rpc/src/model/rack_type.rs @@ -35,11 +35,12 @@ impl From for RackHardwareType { } } -impl From for rpc::forge::RackProductFamily { - fn from(value: RackProductFamily) -> Self { +impl From<&RackProductFamily> for rpc::forge::RackProductFamily { + fn from(value: &RackProductFamily) -> Self { match value { RackProductFamily::Gb200 => rpc::forge::RackProductFamily::Gb200, RackProductFamily::Gb300 => rpc::forge::RackProductFamily::Gb300, + RackProductFamily::Other(_) => rpc::forge::RackProductFamily::Unspecified, } } } @@ -203,7 +204,8 @@ impl From<&RackProfile> for rpc::forge::RackProfile { capabilities: Some((&value.rack_capabilities).into()), product_family: value .product_family - .map(|p| rpc::forge::RackProductFamily::from(p) as i32) + .as_ref() + .map(|family| rpc::forge::RackProductFamily::from(family) as i32) .unwrap_or(rpc::forge::RackProductFamily::Unspecified as i32), } } @@ -240,11 +242,11 @@ mod tests { ] .map(|row| Case { scenario: row.scenario, - input: (row.model, row.proto), + input: (row.model.clone(), row.proto), expect: Yields(row.model), }), |(model, proto)| { - let converted: rpc::forge::RackProductFamily = model.into(); + let converted: rpc::forge::RackProductFamily = (&model).into(); assert_eq!(converted, proto); RackProductFamily::try_from(proto).map_err(drop) @@ -262,6 +264,16 @@ mod tests { .check(|proto| RackProductFamily::try_from(proto).map_err(drop)); } + #[test] + fn test_arbitrary_rack_product_family_projects_to_unspecified() { + let family = RackProductFamily::Other("test-product-family".to_string()); + + assert_eq!( + rpc::forge::RackProductFamily::from(&family), + rpc::forge::RackProductFamily::Unspecified + ); + } + // Each topology round-trips: model -> proto matches the expected proto, and the // proto -> model TryFrom yields the original model. The op asserts the forward // direction, then yields the recovered model so the row pins both directions. @@ -412,6 +424,7 @@ mod tests { proto.product_family, rpc::forge::RackProductFamily::Gb200 as i32 ); + assert_eq!(proto.rack_hardware_type.unwrap().value, "dsx_gb200nvl_72x1"); assert_eq!( proto.rack_hardware_topology, @@ -449,6 +462,7 @@ mod tests { proto.product_family, rpc::forge::RackProductFamily::Unspecified as i32 ); + assert_eq!(proto.rack_hardware_type, None); assert_eq!( proto.rack_hardware_topology, diff --git a/crates/site-explorer/src/machine_creator.rs b/crates/site-explorer/src/machine_creator.rs index 0651178489..8f11a09037 100644 --- a/crates/site-explorer/src/machine_creator.rs +++ b/crates/site-explorer/src/machine_creator.rs @@ -16,7 +16,7 @@ */ use std::sync::Arc; -use carbide_rack::rms_node_type::compute_node_type_for_profile; +use carbide_rack::rms_node_type::compute_node_identity_for_profile; use carbide_secrets::credentials::{ BmcCredentialType, CredentialKey, CredentialManager, Credentials, }; @@ -329,7 +329,7 @@ impl MachineCreator { self.reconcile_host_admin_addresses(&mut txn, &host_machine_id) .await?; - let rms_node_type = if let (Some(rack_id), Some(_)) = + let rms_node_identity = if let (Some(rack_id), Some(_)) = (&expected_machine.data.rack_id, &self.rms_client) { let Some(rack_profile_id) = rack_profile_id.as_ref() else { @@ -345,7 +345,7 @@ impl MachineCreator { }; Some( - compute_node_type_for_profile(rack_profile) + compute_node_identity_for_profile(rack_profile) .map_err(|error| SiteExplorerError::InvalidArgument(error.to_string()))?, ) } else { @@ -354,36 +354,37 @@ impl MachineCreator { txn.commit().await?; - if let (Some(rack_id), Some(rms_client), Some(node_type)) = ( + if let (Some(rack_id), Some(rms_client), Some(node_identity)) = ( &expected_machine.data.rack_id, &self.rms_client, - rms_node_type, + rms_node_identity, ) { - let request = rms::BatchGetNodeDeviceInfoRequest { - nodes: Some(rms::NodeSet { - nodes: vec![rms::NodeInfo { - node_id: host_machine_id.to_string(), - rack_id: rack_id.to_string(), - r#type: Some(node_type as i32), - bmc_endpoint: Some(rms::Endpoint { - interface: Some(rms::NetworkInterface { - ip_address: explored_host.host_bmc_ip.to_string(), - mac_address: expected_machine.bmc_mac_address.to_string(), - host_name: None, - }), - port: 443, - credentials: bmc_credentials.map(|(username, password)| { - rms::Credentials { - auth: Some(rms::credentials::Auth::UserPass( - rms::UsernamePassword { username, password }, - )), - } - }), - dangerously_accept_invalid_certs: true, - }), - ..Default::default() - }], + let mut node = rms::NodeInfo { + node_id: host_machine_id.to_string(), + rack_id: rack_id.to_string(), + r#type: None, + node_descriptor: None, + bmc_endpoint: Some(rms::Endpoint { + interface: Some(rms::NetworkInterface { + ip_address: explored_host.host_bmc_ip.to_string(), + mac_address: expected_machine.bmc_mac_address.to_string(), + host_name: None, + }), + port: 443, + credentials: bmc_credentials.map(|(username, password)| rms::Credentials { + auth: Some(rms::credentials::Auth::UserPass(rms::UsernamePassword { + username, + password, + })), + }), }), + ..Default::default() + }; + + node_identity.apply_to_node_info(&mut node); + + let request = rms::BatchGetNodeDeviceInfoRequest { + nodes: Some(rms::NodeSet { nodes: vec![node] }), }; let (slot_number, tray_index) = crate::fetch_slot_and_tray(rms_client.as_ref(), request).await; diff --git a/crates/site-explorer/tests/integration/machine_creator.rs b/crates/site-explorer/tests/integration/machine_creator.rs index 421e7a1fd9..aca62f1b94 100644 --- a/crates/site-explorer/tests/integration/machine_creator.rs +++ b/crates/site-explorer/tests/integration/machine_creator.rs @@ -51,6 +51,11 @@ use rpc::forge::forge_server::Forge; use rpc::{DiscoveryData, DiscoveryInfo, MachineDiscoveryInfo}; use tonic::Request; +const KEY_PRODUCT_FAMILY: &str = "product_family"; +const KEY_ROLE: &str = "role"; +const KEY_VENDOR: &str = "vendor"; +const ROLE_COMPUTE: &str = "compute"; + struct ExploredHostFixture { host: ExploredManagedHost, host_report: EndpointExplorationReport, @@ -237,8 +242,29 @@ async fn test_machine_creator_compute_rms_request_uses_rack_profile( }; assert_eq!(node.rack_id, rack_id.to_string()); + assert_eq!(node.r#type, Some(rms::NodeType::ComputeGb200Nvidia as i32)); + let descriptor = node.node_descriptor.as_ref().expect("node descriptor"); + + assert_eq!( + descriptor.attributes.get(KEY_ROLE).map(String::as_str), + Some(ROLE_COMPUTE) + ); + + assert_eq!( + descriptor.attributes.get(KEY_VENDOR).map(String::as_str), + Some("NVIDIA") + ); + + assert_eq!( + descriptor + .attributes + .get(KEY_PRODUCT_FAMILY) + .map(String::as_str), + Some("gb200") + ); + Ok(()) } diff --git a/dev/mac-local-dev/README.md b/dev/mac-local-dev/README.md index b9c3998acb..d2ce117bf7 100644 --- a/dev/mac-local-dev/README.md +++ b/dev/mac-local-dev/README.md @@ -67,21 +67,23 @@ grpcurl -insecure localhost:1079 list open https://localhost:1079/admin ``` -### RMS node type resolution - -When testing RMS component-manager backends, configure the local rack profile so -`product_family` is set to `gb200` or `gb300`. This field is required for -RMS-backed operations and must exactly match the lowercase value; it is not -normalized. The component-manager backend fields default to `rms`, so set any -role you are not testing to a non-RMS backend. When a backend is set to `rms`, -the matching vendor field in each configured profile is required for startup -validation. - -Recommended vendor values are `NVIDIA` or `Lenovo` for compute, `NVIDIA` for -switches, and `LiteOn` or `Delta` for power shelves. Vendor matching is -case-insensitive and ignores spaces, hyphens, and underscores, so values like -`nvidia`, `Lite-On`, and `lite_on` are accepted. The rack's `rack_profile_id` -must match a key in `[rack_profiles]`. +### RMS node descriptors + +When testing RMS component-manager backends, configure a non-empty +`product_family` in each local rack profile and a non-empty vendor for each role +using RMS. NICo trims outer whitespace and preserves the remaining identifier. +It sends `role`, `vendor`, and `product_family` in an RMS `NodeDescriptor` +on every request. For exact combinations represented by the current RMS +`NodeType` enum, NICo also sends that enum and legacy firmware-filter entries +for compatibility with older RMS servers. Other combinations, including +VRNVL72 power shelves, remain descriptor-only. Legacy mapping is best effort +and never rejects startup; RMS evaluates descriptor support when an operation +runs. + +The component-manager backend fields default to `rms`, so set any role you are +not testing to a non-RMS backend. The rack's `rack_profile_id` must match a key +in `[rack_profiles]`. Descriptor-based requests require an RMS version that +supports `NodeDescriptor`. The examples below only show the component-manager and rack-profile fields. Configure local `[rms]` settings separately when NICo needs to call RMS. diff --git a/docs/configuration/component-manager-rms.md b/docs/configuration/component-manager-rms.md index db1d0ef111..c7ce9a647c 100644 --- a/docs/configuration/component-manager-rms.md +++ b/docs/configuration/component-manager-rms.md @@ -2,17 +2,17 @@ Operator guide for configuring **Rack Manager Service (RMS)** backends in the `[component_manager]` section of `nico-api` site config, and the **rack profile** -data those backends require for node type resolution. +data those backends require for node descriptors. `[component_manager]` manages compute trays, NVLink switches, and power shelves. -When a role's backend is set to `rms`, NICo resolves the RMS node type from the -rack profile. If a configured -rack profile definition is missing required fields or is ambiguous, `nico-api` -**fails configuration validation at startup**. Per-rack `rack_profile_id` -assignments are not checked at startup. Those errors surface at runtime when an -RMS operation runs (refer to [Startup validation](#startup-validation)). - -Canonical field reference: [`crates/api-core/src/cfg/README.md`](https://github.com/NVIDIA/infra-controller/tree/main/crates/api-core/src/cfg/README.md). +When a role's backend is set to `rms`, NICo builds an RMS `NodeDescriptor` from +the rack profile. If a configured rack profile is missing required fields, +`nico-api` **fails configuration validation at startup**. Per-rack +`rack_profile_id` assignments are not checked at startup. Those errors surface +at runtime when an RMS operation runs (refer to +[Startup validation](#startup-validation)). + +Canonical field reference: [`crates/api-core/src/cfg/README.md`](https://github.com/NVIDIA/infra-controller/blob/main/crates/api-core/src/cfg/README.md). Configure the `[rms]` block (mTLS connectivity to the external RMS) separately; the examples on this page cover the component-manager and rack-profile fields. @@ -20,13 +20,58 @@ the examples on this page cover the component-manager and rack-profile fields. ## What the rack profile provides -For RMS component-manager backends, the rack profile supplies two facts: +For RMS component-manager backends, NICo sends a descriptor containing three +attributes: -- **Product family**: `product_family`. Required for RMS-backed operations; - currently accepts `gb200` or `gb300`. +- **Role**: Derived from the operation as `compute`, `switch`, or `power_shelf`. +- **Product family**: Taken from `product_family`. - **Vendor**: `rack_capabilities..vendor`, for each role using an RMS backend. +NICo trims outer whitespace from product-family and vendor values and requires +both to be non-empty. Case and internal punctuation are preserved after +trimming. NICo does not map these values to a supported-hardware list. RMS +validates each role/vendor/product-family combination when a request is made. + +For product families other than `gb200` and `gb300`, the `GetRackProfile` +`product_family` enum is `UNSPECIFIED`. The configured string remains available +to descriptor-based RMS operations. + +NICo always sends descriptor-based RMS requests. For exact role, vendor, and +product-family combinations represented by the current RMS `NodeType` enum, +NICo also sends that enum and legacy firmware-filter entries for compatibility +with older RMS servers. Other combinations leave `NodeType` unset and require +RMS support for `NodeDescriptor`. This best-effort legacy mapping does not +participate in startup validation. VRNVL72 power shelves are descriptor-only +because no matching legacy `NodeType` exists. + +## Supported RMS descriptor combinations + +RMS accepts these role, vendor, and product-family combinations: + +| `product_family` | Role | Supported vendor | +| ---------------- | ---- | ---------------- | +| `gb200` | `compute` | `nvidia` | +| `gb200` | `switch` | `nvidia` | +| `gb200` | `power_shelf` | `liteon`, `delta` | +| `gb300` | `compute` | `nvidia`, `lenovo` | +| `gb300` | `switch` | `nvidia` | +| `gb300` | `power_shelf` | `liteon`, `delta` | +| `vrnvl72` | `compute` | `nvidia` | +| `vrnvl72` | `switch` | `nvidia` | +| `vrnvl72` | `power_shelf` | `liteon`, `delta` | + +RMS compares normalized values: matching is case-insensitive and ignores +spaces, hyphens, and underscores. For example, `Lite-On` and `LiteOn` are +equivalent, as are `vr_nvl72` and `vrnvl72`. After normalization, RMS compares +full values rather than prefixes, so `NVIDIACorp` does not match `NVIDIA`. + +VRNVL72 power shelves use the GB200 LiteOn or Delta internal implementation +after descriptor resolution. RMS returns `INVALID_ARGUMENT` when no descriptor +rule matches. NICo accepts other non-empty values at startup; RMS validates +support when it receives a request. Consult the hardware compatibility list for +the deployed RMS version when NICo and RMS versions differ. + ## Startup validation NICo validates configured rack profiles at startup **when any component-manager @@ -47,30 +92,11 @@ a key under `[rack_profiles]`. Startup validation does not scan existing rack database rows, so missing or unknown per-rack profile IDs are still checked when an RMS operation runs. -## Canonical vendor names - -Use these vendor names in config: - -| Role | Canonical values | -| --- | --- | -| Compute, when `compute_tray_backend = "rms"` | `NVIDIA`; `Lenovo` (GB300; not valid for GB200) | -| Switch, when `nv_switch_backend = "rms"` | `NVIDIA` | -| Power shelf, when `power_shelf_backend = "rms"` | `LiteOn`, `Delta` | - -`product_family` is **not normalized**. It must exactly match one of the accepted -lowercase values (`gb200`, `gb300`); values like `GB200` are rejected. - -Vendor matching is more forgiving: values are trimmed, case-insensitive, and ignore -spaces, hyphens, and underscores, so `NVIDIA`, `nvidia`, `LiteOn`, `liteon`, -`Lite-On`, and `lite_on` all work. Common company-suffix text also works when the -normalized value starts with the canonical vendor, but the canonical values above -are preferred for operator-supplied config. - --- ## Examples -### GB200 rack, all component-manager roles use RMS +### GB200 rack with RMS for compute, switch, and power shelf ```toml [component_manager] @@ -143,17 +169,17 @@ vendor = "Lite-On" | Field | Accepted values | | ----- | --------------- | -| `product_family`, when an RMS-backed operation uses the profile | Exact match: `gb200`, `gb300` | -| `rack_hardware_topology` | `gb200_nvl36r1_c2g4_topology`, `gb200_nvl72r1_c2g4_topology`, `gb300_nvl36r1_c2g4_topology`, `gb300_nvl72r1_c2g4_topology` | -| Compute profile vendor, when `compute_tray_backend = "rms"` | `nvidia`, `lenovo` after normalization (`lenovo` requires `product_family = "gb300"`; GB200 compute accepts `nvidia`) | -| Switch profile vendor, when `nv_switch_backend = "rms"` | `nvidia` after normalization | -| Power shelf profile vendor, when `power_shelf_backend = "rms"` | `liteon`, `delta` after normalization | +| `product_family`, when an RMS-backed operation uses the profile | Non-empty string; RMS validates support at request time | +| `rack_hardware_topology` | `gb200_nvl36r1_c2g4_topology`, `gb200_nvl72r1_c2g4_topology`, `gb300_nvl36r1_c2g4_topology`, `gb300_nvl72r1_c2g4_topology`, `vr_nvl8r1_c2g4_rtf_topology`, `vr_nvl72r1_c2g4_topology` | +| Compute profile vendor, when `compute_tray_backend = "rms"` | Non-empty string; RMS validates support at request time | +| Switch profile vendor, when `nv_switch_backend = "rms"` | Non-empty string; RMS validates support at request time | +| Power shelf profile vendor, when `power_shelf_backend = "rms"` | Non-empty string; RMS validates support at request time | ## Machine ingestion note -The separate site-explorer machine-ingestion path performs an RMS slot/tray lookup -and uses the rack profile for node type resolution. This path runs when **both** -conditions hold: +The separate site-explorer machine-ingestion path performs an RMS slot/tray +lookup and uses the rack profile to build a compute node descriptor. This path +runs when **both** conditions hold: 1. An RMS client is configured (the `[rms]` block is present). 2. The machine has a `rack_id`. diff --git a/docs/manuals/rack_level_admin.md b/docs/manuals/rack_level_admin.md index a0b54708b3..0ddcd9eb67 100644 --- a/docs/manuals/rack_level_admin.md +++ b/docs/manuals/rack_level_admin.md @@ -16,7 +16,18 @@ NICo provides APIs and automated workflows to manage these components for the fo ## Dependencies -In order to use rack-level administration features today, NICo deployment needs to include NICo Flow, NSM, and PSM, properly configured with the REST API, site agent, temporal workflow, and NICo Core. The following diagram shows the control and data flows within NICo services and dependencies. +Rack-level administration does not require one fixed backend stack. NICo Core +must be configured with a component-manager backend for each role being managed: + +- [RMS](../configuration/component-manager-rms.md) can provide compute, switch, + and power-shelf management. +- NSM is required only when the switch backend is set to `nsm`. +- PSM is required only when the power-shelf backend is set to `psm`. + +NICo Flow is required for deployments using the HW Lifecycle REST API and its +workflow orchestration. Clients using NICo Core APIs directly do not require +NICo Flow. The following diagram shows the Flow-based REST deployment and its +control and data paths. ![Dependencies](../static/rack-level-admin-dependencies.svg) @@ -38,9 +49,13 @@ NICo Flow contains software-defined states for managing a site, such as those fo * **Backend**: Previously, NICo Core accessed machines directly via BMC. With rack-scale systems, we now have more types of component HW (compute, switch, and powershelf), as well as more ways to access these components (BMC and NVUE). The complexity of these HW access and management operations are now moved out of NICo Core into the backend for NICo. NICo backend is an extensible interface for different types of hardware to be plugged into and managed by NICo. -Today there is a NVSwitch Manager (NSM) backend and a Powershelf Manager (PSM) backend, providing access to switch and powershelf trays in racks, called from NICo Core. - -In the near future, NVIDIA Rack Manager Service (RMS) will be shipped as a backend for NICo to provide unified compute, switch, and powershelf trays access and management, as well as optimized default HW sequencing for rack power control and firmware update. +NICo Core supports NVSwitch Manager (NSM) and PowerShelf Manager (PSM) +backends for switch and power-shelf access. NVIDIA Rack Manager Service (RMS) +can serve compute, switch, and power-shelf roles and provides rack-level power +and firmware operations. See +[Component Manager RMS Backends](../configuration/component-manager-rms.md) +for RMS configuration requirements and the documented GB200, GB300, and VRNVL72 +role/vendor support matrix. ## Rack-Level Operations @@ -148,5 +163,3 @@ Currently, NICo only supports GB200 NVL72 racks, where a rack and a NVL domain o - [PATCH /v2/org/{org}/carbide/tray/{id}/power](https://docs.nvidia.com/infra-controller/rest-api-reference/api-reference/tray/power-control-tray): Control the power of the specified tray. Supported power states are `on`, `off`, `cycle`, `forceoff`, `forcecycle`. - [PATCH /v2/org/{org}/carbide/tray/firmware](https://docs.nvidia.com/infra-controller/rest-api-reference/api-reference/tray/firmware-update-trays): Update the firmware on all or selected trays in the site. - [PATCH /v2/org/{org}/carbide/tray/{id}/firmware](https://docs.nvidia.com/infra-controller/rest-api-reference/api-reference/tray/firmware-update-tray): Update the firmware on the specified tray. - -