Files
wakey/wakey-control-plane/src/config/init.rs
T

407 lines
14 KiB
Rust

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<Option<PathBuf>> {
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(|| "<defaults>".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<bool> {
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);
}
}