diff --git a/crates/api-core/src/cfg/file.rs b/crates/api-core/src/cfg/file.rs index e5a349b162..5fe5883723 100644 --- a/crates/api-core/src/cfg/file.rs +++ b/crates/api-core/src/cfg/file.rs @@ -1614,7 +1614,15 @@ pub struct DpfResolvedMandatoryServicesConfig { } /// Configuration for a single Helm-based DPF service. +/// +/// All string fields default to the empty string when absent from the config +/// file so that a site-config override can supply only the fields that differ +/// (e.g. only `helm_version`) without triggering a deserialization error for +/// the fields it omits. The caller is responsible for filling any empty fields +/// from the service's compiled-in defaults after deserialization; see +/// [`DpfServiceConfig::fill_from_defaults`]. #[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(default)] pub struct DpfServiceConfig { /// Name of the Helm service. pub name: String, @@ -1631,10 +1639,40 @@ pub struct DpfServiceConfig { /// Secret to use to pull the docker images. `None` when the service pulls from /// a public registry (`dts` and `doca_hbn` default to this); when set, an /// `imagePullSecrets` entry is emitted in the service's Helm values. - #[serde(default)] pub docker_image_pull_secret: Option, } +impl DpfServiceConfig { + /// Fill every empty field in `self` from the corresponding field in + /// `defaults`. This is used after Figment extraction to ensure that a + /// site-config partial override (e.g. only `helm_version`) still produces + /// a fully-populated config: the compiled-in service defaults supply every + /// field that the operator did not explicitly override. + pub fn fill_from_defaults(&mut self, defaults: &DpfServiceConfig) { + if self.name.is_empty() { + self.name = defaults.name.clone(); + } + if self.helm_repo_url.is_empty() { + self.helm_repo_url = defaults.helm_repo_url.clone(); + } + if self.helm_chart.is_empty() { + self.helm_chart = defaults.helm_chart.clone(); + } + if self.helm_version.is_empty() { + self.helm_version = defaults.helm_version.clone(); + } + if self.docker_repo_url.is_empty() { + self.docker_repo_url = defaults.docker_repo_url.clone(); + } + if self.docker_image_tag.is_empty() { + self.docker_image_tag = defaults.docker_image_tag.clone(); + } + if self.docker_image_pull_secret.is_none() { + self.docker_image_pull_secret = defaults.docker_image_pull_secret.clone(); + } + } +} + /// Per-deployment DPF configuration for named entries under `[dpf.deployments]`. /// /// `flavor_name`, `deployment_name`, and `node_label_key` are required when a @@ -1774,8 +1812,15 @@ impl DpfDeploymentsConfig { .copied() .map(|service| (service, service.default_config())) .collect(); - // A configured entry replaces only that service's built-in definition. - deployment.extra_services.extend(configured); + // A configured entry overrides the built-in definition for that service. + // `DpfServiceConfig` now allows partial TOML entries (see `#[serde(default)]`), + // so a configured entry may have empty string fields. Fill those from the + // service's built-in default before inserting so a partial override does not + // silently leave `name`, `helm_repo_url`, etc. blank. + for (service, mut partial) in configured { + partial.fill_from_defaults(&service.default_config()); + deployment.extra_services.insert(service, partial); + } } /// Returns all active deployment configs as `(name, config)` pairs. @@ -6732,6 +6777,74 @@ node_label_key = "carbide.nvidia.com/astra" ); } + // ------------------------------------------------------------------------- + // DpfServiceConfig::fill_from_defaults + // ------------------------------------------------------------------------- + + #[test] + fn fill_from_defaults_preserves_non_empty_fields() { + let default = DpfServiceConfig { + name: "carbide-dhcp-server".to_string(), + helm_repo_url: "https://default-repo.example.com".to_string(), + helm_chart: "carbide-dhcp-server".to_string(), + helm_version: "1.0.0".to_string(), + docker_repo_url: "nvcr.io/default/dhcp".to_string(), + docker_image_tag: "1.0.0".to_string(), + docker_image_pull_secret: Some("default-pull-secret".to_string()), + }; + + // Simulate a partial site-config entry: only helm_version is set, + // everything else is the empty-string default that #[serde(default)] + // produces when the field is absent from the TOML. + let mut partial = DpfServiceConfig { + helm_version: "2.1.0-pr-385".to_string(), + ..DpfServiceConfig::default() + }; + + partial.fill_from_defaults(&default); + + // The overridden field must be preserved. + assert_eq!(partial.helm_version, "2.1.0-pr-385"); + // All other fields must come from the default. + assert_eq!(partial.name, "carbide-dhcp-server"); + assert_eq!(partial.helm_repo_url, "https://default-repo.example.com"); + assert_eq!(partial.helm_chart, "carbide-dhcp-server"); + assert_eq!(partial.docker_repo_url, "nvcr.io/default/dhcp"); + assert_eq!(partial.docker_image_tag, "1.0.0"); + assert_eq!( + partial.docker_image_pull_secret, + Some("default-pull-secret".to_string()) + ); + } + + #[test] + fn fill_from_defaults_full_config_unchanged() { + // When all fields are already populated, fill_from_defaults must not overwrite any of them. + let default = DpfServiceConfig { + name: "default-name".to_string(), + helm_repo_url: "https://default.example.com".to_string(), + helm_chart: "default-chart".to_string(), + helm_version: "1.0.0".to_string(), + docker_repo_url: "nvcr.io/default/image".to_string(), + docker_image_tag: "1.0.0".to_string(), + docker_image_pull_secret: Some("default-secret".to_string()), + }; + let mut full = DpfServiceConfig { + name: "custom-name".to_string(), + helm_repo_url: "https://custom.example.com".to_string(), + helm_chart: "custom-chart".to_string(), + helm_version: "2.0.0".to_string(), + docker_repo_url: "nvcr.io/custom/image".to_string(), + docker_image_tag: "2.0.0".to_string(), + docker_image_pull_secret: Some("custom-secret".to_string()), + }; + full.fill_from_defaults(&default); + + assert_eq!(full.name, "custom-name"); + assert_eq!(full.helm_version, "2.0.0"); + assert_eq!(full.docker_image_pull_secret, Some("custom-secret".to_string())); + } + #[test] fn validate_provisioning_sources_requires_exactly_one_psid() { // Exactly one PSID entry is accepted. diff --git a/crates/api-core/src/cfg/load.rs b/crates/api-core/src/cfg/load.rs index 22534424b7..0b19c84e44 100644 --- a/crates/api-core/src/cfg/load.rs +++ b/crates/api-core/src/cfg/load.rs @@ -107,6 +107,46 @@ pub fn parse_carbide_config( .extract() .wrap_err("failed to load configuration files")?; + // Fill any empty DpfServiceConfig fields from the compiled-in service defaults. + // A site-config partial override (e.g. only `helm_version`) leaves the other + // fields as empty strings after Figment extraction because the compiled-in + // defaults live in Rust code, not in any TOML file that Figment can deep-merge. + // `fill_from_defaults` restores those fields so the rest of the API sees a + // fully-populated config regardless of how many fields were overridden. + { + use crate::dpf_services::{ + default_dhcp_server_service, default_doca_hbn_service, default_dpu_agent_service, + default_fmds_service, default_otelcol_service, default_dts_service, + }; + let s = &mut config.dpf.services; + s.dts.fill_from_defaults(&default_dts_service()); + s.doca_hbn.fill_from_defaults(&default_doca_hbn_service()); + s.dpu_agent.fill_from_defaults(&default_dpu_agent_service()); + s.dhcp_server.fill_from_defaults(&default_dhcp_server_service()); + s.fmds.fill_from_defaults(&default_fmds_service()); + s.otel.fill_from_defaults(&default_otelcol_service()); + + // Apply the same fill to per-deployment service overrides (bf3, bf4_generic, bf4_astra). + // These use the same DpfMandatoryServicesConfig type as the top-level services block. + let fill_deployment = |dep: &mut crate::cfg::file::DpfDeploymentConfig| { + if let Some(ref mut dep_services) = dep.services { + dep_services.dts.fill_from_defaults(&default_dts_service()); + dep_services.doca_hbn.fill_from_defaults(&default_doca_hbn_service()); + dep_services.dpu_agent.fill_from_defaults(&default_dpu_agent_service()); + dep_services.dhcp_server.fill_from_defaults(&default_dhcp_server_service()); + dep_services.fmds.fill_from_defaults(&default_fmds_service()); + dep_services.otel.fill_from_defaults(&default_otelcol_service()); + } + }; + fill_deployment(&mut config.dpf.deployments.bf3); + if let Some(ref mut dep) = config.dpf.deployments.bf4_generic { + fill_deployment(dep); + } + if let Some(ref mut dep) = config.dpf.deployments.bf4_astra { + fill_deployment(dep); + } + } + config.config_ctx = Some(merged_config); for (label, _) in config