407 lines
14 KiB
Rust
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);
|
|
}
|
|
}
|