use std::path::PathBuf; use anyhow::{Context, Result}; use crate::cli::{InitConfigArgs, ServeArgs}; use crate::config::resolve::{load_file_config, normalize_public_url, resolve_path}; use crate::config::types::{DEFAULT_CONFIG, FileConfig, FileTelemetryConfig}; pub fn write_init_config(args: &InitConfigArgs) -> Result> { if args.stdout && args.config_file.is_some() { anyhow::bail!("--stdout cannot be used with --config-file"); } if let Some(config_file) = &args.config_file && config_file.exists() && !args.force { anyhow::bail!( "config {} already exists; re-run with --force to overwrite", config_file.display() ); } let base = if let Some(from_config) = &args.from_config { if !from_config.exists() { anyhow::bail!("base config {} does not exist", from_config.display()); } load_file_config(from_config)? } else { Default::default() }; let base_telemetry = base.telemetry.unwrap_or_default(); let defaults = &*DEFAULT_CONFIG; let bind = match args.bind { Some(bind) => bind, None => match base.bind { Some(ref bind) => bind.parse().with_context(|| { format!( "invalid bind address `{bind}` in base config {}", args.from_config .as_ref() .map(|path| path.display().to_string()) .unwrap_or_else(|| "".to_string()) ) })?, None => defaults.bind, }, }; let public_url = normalize_public_url( args.public_url .as_deref() .or(base.public_url.as_deref()) .unwrap_or(&defaults.public_url), ); let data_dir = args .data_dir .clone() .or(base.data_dir) .unwrap_or_else(|| defaults.data_dir.clone()); let state_file_raw = args .state_file .clone() .or(base.state_file) .unwrap_or_else(|| defaults.state_file.clone()); let state_file = path_for_rendered_config(&data_dir, state_file_raw); let pid_file_raw = args .pid_file .clone() .or(base.pid_file) .unwrap_or_else(|| defaults.pid_file.clone()); let pid_file = path_for_rendered_config(&data_dir, pid_file_raw); let ui_dist_dir = args .ui_dist_dir .clone() .or(base.ui_dist_dir) .unwrap_or_else(|| defaults.ui_dist_dir.clone()); let bootstrap_enroll_tokens = if args.bootstrap_enroll_tokens.is_empty() { base.bootstrap_enroll_tokens .unwrap_or_else(|| defaults.bootstrap_enroll_tokens.clone()) } else { args.bootstrap_enroll_tokens.clone() }; let command_timeout = args .command_timeout_ms .map(std::time::Duration::from_millis) .or(base.command_timeout) .unwrap_or(defaults.command_timeout); let enroll_token_ttl = args .enroll_token_ttl_seconds .map(std::time::Duration::from_secs) .or(base.enroll_token_ttl) .unwrap_or(defaults.enroll_token_ttl); let history_retention = args .observation_retention_seconds .map(std::time::Duration::from_secs) .or(base.history_retention) .unwrap_or(defaults.history_retention); super::resolve::validate_durations(command_timeout, enroll_token_ttl, history_retention)?; let body = FileConfig { data_dir: Some(data_dir), bind: Some(bind.to_string()), public_url: Some(public_url), state_file: Some(state_file), command_timeout: Some(command_timeout), enroll_token_ttl: Some(enroll_token_ttl), history_retention: Some(history_retention), pid_file: Some(pid_file), ui_dist_dir: Some(ui_dist_dir), // Preserve the previous generated template's omission of an empty list. // Explicit empty lists still round-trip through FileConfig unchanged. bootstrap_enroll_tokens: (!bootstrap_enroll_tokens.is_empty()) .then_some(bootstrap_enroll_tokens), telemetry: Some(FileTelemetryConfig { console_filter: base_telemetry .console_filter .or_else(|| defaults.telemetry.console_filter.clone()), otlp_log_filter: base_telemetry .otlp_log_filter .or_else(|| defaults.telemetry.otlp_log_filter.clone()), trace_filter: base_telemetry .trace_filter .or_else(|| defaults.telemetry.trace_filter.clone()), authorization_env: base_telemetry .authorization_env .or_else(|| defaults.telemetry.authorization_env.clone()), otlp_endpoint: args .telemetry_otlp_endpoint .clone() .or(base_telemetry.otlp_endpoint) .or_else(|| defaults.telemetry.otlp_endpoint.clone()), service_name: Some( args.telemetry_service_name .clone() .or(base_telemetry.service_name) .unwrap_or_else(|| defaults.telemetry.service_name.clone()), ), json_logs: Some( args.telemetry_json_logs .or(base_telemetry.json_logs) .unwrap_or(defaults.telemetry.json_logs), ), }), }; let rendered = toml::to_string_pretty(&body).context("failed to render config template")?; if let Some(config_file) = &args.config_file { if let Some(parent) = config_file.parent() { std::fs::create_dir_all(parent) .with_context(|| format!("failed to create config dir {}", parent.display()))?; } std::fs::write(config_file, rendered) .with_context(|| format!("failed to write config {}", config_file.display()))?; return Ok(Some(config_file.clone())); } print!("{}", rendered); Ok(None) } /// Keep relative paths relative when the rendered config's `data_dir` is also /// relative. The daemon resolves relative paths under `data_dir`; embedding /// that directory into another relative path here would make it get joined a /// second time when the generated config is loaded. fn path_for_rendered_config(data_dir: &std::path::Path, candidate: PathBuf) -> PathBuf { if data_dir.is_relative() && candidate.is_relative() { candidate } else { resolve_path(data_dir, candidate) } } pub fn bootstrap_config_if_missing(args: &ServeArgs) -> Result { if args.config_file.exists() { return Ok(false); } let init = InitConfigArgs { config_file: Some(args.config_file.clone()), stdout: false, data_dir: args.data_dir.clone(), bind: args.bind, public_url: args.public_url.clone(), state_file: args.state_file.clone(), pid_file: args.pid_file.clone(), ui_dist_dir: args.ui_dist_dir.clone(), command_timeout_ms: args.command_timeout_ms, enroll_token_ttl_seconds: args.enroll_token_ttl_seconds, observation_retention_seconds: args.observation_retention_seconds, bootstrap_enroll_tokens: args.bootstrap_enroll_tokens.clone(), from_config: None, telemetry_otlp_endpoint: None, telemetry_service_name: None, telemetry_json_logs: None, force: false, }; write_init_config(&init)?; Ok(true) } #[cfg(test)] mod tests { use super::write_init_config; use crate::cli::InitConfigArgs; use crate::config::types::DEFAULT_CONFIG; fn temp_test_dir(name: &str) -> std::path::PathBuf { std::env::temp_dir().join(format!( "wakey-cc-init-config-{name}-{}", std::process::id() )) } #[test] fn init_config_from_config_preserves_base_and_applies_overrides() { let dir = temp_test_dir("from-config"); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).expect("create temp dir"); let base = dir.join("base.toml"); let out = dir.join("out.toml"); std::fs::write( &base, r#" data_dir = "/tmp/wakey-base" bind = "127.0.0.1:9090" public_url = "https://old.example/" state_file = "old.sqlite3" command_timeout_ms = 1234 enroll_token_ttl_seconds = 5678 pid_file = "old.pid" ui_dist_dir = "/opt/wakey/ui" bootstrap_enroll_tokens = ["old-token"] [telemetry] otlp_endpoint = "http://otel.example" authorization_env = "WAKEY_OTLP_AUTHORIZATION" console_filter = "info" otlp_log_filter = "debug" trace_filter = "info" service_name = "old-service" json_logs = true "#, ) .expect("write base config"); write_init_config(&InitConfigArgs { config_file: Some(out.clone()), from_config: Some(base), stdout: false, data_dir: None, bind: None, public_url: Some("https://new.example/".to_string()), state_file: None, pid_file: None, ui_dist_dir: None, command_timeout_ms: Some(999), enroll_token_ttl_seconds: None, observation_retention_seconds: None, bootstrap_enroll_tokens: Vec::new(), telemetry_otlp_endpoint: None, telemetry_service_name: None, telemetry_json_logs: None, force: false, }) .expect("write derived config"); let rendered = std::fs::read_to_string(&out).expect("read derived config"); let parsed: toml::Value = toml::from_str(&rendered).expect("parse derived config"); assert_eq!(parsed["public_url"].as_str(), Some("https://new.example")); assert_eq!(parsed["bind"].as_str(), Some("127.0.0.1:9090")); let converted: super::FileConfig = toml::from_str(&rendered).unwrap(); assert_eq!( converted.command_timeout, Some(std::time::Duration::from_millis(999)) ); assert_eq!( converted.enroll_token_ttl, Some(std::time::Duration::from_secs(5678)) ); assert!(parsed.get("command_timeout_ms").is_none()); assert!(parsed.get("enroll_token_ttl_seconds").is_none()); assert_eq!( parsed["state_file"].as_str(), Some("/tmp/wakey-base/old.sqlite3") ); assert_eq!(parsed["telemetry"]["json_logs"].as_bool(), Some(true)); assert_eq!(parsed["telemetry"]["console_filter"].as_str(), Some("info")); assert_eq!( parsed["telemetry"]["otlp_log_filter"].as_str(), Some("debug") ); assert_eq!(parsed["telemetry"]["trace_filter"].as_str(), Some("info")); assert_eq!( parsed["telemetry"]["authorization_env"].as_str(), Some("WAKEY_OTLP_AUTHORIZATION") ); assert_eq!( parsed["telemetry"]["service_name"].as_str(), Some("old-service") ); let _ = std::fs::remove_dir_all(&dir); } #[test] fn init_config_renders_shared_default_template() { let dir = temp_test_dir("defaults"); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).expect("create temp dir"); let out = dir.join("out.toml"); write_init_config(&InitConfigArgs { config_file: Some(out.clone()), from_config: None, stdout: false, data_dir: None, bind: None, public_url: None, state_file: None, pid_file: None, ui_dist_dir: None, command_timeout_ms: None, enroll_token_ttl_seconds: None, observation_retention_seconds: None, bootstrap_enroll_tokens: Vec::new(), telemetry_otlp_endpoint: None, telemetry_service_name: None, telemetry_json_logs: None, force: false, }) .expect("write default config"); let rendered = std::fs::read_to_string(&out).expect("read default config"); let parsed: toml::Value = toml::from_str(&rendered).expect("parse default config"); let defaults = &*DEFAULT_CONFIG; assert_eq!( parsed["state_file"].as_str(), defaults.data_dir.join(&defaults.state_file).to_str() ); let converted: super::FileConfig = toml::from_str(&rendered).unwrap(); assert_eq!(converted.command_timeout, Some(defaults.command_timeout)); assert_eq!( parsed["telemetry"]["service_name"].as_str(), Some(defaults.telemetry.service_name.as_str()) ); let _ = std::fs::remove_dir_all(&dir); } #[test] fn init_config_preserves_relative_paths_for_relative_data_dir() { let dir = temp_test_dir("relative-data-dir"); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).expect("create temp dir"); let base = dir.join("base.toml"); let out = dir.join("out.toml"); std::fs::write( &base, r#" data_dir = "test-ddir/wakey-control-plane" state_file = "state.sqlite3" pid_file = "wakey-control-plane.pid" "#, ) .expect("write base config"); write_init_config(&InitConfigArgs { config_file: Some(out.clone()), from_config: Some(base), stdout: false, data_dir: None, bind: None, public_url: None, state_file: None, pid_file: None, ui_dist_dir: None, command_timeout_ms: None, enroll_token_ttl_seconds: None, observation_retention_seconds: None, bootstrap_enroll_tokens: Vec::new(), telemetry_otlp_endpoint: None, telemetry_service_name: None, telemetry_json_logs: None, force: false, }) .expect("write derived config"); let parsed: toml::Value = toml::from_str(&std::fs::read_to_string(&out).expect("read derived config")) .expect("parse derived config"); assert_eq!(parsed["state_file"].as_str(), Some("state.sqlite3")); assert_eq!(parsed["pid_file"].as_str(), Some("wakey-control-plane.pid")); let _ = std::fs::remove_dir_all(&dir); } }