From f5a3be7ea6bf547e4b51f707002c475d03990c21 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Wed, 29 Jul 2026 02:39:04 +0000 Subject: [PATCH 1/4] feat(vmm): add configuration validation command --- dstack/vmm/src/main.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/dstack/vmm/src/main.rs b/dstack/vmm/src/main.rs index fb34bc362..f841b67e3 100644 --- a/dstack/vmm/src/main.rs +++ b/dstack/vmm/src/main.rs @@ -55,6 +55,8 @@ enum Command { /// Start the VMM server (default mode) #[default] Serve, + /// Validate the effective server configuration without starting services. + CheckConfig, /// One-shot VM execution mode for debugging Run(RunArgs), /// Internal per-VM QEMU/swtpm launcher. @@ -188,6 +190,10 @@ async fn main() -> Result<()> { // Handle commands match args.command.unwrap_or_default() { Command::VmLauncher(_) => unreachable!("launcher mode handled before config loading"), + Command::CheckConfig => { + println!("configuration is valid"); + return Ok(()); + } Command::Run(run_args) => { // One-shot VM execution mode return one_shot::run_one_shot( From 219fa0a09ba43a70e7218558421396d47ad24c19 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Tue, 4 Aug 2026 04:41:42 -0700 Subject: [PATCH 2/4] feat(vmm): validate static configuration invariants --- dstack/vmm/src/config.rs | 208 ++++++++++++++++++++++++++++++++++++++- dstack/vmm/src/main.rs | 13 +-- 2 files changed, 211 insertions(+), 10 deletions(-) diff --git a/dstack/vmm/src/config.rs b/dstack/vmm/src/config.rs index a31ad081f..3367f6485 100644 --- a/dstack/vmm/src/config.rs +++ b/dstack/vmm/src/config.rs @@ -513,6 +513,118 @@ impl Config { self.run_path = self.run_path.absolutize()?.to_path_buf(); Ok(self) } + + /// Validate configuration invariants that do not require starting services + /// or modifying host state. + pub fn validate(&self) -> Result<()> { + self.host_api + .validate() + .context("Invalid host_api configuration")?; + + anyhow::ensure!(self.cvm.cid_start >= 3, "cvm.cid_start must be at least 3"); + anyhow::ensure!( + self.cvm.cid_pool_size > 0, + "cvm.cid_pool_size must be greater than zero" + ); + self.cvm + .cid_start + .checked_add(self.cvm.cid_pool_size) + .context("cvm CID pool overflows u32")?; + + anyhow::ensure!( + matches!(self.cvm.host_share_mode.as_str(), "9p" | "vhd" | "vvfat"), + "cvm.host_share_mode must be one of: 9p, vhd, vvfat" + ); + if self.cvm.auto_restart.enabled { + anyhow::ensure!( + self.cvm.auto_restart.interval > 0, + "cvm.auto_restart.interval must be greater than zero when enabled" + ); + } + for range in &self.cvm.port_mapping.range { + anyhow::ensure!( + range.from <= range.to, + "cvm.port_mapping range start {} exceeds end {}", + range.from, + range.to + ); + } + + validate_networking(&self.cvm.networking)?; + for (name, value) in [ + ("supervisor.sock", self.supervisor.sock.as_str()), + ("supervisor.pid_file", self.supervisor.pid_file.as_str()), + ("supervisor.log_file", self.supervisor.log_file.as_str()), + ] { + anyhow::ensure!(!value.trim().is_empty(), "{name} must not be empty"); + } + if self.supervisor.auto_start { + anyhow::ensure!( + !self.supervisor.exe.trim().is_empty(), + "supervisor.exe must not be empty when auto_start is enabled" + ); + } + + for (name, values) in [ + ("cvm.kms_urls", self.cvm.kms_urls.as_slice()), + ("cvm.gateway_urls", self.cvm.gateway_urls.as_slice()), + ] { + for value in values { + validate_http_url(name, value)?; + } + } + for (name, value) in [ + ("cvm.pccs_url", Some(self.cvm.pccs_url.as_str())), + ( + "cvm.nvidia_attestation_proxy_url", + self.cvm.nvidia_attestation_proxy_url.as_deref(), + ), + ] { + if let Some(value) = value.filter(|value| !value.is_empty()) { + validate_http_url(name, value)?; + } + } + Ok(()) + } +} + +fn validate_http_url(name: &str, value: &str) -> Result<()> { + let url = url::Url::parse(value).with_context(|| format!("{name} contains an invalid URL"))?; + anyhow::ensure!( + matches!(url.scheme(), "http" | "https"), + "{name} URL must use http or https: {value}" + ); + anyhow::ensure!( + url.host().is_some(), + "{name} URL must include a host: {value}" + ); + Ok(()) +} + +fn validate_networking(networking: &Networking) -> Result<()> { + let prefix = networking.mac_prefix.as_str(); + if !prefix.is_empty() { + let bytes = prefix.split(':').collect::>(); + anyhow::ensure!( + bytes.len() <= 3 + && bytes + .iter() + .all(|byte| byte.len() == 2 && u8::from_str_radix(byte, 16).is_ok()), + "cvm.networking.mac_prefix must contain 1 to 3 two-digit hexadecimal bytes" + ); + } + match networking.mode { + NetworkingMode::Bridge => anyhow::ensure!( + !networking.bridge.trim().is_empty(), + "cvm.networking.bridge must not be empty in bridge mode" + ), + NetworkingMode::Custom => anyhow::ensure!( + !networking.netdev.trim().is_empty(), + "cvm.networking.netdev must not be empty in custom mode" + ), + NetworkingMode::User => {} + } + Ok(()) } #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] @@ -583,13 +695,20 @@ impl HostApiConfig { /// The host API must only listen on vsock for security reasons. /// TCP/Unix socket listening is not supported. pub fn validate(&self) -> Result<()> { - if !self.address.starts_with("vsock:") { - anyhow::bail!( + let cid = self.address.strip_prefix("vsock:").with_context(|| { + format!( "Host API address must be a vsock address (e.g., 'vsock:2'), got: '{}'. \ - TCP/Unix socket listening is not supported for the host API.", + TCP/Unix socket listening is not supported for the host API.", self.address - ); + ) + })?; + if let Some(cid) = cid.strip_prefix("0x") { + u32::from_str_radix(cid, 16).context("Host API address contains an invalid CID")?; + } else { + cid.parse::() + .context("Host API address contains an invalid CID")?; } + anyhow::ensure!(self.port > 0, "Host API port must be greater than zero"); Ok(()) } } @@ -794,6 +913,87 @@ mod tests { ); } + fn default_config() -> Config { + use rocket::figment::providers::{Format, Toml}; + + Figment::from(Toml::string(DEFAULT_CONFIG)) + .extract() + .expect("default VMM config should parse") + } + + #[test] + fn config_validation_accepts_defaults() { + default_config().validate().unwrap(); + } + + #[test] + fn config_validation_rejects_invalid_static_invariants() { + let mut config = default_config(); + config.cvm.cid_pool_size = 0; + assert!(config + .validate() + .unwrap_err() + .to_string() + .contains("cid_pool_size")); + + let mut config = default_config(); + config.cvm.port_mapping.range[0].from = 200; + config.cvm.port_mapping.range[0].to = 100; + assert!(config + .validate() + .unwrap_err() + .to_string() + .contains("range start")); + + let mut config = default_config(); + config.cvm.networking.mac_prefix = "02:not-hex".into(); + assert!(config + .validate() + .unwrap_err() + .to_string() + .contains("mac_prefix")); + + let mut config = default_config(); + config.cvm.host_share_mode = "unknown".into(); + assert!(config + .validate() + .unwrap_err() + .to_string() + .contains("host_share_mode")); + } + + #[test] + fn config_validation_rejects_invalid_endpoints() { + let mut config = default_config(); + config.cvm.kms_urls = vec!["not a URL".into()]; + assert!(config + .validate() + .unwrap_err() + .to_string() + .contains("kms_urls")); + + let mut config = default_config(); + config.supervisor.sock.clear(); + assert!(config + .validate() + .unwrap_err() + .to_string() + .contains("supervisor.sock")); + + let mut config = default_config(); + config.cvm.networking.mode = NetworkingMode::Bridge; + config.cvm.networking.bridge.clear(); + assert!(config + .validate() + .unwrap_err() + .to_string() + .contains("networking.bridge")); + + let mut config = default_config(); + config.host_api.address = "vsock:not-a-cid".into(); + assert!(format!("{:#}", config.validate().unwrap_err()).contains("invalid CID")); + } + #[test] fn tee_platform_auto_detects_amd_sev_snp_from_flag() { let cpuinfo = "flags : fpu svm sev sev_es sev_snp debug_swap"; diff --git a/dstack/vmm/src/main.rs b/dstack/vmm/src/main.rs index f841b67e3..7b67d1734 100644 --- a/dstack/vmm/src/main.rs +++ b/dstack/vmm/src/main.rs @@ -180,12 +180,13 @@ async fn main() -> Result<()> { let figment = config::load_config_figment(args.config.as_deref()); let config = Config::extract_or_default(&figment)?.abs_path()?; - - // Validate host API configuration - config - .host_api - .validate() - .context("Invalid host_api configuration")?; + config.validate()?; + let _: rocket::listener::Endpoint = figment + .extract_inner("address") + .context("Invalid management API address")?; + let _: u16 = figment + .extract_inner("port") + .context("Invalid management API port")?; // Handle commands match args.command.unwrap_or_default() { From a03d8b173fb8c68effc6cdf50a8aa15275fbdbeb Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Tue, 4 Aug 2026 05:42:51 -0700 Subject: [PATCH 3/4] fix(vmm): conditionally validate supervisor startup paths --- dstack/vmm/src/config.rs | 35 ++++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/dstack/vmm/src/config.rs b/dstack/vmm/src/config.rs index 3367f6485..3fdd1d9d9 100644 --- a/dstack/vmm/src/config.rs +++ b/dstack/vmm/src/config.rs @@ -551,18 +551,21 @@ impl Config { } validate_networking(&self.cvm.networking)?; - for (name, value) in [ - ("supervisor.sock", self.supervisor.sock.as_str()), - ("supervisor.pid_file", self.supervisor.pid_file.as_str()), - ("supervisor.log_file", self.supervisor.log_file.as_str()), - ] { - anyhow::ensure!(!value.trim().is_empty(), "{name} must not be empty"); - } + anyhow::ensure!( + !self.supervisor.sock.trim().is_empty(), + "supervisor.sock must not be empty" + ); if self.supervisor.auto_start { - anyhow::ensure!( - !self.supervisor.exe.trim().is_empty(), - "supervisor.exe must not be empty when auto_start is enabled" - ); + for (name, value) in [ + ("supervisor.exe", self.supervisor.exe.as_str()), + ("supervisor.pid_file", self.supervisor.pid_file.as_str()), + ("supervisor.log_file", self.supervisor.log_file.as_str()), + ] { + anyhow::ensure!( + !value.trim().is_empty(), + "{name} must not be empty when supervisor.auto_start is enabled" + ); + } } for (name, values) in [ @@ -994,6 +997,16 @@ mod tests { assert!(format!("{:#}", config.validate().unwrap_err()).contains("invalid CID")); } + #[test] + fn config_validation_does_not_require_supervisor_startup_paths_when_disabled() { + let mut config = default_config(); + config.supervisor.auto_start = false; + config.supervisor.exe.clear(); + config.supervisor.pid_file.clear(); + config.supervisor.log_file.clear(); + config.validate().unwrap(); + } + #[test] fn tee_platform_auto_detects_amd_sev_snp_from_flag() { let cpuinfo = "flags : fpu svm sev sev_es sev_snp debug_swap"; From 2f1e3933b286b68b5c549a34ee5d9002e37a6066 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Tue, 4 Aug 2026 05:45:34 -0700 Subject: [PATCH 4/4] fix(vmm): limit extended validation to check-config --- dstack/vmm/src/main.rs | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/dstack/vmm/src/main.rs b/dstack/vmm/src/main.rs index 7b67d1734..febceb975 100644 --- a/dstack/vmm/src/main.rs +++ b/dstack/vmm/src/main.rs @@ -180,18 +180,24 @@ async fn main() -> Result<()> { let figment = config::load_config_figment(args.config.as_deref()); let config = Config::extract_or_default(&figment)?.abs_path()?; - config.validate()?; - let _: rocket::listener::Endpoint = figment - .extract_inner("address") - .context("Invalid management API address")?; - let _: u16 = figment - .extract_inner("port") - .context("Invalid management API port")?; + // Preserve the existing startup validation. The broader static checks are + // opt-in through `check-config` until they have seen wider deployment use. + config + .host_api + .validate() + .context("Invalid host_api configuration")?; // Handle commands match args.command.unwrap_or_default() { Command::VmLauncher(_) => unreachable!("launcher mode handled before config loading"), Command::CheckConfig => { + config.validate()?; + let _: rocket::listener::Endpoint = figment + .extract_inner("address") + .context("Invalid management API address")?; + let _: u16 = figment + .extract_inner("port") + .context("Invalid management API port")?; println!("configuration is valid"); return Ok(()); }