diff --git a/crates/openshell-server/src/cli.rs b/crates/openshell-server/src/cli.rs index c750794c1f..11351174bd 100644 --- a/crates/openshell-server/src/cli.rs +++ b/crates/openshell-server/src/cli.rs @@ -401,20 +401,11 @@ fn prepare_server_config_with_drivers( // when that file exists. Missing default config is not an error: runtime // defaults and OPENSHELL_* env vars are enough for package-managed starts. let config_path = resolve_config_path(args)?; - let file: Option = if let Some(path) = config_path { + let mut file: Option = if let Some(path) = config_path { Some(config_file::load(&path).map_err(|e| miette::miette!("{e}"))?) } else { None }; - // Normalize sources now, but defer artifact staging until the selected - // driver has been proven able to propagate the bundle. This keeps a - // rejected startup/preflight from leaving gateway state behind. - let normalized_network_trust = file - .as_ref() - .map(|file| crate::network_trust::normalize_from_config(&file.openshell.supervisor.network)) - .transpose() - .map_err(|error| miette::miette!("{error}"))? - .flatten(); if let Some(file) = file.as_ref() { merge_file_into_args(args, &file.openshell.gateway, matches); } @@ -423,6 +414,18 @@ fn prepare_server_config_with_drivers( let compute_driver = compute_drivers .select(args.compute_driver.as_deref()) .map_err(|error| miette::miette!("{error}"))?; + if let Some(file) = file.as_mut() { + config_file::normalize_legacy_podman_ca_bundle(file, compute_driver.name()) + .map_err(|error| miette::miette!("{error}"))?; + } + // Interpret legacy driver settings only after effective selection. Defer + // artifact staging until the driver has proven it can propagate the bundle. + let normalized_network_trust = file + .as_ref() + .map(|file| crate::network_trust::normalize_from_config(&file.openshell.supervisor.network)) + .transpose() + .map_err(|error| miette::miette!("{error}"))? + .flatten(); let local_tls = apply_runtime_defaults(args)?; let guest_tls = GuestTlsPaths::resolve( file.as_ref().map(|file| &file.openshell.gateway), @@ -807,6 +810,15 @@ async fn run_from_args( { warn!("{field} is deprecated and ignored; OpenShell emits OCSF 1.8.0"); } + if prepared + .config_file + .as_ref() + .is_some_and(|file| file.legacy_podman_ca_bundle_seen) + { + warn!( + "openshell.drivers.podman.additional_ca_bundle is deprecated; use openshell.supervisor.network.additional_ca_bundle" + ); + } if let Some(bundle) = prepared.network_trust.as_ref() { info!( certificate_count = bundle.certificate_count(), @@ -978,7 +990,7 @@ fn run_effective_config_preflight( } else { resolve_config_path(&run)? }; - let file = path + let mut file = path .as_ref() .map(|path| config_file::preflight(path).map_err(|error| miette::miette!("{error}"))) .transpose()?; @@ -1000,6 +1012,12 @@ fn run_effective_config_preflight( .map(|driver| compute_drivers.select(Some(driver))) .transpose() .map_err(|error| miette::miette!("{error}"))?; + if let Some(selection) = selection.as_ref() + && let Some(file) = file.as_mut() + { + config_file::normalize_legacy_podman_ca_bundle(file, selection.name()) + .map_err(|error| miette::miette!("{error}"))?; + } let empty_file = ConfigFile::default(); let semantic_file = file.as_ref().unwrap_or(&empty_file); validate_preflight_semantics(&run, matches, semantic_file)?; @@ -1063,14 +1081,28 @@ fn run_effective_config_preflight( if !semantic_file.openshell.drivers.contains_key(driver_name) { continue; } - validate_preflight_isolation_backend(semantic_file, driver_name)?; + // Detection-free preflight checks each candidate independently; + // a Podman alias must never change another candidate's trust. + let mut candidate_file = semantic_file.clone(); + config_file::normalize_legacy_podman_ca_bundle(&mut candidate_file, driver_name) + .map_err(|error| miette::miette!("{error}"))?; + let candidate_trust_configured = crate::network_trust::normalize_from_config( + &candidate_file.openshell.supervisor.network, + )? + .is_some(); + let candidate_startup = crate::compute::driver_config::DriverStartupContext { + file: Some(&candidate_file), + network_trust_configured: candidate_trust_configured, + ..driver_startup + }; + validate_preflight_isolation_backend(&candidate_file, driver_name)?; crate::validate_compute_driver_config( compute_drivers, driver_name, run.name.trim(), SocketAddr::new(run.bind_address, run.port), &run.log_level, - driver_startup, + candidate_startup, true, )?; } @@ -3328,6 +3360,17 @@ mod tests { #[test] fn prepare_server_config_normalizes_global_network_trust_before_driver_startup() { + assert_startup_normalizes_network_trust("openshell.supervisor.network", "cli"); + } + + #[test] + fn prepare_server_config_normalizes_legacy_podman_network_trust_before_driver_startup() { + for selector in ["cli", "env", "file", "auto"] { + assert_startup_normalizes_network_trust("openshell.drivers.podman", selector); + } + } + + fn assert_startup_normalizes_network_trust(table: &str, selector: &str) { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); @@ -3336,43 +3379,164 @@ mod tests { let config_dir = tempfile::tempdir().unwrap(); let _state = EnvVarGuard::set("XDG_STATE_HOME", state.path().to_str().unwrap()); let _config = EnvVarGuard::set("XDG_CONFIG_HOME", config_dir.path().to_str().unwrap()); + let _legacy = EnvVarGuard::remove("OPENSHELL_DRIVERS"); + let _driver = if selector == "env" { + EnvVarGuard::set("OPENSHELL_COMPUTE_DRIVER", "podman") + } else { + EnvVarGuard::remove("OPENSHELL_COMPUTE_DRIVER") + }; let certificate = rcgen::generate_simple_self_signed(vec!["destination.example".into()]) .expect("test certificate"); let source = source_dir.path().join("destination-ca.pem"); std::fs::write(&source, certificate.cert.pem()).unwrap(); let config_path = config_dir.path().join("gateway.toml"); + let driver_table = if selector == "file" { + "[openshell.gateway]\ncompute_driver = 'podman'\n" + } else { + "" + }; std::fs::write( &config_path, format!( - "[openshell]\nversion = 2\n\n[openshell.supervisor.network]\nadditional_ca_bundle = \"{}\"\n", + "[openshell]\nversion = 2\n{driver_table}\n[{table}]\nadditional_ca_bundle = \"{}\"\n", source.display() ), ) .unwrap(); - let (mut args, matches) = parse_with_args(&[ + let mut argv = vec![ "openshell-gateway", "--config", config_path.to_str().unwrap(), "--db-url", "sqlite::memory:", "--disable-tls", - "--compute-driver", - "local", - ]); - let prepared = super::prepare_server_config_with_drivers( - &mut args, - &matches, - &detected_local_registry(), - ) - .expect("global network trust should normalize during startup preparation"); + ]; + if selector == "cli" { + argv.extend(["--compute-driver", "podman"]); + } + let (mut run, matches) = parse_with_args(&argv); + let mut registry = crate::ComputeDriverRegistry::new(); + registry + .install( + crate::ComputeDriverRegistration::new( + "podman", + 100, + Some(detect_registered_local), + TestFactory, + ) + .unwrap() + .with_local_singleplayer(), + ) + .unwrap(); + let (preflight_args, preflight_matches) = parse_with_args(&argv); + super::run_effective_config_preflight(None, preflight_args, &preflight_matches, ®istry) + .expect("selected or candidate Podman preflight accepts the legacy alias"); + assert!(!state.path().join("network-supervisor").exists()); + let prepared = super::prepare_server_config_with_drivers(&mut run, &matches, ®istry) + .expect("global network trust should normalize during startup preparation"); let bundle = prepared.network_trust.expect("normalized trust bundle"); assert_eq!(bundle.certificate_count(), 1); assert!(bundle.artifact_path().is_file()); assert_eq!(std::fs::read(bundle.artifact_path()).unwrap(), bundle.pem()); } + #[test] + fn inactive_podman_ca_bundle_is_inert_in_startup_and_preflight() { + let _lock = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let root = tempfile::tempdir().unwrap(); + let state = root.path().join("state"); + let _state = EnvVarGuard::set("XDG_STATE_HOME", state.to_str().unwrap()); + let _config = EnvVarGuard::set("XDG_CONFIG_HOME", root.path().to_str().unwrap()); + let _legacy = EnvVarGuard::remove("OPENSHELL_DRIVERS"); + let certificate = + rcgen::generate_simple_self_signed(vec!["destination.example".into()]).unwrap(); + let ca = root.path().join("global-ca.pem"); + std::fs::write(&ca, certificate.cert.pem()).unwrap(); + let valid_legacy = format!("'{}'", ca.display()); + let config_path = root.path().join("gateway.toml"); + let mut registry = crate::ComputeDriverRegistry::new(); + registry + .install( + crate::ComputeDriverRegistration::new( + "docker", + 100, + Some(detect_registered_local), + TestFactory, + ) + .unwrap() + .with_local_singleplayer(), + ) + .unwrap(); + + for selector in ["cli", "env", "auto"] { + let _driver = if selector == "env" { + EnvVarGuard::set("OPENSHELL_COMPUTE_DRIVER", "docker") + } else { + EnvVarGuard::remove("OPENSHELL_COMPUTE_DRIVER") + }; + for global in [false, true] { + for legacy in [ + "'/nonexistent/inactive-ca.pem'", + "''", + "42", + "['/old.pem']", + &valid_legacy, + ] { + let configured_driver = if selector == "auto" { + "" + } else { + "compute_driver = 'podman'" + }; + let global_table = if global { + format!( + "[openshell.supervisor.network]\nadditional_ca_bundle = '{}'\n", + ca.display() + ) + } else { + String::new() + }; + std::fs::write(&config_path, format!( + "[openshell]\nversion = 2\n[openshell.gateway]\n{configured_driver}\n{global_table}[openshell.drivers.docker]\n[openshell.drivers.podman]\nadditional_ca_bundle = {legacy}\n" + )).unwrap(); + let mut argv = vec![ + "openshell-gateway", + "--config", + config_path.to_str().unwrap(), + "--db-url", + "sqlite::memory:", + "--disable-tls", + ]; + if selector == "cli" { + argv.extend(["--compute-driver", "docker"]); + } + let (run, matches) = parse_with_args(&argv); + super::run_effective_config_preflight(None, run, &matches, ®istry) + .expect("inactive Podman settings must not affect preflight"); + let (mut run, matches) = parse_with_args(&argv); + let prepared = + super::prepare_server_config_with_drivers(&mut run, &matches, ®istry) + .expect("inactive Podman settings must not affect startup"); + assert_eq!(prepared.compute_driver.name(), "docker"); + assert_eq!(prepared.network_trust.is_some(), global); + if let Some(bundle) = prepared.network_trust { + assert_eq!(bundle.certificate_count(), 1); + } + let file = prepared.config_file.unwrap(); + assert!(!file.legacy_podman_ca_bundle_seen); + assert!( + file.openshell.drivers["podman"] + .get("additional_ca_bundle") + .is_some() + ); + } + } + } + } + #[test] fn prepare_server_config_rejects_network_trust_endpoint_override_without_artifact() { let _lock = ENV_LOCK diff --git a/crates/openshell-server/src/config_file.rs b/crates/openshell-server/src/config_file.rs index bbe45b267e..b49e26495d 100644 --- a/crates/openshell-server/src/config_file.rs +++ b/crates/openshell-server/src/config_file.rs @@ -49,6 +49,9 @@ pub const SCHEMA_VERSION: u32 = 2; pub struct ConfigFile { #[serde(default)] pub openshell: OpenShellRoot, + /// Retained until tracing is installed so startup can report migration guidance. + #[serde(skip)] + pub(crate) legacy_podman_ca_bundle_seen: bool, } /// `[openshell]` table. @@ -985,6 +988,52 @@ fn parse_and_validate(path: &Path, contents: &str) -> Result Result<(), ConfigFileError> { + const LEGACY_FIELD: &str = "openshell.drivers.podman.additional_ca_bundle"; + if selected_driver != "podman" { + return Ok(()); + } + let Some(value) = file + .openshell + .drivers + .get_mut("podman") + .and_then(toml::Value::as_table_mut) + .and_then(|table| table.remove("additional_ca_bundle")) + else { + return Ok(()); + }; + let path = value.as_str().ok_or(ConfigFileError::InvalidValue { + field: LEGACY_FIELD, + message: "must be a string path", + })?; + if path.trim().is_empty() { + return Err(ConfigFileError::InvalidValue { + field: LEGACY_FIELD, + message: "path must not be empty", + }); + } + let path = PathBuf::from(path); + let canonical = &mut file.openshell.supervisor.network.additional_ca_bundle; + match canonical { + Some(current) if *current != path => { + return Err(ConfigFileError::InvalidValue { + field: LEGACY_FIELD, + message: "conflicts with openshell.supervisor.network.additional_ca_bundle; use only the supervisor network setting", + }); + } + Some(_) => {} + None => *canonical = Some(path), + } + file.legacy_podman_ca_bundle_seen = true; + Ok(()) +} + fn detected_version(contents: &str) -> String { let Ok(value) = contents.parse::() else { return "unavailable".to_string(); @@ -1255,6 +1304,7 @@ compute_driver = 42 }, ..Default::default() }, + ..Default::default() }; let serialized = toml::to_string(&file).expect("config serializes"); @@ -1715,6 +1765,74 @@ additional_ca_bundle = "/etc/openshell/ca-bundle.crt" ); } + #[test] + fn legacy_podman_ca_bundle_normalizes_and_preserves_driver_settings() { + for supervisor in [ + "", + "[openshell.supervisor.network]\nadditional_ca_bundle = '/etc/ca.pem'\n", + ] { + let tmp = write_tmp(&format!( + "{supervisor}[openshell.drivers.podman]\nadditional_ca_bundle = '/etc/ca.pem'\nsocket_path = '/run/podman.sock'\n" + )); + let mut file = load(tmp.path()).expect("legacy alias loads without interpretation"); + normalize_legacy_podman_ca_bundle(&mut file, "podman").unwrap(); + assert!(file.legacy_podman_ca_bundle_seen); + assert_eq!( + file.openshell.supervisor.network.additional_ca_bundle, + Some(PathBuf::from("/etc/ca.pem")) + ); + let table = driver_table( + "podman", + &file.openshell.gateway, + file.openshell.drivers.get("podman"), + ); + assert!(table.get("additional_ca_bundle").is_none()); + assert_eq!(table["socket_path"].as_str(), Some("/run/podman.sock")); + } + } + + #[test] + fn legacy_podman_ca_bundle_rejects_conflicting_supervisor_setting() { + let tmp = write_tmp( + r#" +[openshell.drivers.podman] +additional_ca_bundle = "/etc/old-ca.pem" +[openshell.supervisor.network] +additional_ca_bundle = "/etc/new-ca.pem" +"#, + ); + let mut file = load(tmp.path()).unwrap(); + let error = normalize_legacy_podman_ca_bundle(&mut file, "podman") + .expect_err("different trust paths must not be ignored"); + assert!( + error + .to_string() + .contains("openshell.drivers.podman.additional_ca_bundle") + ); + assert!( + error + .to_string() + .contains("conflicts with openshell.supervisor.network.additional_ca_bundle") + ); + } + + #[test] + fn legacy_podman_ca_bundle_rejects_invalid_values() { + for value in ["42", "true", "['/etc/ca.pem']", "''", "' '"] { + let tmp = write_tmp(&format!( + "[openshell.drivers.podman]\nadditional_ca_bundle = {value}\n" + )); + let mut file = load(tmp.path()).unwrap(); + let error = normalize_legacy_podman_ca_bundle(&mut file, "podman") + .expect_err("invalid selected alias must fail"); + assert!( + error + .to_string() + .contains("openshell.drivers.podman.additional_ca_bundle") + ); + } + } + #[test] fn supervisor_network_additional_ca_bundle_defaults_to_unset() { let file: ConfigFile = toml::from_str("[openshell.supervisor]\n").expect("valid config");