diff --git a/wakey-control-plane/src/config/init.rs b/wakey-control-plane/src/config/init.rs index 218edc5..1bc1825 100644 --- a/wakey-control-plane/src/config/init.rs +++ b/wakey-control-plane/src/config/init.rs @@ -4,7 +4,7 @@ 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, WritableConfig, WritableTelemetry}; +use crate::config::types::{DEFAULT_CONFIG, FileConfig, FileTelemetryConfig}; pub fn write_init_config(args: &InitConfigArgs) -> Result> { if args.stdout && args.config_file.is_some() { @@ -44,10 +44,7 @@ pub fn write_init_config(args: &InitConfigArgs) -> Result> { .unwrap_or_else(|| "".to_string()) ) })?, - None => defaults - .bind - .parse() - .expect("static default bind should parse"), + None => defaults.bind, }, }; let public_url = normalize_public_url( @@ -90,29 +87,35 @@ pub fn write_init_config(args: &InitConfigArgs) -> Result> { args.bootstrap_enroll_tokens.clone() }; - let body = WritableConfig { - data_dir, - bind: bind.to_string(), - public_url, - state_file, - command_timeout_ms: args - .command_timeout_ms - .or(base.command_timeout_ms) - .unwrap_or(defaults.command_timeout_ms) - .max(1), - enroll_token_ttl_seconds: args - .enroll_token_ttl_seconds - .or(base.enroll_token_ttl_seconds) - .unwrap_or(defaults.enroll_token_ttl_seconds) - .max(1), - observation_retention_seconds: args - .observation_retention_seconds - .or(base.observation_retention_seconds) - .unwrap_or(defaults.observation_retention_seconds), - pid_file, - ui_dist_dir, - bootstrap_enroll_tokens, - telemetry: WritableTelemetry { + 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_ms: Some( + args.command_timeout_ms + .or(base.command_timeout_ms) + .unwrap_or(defaults.command_timeout.as_millis() as u64) + .max(1), + ), + enroll_token_ttl_seconds: Some( + args.enroll_token_ttl_seconds + .or(base.enroll_token_ttl_seconds) + .unwrap_or(defaults.enroll_token_ttl.as_secs()) + .max(1), + ), + observation_retention_seconds: Some( + args.observation_retention_seconds + .or(base.observation_retention_seconds) + .unwrap_or(defaults.observation_retention.as_secs()), + ), + 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()), @@ -130,16 +133,18 @@ pub fn write_init_config(args: &InitConfigArgs) -> Result> { .clone() .or(base_telemetry.otlp_endpoint) .or_else(|| defaults.telemetry.otlp_endpoint.clone()), - service_name: args - .telemetry_service_name - .clone() - .or(base_telemetry.service_name) - .unwrap_or_else(|| defaults.telemetry.service_name.clone()), - json_logs: args - .telemetry_json_logs - .or(base_telemetry.json_logs) - .unwrap_or(defaults.telemetry.json_logs), - }, + 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")?; @@ -332,7 +337,7 @@ json_logs = true ); assert_eq!( parsed["command_timeout_ms"].as_integer(), - Some(defaults.command_timeout_ms as i64) + Some(defaults.command_timeout.as_millis() as i64) ); assert_eq!( parsed["telemetry"]["service_name"].as_str(), diff --git a/wakey-control-plane/src/config/resolve.rs b/wakey-control-plane/src/config/resolve.rs index 13ce2ad..b431ccc 100644 --- a/wakey-control-plane/src/config/resolve.rs +++ b/wakey-control-plane/src/config/resolve.rs @@ -33,10 +33,7 @@ impl DaemonConfig { args.config_file.display() ) })?, - None => defaults - .bind - .parse() - .expect("static default bind should parse"), + None => defaults.bind, }, }; @@ -57,21 +54,21 @@ impl DaemonConfig { let command_timeout = Duration::from_millis( args.command_timeout_ms .or(file.command_timeout_ms) - .unwrap_or(defaults.command_timeout_ms) + .unwrap_or(defaults.command_timeout.as_millis() as u64) .max(1), ); let enroll_token_ttl = Duration::from_secs( args.enroll_token_ttl_seconds .or(file.enroll_token_ttl_seconds) - .unwrap_or(defaults.enroll_token_ttl_seconds) + .unwrap_or(defaults.enroll_token_ttl.as_secs()) .max(1), ); let observation_retention = Duration::from_secs( args.observation_retention_seconds .or(file.observation_retention_seconds) - .unwrap_or(defaults.observation_retention_seconds) + .unwrap_or(defaults.observation_retention.as_secs()) .max(1), ); @@ -146,7 +143,7 @@ pub fn resolve_issue_token_settings(args: &IssueEnrollTokenArgs) -> Result) -> TelemetryConfig { - let mut out = TelemetryConfig::default(); + // Service-specific defaults live in the CC template, not the shared type. + let mut out = DEFAULT_CONFIG.telemetry.clone(); if let Some(file) = file { out.authorization_env = file.authorization_env; out.console_filter = file.console_filter; @@ -344,10 +342,7 @@ mod tests { resolved.state_file, defaults.data_dir.join(&defaults.state_file) ); - assert_eq!( - resolved.command_timeout, - std::time::Duration::from_millis(defaults.command_timeout_ms) - ); + assert_eq!(resolved.command_timeout, defaults.command_timeout); assert_eq!( resolved.telemetry.service_name, defaults.telemetry.service_name diff --git a/wakey-control-plane/src/config/types.rs b/wakey-control-plane/src/config/types.rs index 8d970dd..4ee1002 100644 --- a/wakey-control-plane/src/config/types.rs +++ b/wakey-control-plane/src/config/types.rs @@ -4,6 +4,7 @@ use std::sync::LazyLock; use std::time::Duration; use serde::{Deserialize, Serialize}; +pub use wakey_core::telemetry::TelemetryConfig; #[derive(Debug, Clone)] pub struct DaemonConfig { @@ -20,33 +21,9 @@ pub struct DaemonConfig { pub telemetry: TelemetryConfig, } -#[derive(Debug, Clone)] -pub struct TelemetryConfig { - pub console_filter: Option, - pub otlp_log_filter: Option, - pub trace_filter: Option, - pub otlp_endpoint: Option, - pub authorization_env: Option, - pub service_name: String, - pub json_logs: bool, -} - -impl Default for TelemetryConfig { - fn default() -> Self { - let defaults = &DEFAULT_CONFIG.telemetry; - Self { - console_filter: defaults.console_filter.clone(), - otlp_log_filter: defaults.otlp_log_filter.clone(), - trace_filter: defaults.trace_filter.clone(), - otlp_endpoint: defaults.otlp_endpoint.clone(), - authorization_env: defaults.authorization_env.clone(), - service_name: defaults.service_name.clone(), - json_logs: defaults.json_logs, - } - } -} - -#[derive(Debug, Deserialize, Default)] +/// TOML DTO for both loading partial files and generating complete files. +/// Options preserve omission for precedence; runtime conversion stays separate. +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] pub(crate) struct FileConfig { pub(crate) data_dir: Option, pub(crate) bind: Option, @@ -62,8 +39,10 @@ pub(crate) struct FileConfig { pub(crate) telemetry: Option, } -#[derive(Debug, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] pub(crate) struct FileTelemetryConfig { + // Missing fields must remain distinguishable from explicit values when + // init-config merges an existing file with CLI overrides and defaults. pub(crate) console_filter: Option, pub(crate) otlp_log_filter: Option, pub(crate) trace_filter: Option, @@ -73,50 +52,19 @@ pub(crate) struct FileTelemetryConfig { pub(crate) json_logs: Option, } -#[derive(Debug, Clone, Serialize)] -pub(crate) struct WritableConfig { - pub(crate) data_dir: PathBuf, - pub(crate) bind: String, - pub(crate) public_url: String, - pub(crate) state_file: PathBuf, - pub(crate) command_timeout_ms: u64, - pub(crate) enroll_token_ttl_seconds: u64, - pub(crate) observation_retention_seconds: u64, - pub(crate) pid_file: PathBuf, - pub(crate) ui_dist_dir: PathBuf, - #[serde(skip_serializing_if = "Vec::is_empty")] - pub(crate) bootstrap_enroll_tokens: Vec, - pub(crate) telemetry: WritableTelemetry, -} - -#[derive(Debug, Clone, Serialize)] -pub(crate) struct WritableTelemetry { - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) console_filter: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) otlp_log_filter: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) trace_filter: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) otlp_endpoint: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) authorization_env: Option, - pub(crate) service_name: String, - pub(crate) json_logs: bool, -} - -pub(crate) static DEFAULT_CONFIG: LazyLock = LazyLock::new(|| WritableConfig { +// Relative template paths are resolved under data_dir by the runtime loader. +pub(crate) static DEFAULT_CONFIG: LazyLock = LazyLock::new(|| DaemonConfig { data_dir: crate::cli::DEFAULT_DATA_DIR.into(), - bind: "0.0.0.0:8080".to_string(), + bind: SocketAddr::from(([0, 0, 0, 0], 8080)), public_url: "http://127.0.0.1:8080".to_string(), state_file: "state.sqlite3".into(), - command_timeout_ms: 30_000, - enroll_token_ttl_seconds: 86_400, - observation_retention_seconds: 2_592_000, + command_timeout: Duration::from_millis(30_000), + enroll_token_ttl: Duration::from_secs(86_400), + observation_retention: Duration::from_secs(2_592_000), pid_file: "wakey-control-plane.pid".into(), ui_dist_dir: "ui/dist".into(), bootstrap_enroll_tokens: Vec::new(), - telemetry: WritableTelemetry { + telemetry: TelemetryConfig { console_filter: None, otlp_log_filter: None, trace_filter: None, @@ -139,3 +87,41 @@ pub struct StateAccessSettings { pub state_file: PathBuf, pub public_url: Option, } + +#[cfg(test)] +mod tests { + use super::{FileConfig, FileTelemetryConfig}; + + #[test] + fn file_dto_round_trip_preserves_omission_and_explicit_values() { + let omitted: FileConfig = toml::from_str("").unwrap(); + assert_eq!(omitted, FileConfig::default()); + assert_eq!(toml::to_string(&omitted).unwrap(), ""); + + let explicit = FileConfig { + bootstrap_enroll_tokens: Some(Vec::new()), + observation_retention_seconds: Some(0), + telemetry: Some(FileTelemetryConfig { + json_logs: Some(false), + console_filter: Some("info".into()), + otlp_log_filter: Some("debug".into()), + trace_filter: Some("info".into()), + ..Default::default() + }), + ..Default::default() + }; + let rendered = toml::to_string_pretty(&explicit).unwrap(); + let restored: FileConfig = toml::from_str(&rendered).unwrap(); + assert_eq!(restored, explicit); + assert_ne!(restored, omitted); + } + + #[test] + fn legacy_token_alias_serializes_with_canonical_name() { + let file: FileConfig = toml::from_str("enroll_tokens = []").unwrap(); + assert_eq!(file.bootstrap_enroll_tokens, Some(Vec::new())); + let rendered = toml::to_string(&file).unwrap(); + assert!(rendered.contains("bootstrap_enroll_tokens = []")); + assert!(!rendered.starts_with("enroll_tokens")); + } +} diff --git a/wakey-control-plane/src/tracing.rs b/wakey-control-plane/src/tracing.rs index 8ffafcd..90d58e5 100644 --- a/wakey-control-plane/src/tracing.rs +++ b/wakey-control-plane/src/tracing.rs @@ -3,17 +3,8 @@ use anyhow::Result; pub use wakey_core::telemetry::TelemetryGuard; pub fn init(verbose: u8, telemetry: &TelemetryConfig) -> Result { - let config = wakey_core::telemetry::TelemetryConfig { - otlp_endpoint: telemetry.otlp_endpoint.clone(), - authorization_env: telemetry.authorization_env.clone(), - service_name: telemetry.service_name.clone(), - json_logs: telemetry.json_logs, - console_filter: telemetry.console_filter.clone(), - otlp_log_filter: telemetry.otlp_log_filter.clone(), - trace_filter: telemetry.trace_filter.clone(), - }; wakey_core::telemetry::init( - &config, + telemetry, "wakey-control-plane", match verbose { 0 => "wakey_control_plane=info,wakey_core=info,opentelemetry=warn",