feat(config): support human-readable durations with legacy compatibility
This commit is contained in:
22 files changed
+715
-151
No files matched your search
@@ -9,6 +9,7 @@ anyhow = { workspace = true }
|
||||
axum = { version = "0.8", features = ["ws", "json"] }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
futures-util = "0.3"
|
||||
humantime-serde = "1"
|
||||
macaddr = { workspace = true }
|
||||
nix = { version = "0.31", default-features = false, features = [
|
||||
"signal",
|
||||
|
||||
@@ -172,11 +172,9 @@ pub async fn relay_agent_command(
|
||||
));
|
||||
}
|
||||
|
||||
let timeout = std::time::Duration::from_millis(
|
||||
timeout_ms
|
||||
.unwrap_or(state.command_timeout.as_millis() as u64)
|
||||
.max(1),
|
||||
);
|
||||
let timeout = timeout_ms
|
||||
.map(|value| std::time::Duration::from_millis(value.max(1)))
|
||||
.unwrap_or(state.command_timeout);
|
||||
let outcome = tokio::time::timeout(timeout, pending_rx).await;
|
||||
let response = match outcome {
|
||||
Ok(Ok(AgentReply::Result(result))) => {
|
||||
|
||||
@@ -72,6 +72,7 @@ pub struct ServeArgs {
|
||||
#[arg(long)]
|
||||
pub enroll_token_ttl_seconds: Option<u64>,
|
||||
|
||||
/// Audit/alert history retention in seconds (legacy CLI name; not device retention).
|
||||
#[arg(long)]
|
||||
pub observation_retention_seconds: Option<u64>,
|
||||
|
||||
@@ -129,6 +130,7 @@ pub struct InitConfigArgs {
|
||||
#[arg(long)]
|
||||
pub enroll_token_ttl_seconds: Option<u64>,
|
||||
|
||||
/// Audit/alert history retention in seconds (legacy CLI name; not device retention).
|
||||
#[arg(long)]
|
||||
pub observation_retention_seconds: Option<u64>,
|
||||
|
||||
|
||||
@@ -87,28 +87,31 @@ pub fn write_init_config(args: &InitConfigArgs) -> Result<Option<PathBuf>> {
|
||||
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_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()),
|
||||
),
|
||||
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.
|
||||
@@ -275,8 +278,17 @@ json_logs = true
|
||||
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"));
|
||||
assert_eq!(parsed["command_timeout_ms"].as_integer(), Some(999));
|
||||
assert_eq!(parsed["enroll_token_ttl_seconds"].as_integer(), Some(5678));
|
||||
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")
|
||||
@@ -335,10 +347,8 @@ json_logs = true
|
||||
parsed["state_file"].as_str(),
|
||||
defaults.data_dir.join(&defaults.state_file).to_str()
|
||||
);
|
||||
assert_eq!(
|
||||
parsed["command_timeout_ms"].as_integer(),
|
||||
Some(defaults.command_timeout.as_millis() as i64)
|
||||
);
|
||||
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())
|
||||
|
||||
@@ -51,26 +51,22 @@ impl DaemonConfig {
|
||||
.unwrap_or_else(|| defaults.state_file.clone());
|
||||
let state_file = resolve_path(&data_dir, state_file_raw);
|
||||
|
||||
let command_timeout = Duration::from_millis(
|
||||
args.command_timeout_ms
|
||||
.or(file.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.as_secs())
|
||||
.max(1),
|
||||
);
|
||||
|
||||
let observation_retention = Duration::from_secs(
|
||||
args.observation_retention_seconds
|
||||
.or(file.observation_retention_seconds)
|
||||
.unwrap_or(defaults.observation_retention.as_secs())
|
||||
.max(1),
|
||||
);
|
||||
let command_timeout = args
|
||||
.command_timeout_ms
|
||||
.map(Duration::from_millis)
|
||||
.or(file.command_timeout)
|
||||
.unwrap_or(defaults.command_timeout);
|
||||
let enroll_token_ttl = args
|
||||
.enroll_token_ttl_seconds
|
||||
.map(Duration::from_secs)
|
||||
.or(file.enroll_token_ttl)
|
||||
.unwrap_or(defaults.enroll_token_ttl);
|
||||
let history_retention = args
|
||||
.observation_retention_seconds
|
||||
.map(Duration::from_secs)
|
||||
.or(file.history_retention)
|
||||
.unwrap_or(defaults.history_retention);
|
||||
validate_durations(command_timeout, enroll_token_ttl, history_retention)?;
|
||||
|
||||
let pid_file_raw = args
|
||||
.pid_file
|
||||
@@ -101,7 +97,7 @@ impl DaemonConfig {
|
||||
state_file,
|
||||
command_timeout,
|
||||
enroll_token_ttl,
|
||||
observation_retention,
|
||||
history_retention,
|
||||
pid_file,
|
||||
ui_dist_dir,
|
||||
bootstrap_enroll_tokens,
|
||||
@@ -140,12 +136,12 @@ pub fn resolve_issue_token_settings(args: &IssueEnrollTokenArgs) -> Result<Issue
|
||||
&args.target,
|
||||
)?;
|
||||
|
||||
let ttl = Duration::from_secs(
|
||||
args.ttl_seconds
|
||||
.or(file.enroll_token_ttl_seconds)
|
||||
.unwrap_or(defaults.enroll_token_ttl.as_secs())
|
||||
.max(1),
|
||||
);
|
||||
let ttl = args
|
||||
.ttl_seconds
|
||||
.map(Duration::from_secs)
|
||||
.or(file.enroll_token_ttl)
|
||||
.unwrap_or(defaults.enroll_token_ttl);
|
||||
validate_durations(defaults.command_timeout, ttl, defaults.history_retention)?;
|
||||
|
||||
Ok(IssueTokenSettings {
|
||||
data_dir: state.data_dir,
|
||||
@@ -206,8 +202,52 @@ pub(crate) fn load_file_config(path: &Path) -> Result<FileConfig> {
|
||||
|
||||
let raw = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("failed to read config file {}", path.display()))?;
|
||||
toml::from_str::<FileConfig>(&raw)
|
||||
.with_context(|| format!("failed to parse config file {}", path.display()))
|
||||
let raw: toml::Value = toml::from_str(&raw)
|
||||
.with_context(|| format!("failed to parse config file {}", path.display()))?;
|
||||
let mut value = serde_json::to_value(raw)?;
|
||||
let deprecated =
|
||||
wakey_core::config_duration::normalize(&mut value, super::types::DURATION_KEYS)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
let config = serde_json::from_value(value)
|
||||
.with_context(|| format!("failed to parse config file {}", path.display()))?;
|
||||
for key in deprecated {
|
||||
// Daemon config is loaded before telemetry initialization. Keep these
|
||||
// warnings on stderr, including when init-config emits TOML on stdout.
|
||||
eprintln!(
|
||||
"warning: {}: `{}` is deprecated; use `{}` with a duration string",
|
||||
path.display(),
|
||||
key.old,
|
||||
key.new
|
||||
);
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub(crate) fn validate_durations(
|
||||
command_timeout: Duration,
|
||||
enroll_token_ttl: Duration,
|
||||
history_retention: Duration,
|
||||
) -> Result<()> {
|
||||
for (name, value) in [
|
||||
("command_timeout", command_timeout),
|
||||
("enroll_token_ttl", enroll_token_ttl),
|
||||
("history_retention", history_retention),
|
||||
] {
|
||||
anyhow::ensure!(!value.is_zero(), "{name} must be positive");
|
||||
anyhow::ensure!(
|
||||
std::time::Instant::now().checked_add(value).is_some(),
|
||||
"{name} exceeds the platform timer range"
|
||||
);
|
||||
}
|
||||
anyhow::ensure!(
|
||||
enroll_token_ttl.subsec_nanos() == 0,
|
||||
"enroll_token_ttl must use whole seconds"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
history_retention.subsec_nanos() == 0,
|
||||
"history_retention must use whole seconds"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve_admin_public_url(
|
||||
@@ -348,4 +388,42 @@ mod tests {
|
||||
defaults.telemetry.service_name
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_duration_policies_are_rejected_without_clamping() {
|
||||
use std::time::Duration;
|
||||
let defaults = &*DEFAULT_CONFIG;
|
||||
assert!(
|
||||
super::validate_durations(
|
||||
Duration::ZERO,
|
||||
defaults.enroll_token_ttl,
|
||||
defaults.history_retention
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
super::validate_durations(
|
||||
defaults.command_timeout,
|
||||
Duration::from_millis(500),
|
||||
defaults.history_retention
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
super::validate_durations(
|
||||
defaults.command_timeout,
|
||||
defaults.enroll_token_ttl,
|
||||
Duration::ZERO
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
super::validate_durations(
|
||||
Duration::from_secs(u64::MAX),
|
||||
defaults.enroll_token_ttl,
|
||||
defaults.history_retention
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ use std::sync::LazyLock;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use wakey_core::config_duration::{self, LegacyDuration};
|
||||
pub use wakey_core::telemetry::TelemetryConfig;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -14,7 +15,7 @@ pub struct DaemonConfig {
|
||||
pub state_file: PathBuf,
|
||||
pub command_timeout: Duration,
|
||||
pub enroll_token_ttl: Duration,
|
||||
pub observation_retention: Duration,
|
||||
pub history_retention: Duration,
|
||||
pub pid_file: PathBuf,
|
||||
pub ui_dist_dir: PathBuf,
|
||||
pub bootstrap_enroll_tokens: Vec<String>,
|
||||
@@ -24,14 +25,18 @@ pub struct DaemonConfig {
|
||||
/// 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)]
|
||||
#[serde(remote = "Self")]
|
||||
pub(crate) struct FileConfig {
|
||||
pub(crate) data_dir: Option<PathBuf>,
|
||||
pub(crate) bind: Option<String>,
|
||||
pub(crate) public_url: Option<String>,
|
||||
pub(crate) state_file: Option<PathBuf>,
|
||||
pub(crate) command_timeout_ms: Option<u64>,
|
||||
pub(crate) enroll_token_ttl_seconds: Option<u64>,
|
||||
pub(crate) observation_retention_seconds: Option<u64>,
|
||||
#[serde(default, with = "humantime_serde::option")]
|
||||
pub(crate) command_timeout: Option<Duration>,
|
||||
#[serde(default, with = "humantime_serde::option")]
|
||||
pub(crate) enroll_token_ttl: Option<Duration>,
|
||||
#[serde(default, with = "humantime_serde::option")]
|
||||
pub(crate) history_retention: Option<Duration>,
|
||||
pub(crate) pid_file: Option<PathBuf>,
|
||||
pub(crate) ui_dist_dir: Option<PathBuf>,
|
||||
#[serde(alias = "enroll_tokens")]
|
||||
@@ -39,6 +44,35 @@ pub(crate) struct FileConfig {
|
||||
pub(crate) telemetry: Option<FileTelemetryConfig>,
|
||||
}
|
||||
|
||||
pub(crate) const DURATION_KEYS: &[LegacyDuration] = &[
|
||||
LegacyDuration {
|
||||
old: "command_timeout_ms",
|
||||
new: "command_timeout",
|
||||
unit: Duration::from_millis(1),
|
||||
},
|
||||
LegacyDuration {
|
||||
old: "enroll_token_ttl_seconds",
|
||||
new: "enroll_token_ttl",
|
||||
unit: Duration::from_secs(1),
|
||||
},
|
||||
LegacyDuration {
|
||||
old: "observation_retention_seconds",
|
||||
new: "history_retention",
|
||||
unit: Duration::from_secs(1),
|
||||
},
|
||||
];
|
||||
|
||||
impl Serialize for FileConfig {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
Self::serialize(self, serializer)
|
||||
}
|
||||
}
|
||||
impl<'de> Deserialize<'de> for FileConfig {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
config_duration::deserialize_legacy(deserializer, DURATION_KEYS, Self::deserialize)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
|
||||
pub(crate) struct FileTelemetryConfig {
|
||||
// Missing fields must remain distinguishable from explicit values when
|
||||
@@ -60,7 +94,7 @@ pub(crate) static DEFAULT_CONFIG: LazyLock<DaemonConfig> = LazyLock::new(|| Daem
|
||||
state_file: "state.sqlite3".into(),
|
||||
command_timeout: Duration::from_millis(30_000),
|
||||
enroll_token_ttl: Duration::from_secs(86_400),
|
||||
observation_retention: Duration::from_secs(2_592_000),
|
||||
history_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(),
|
||||
@@ -100,7 +134,7 @@ mod tests {
|
||||
|
||||
let explicit = FileConfig {
|
||||
bootstrap_enroll_tokens: Some(Vec::new()),
|
||||
observation_retention_seconds: Some(0),
|
||||
history_retention: Some(std::time::Duration::ZERO),
|
||||
telemetry: Some(FileTelemetryConfig {
|
||||
json_logs: Some(false),
|
||||
console_filter: Some("info".into()),
|
||||
@@ -124,4 +158,32 @@ mod tests {
|
||||
assert!(rendered.contains("bootstrap_enroll_tokens = []"));
|
||||
assert!(!rendered.starts_with("enroll_tokens"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_durations_match_new_keys_and_emit_only_strings() {
|
||||
let legacy: FileConfig = toml::from_str("command_timeout_ms = 1234\nenroll_token_ttl_seconds = 3600\nobservation_retention_seconds = 2592000").unwrap();
|
||||
let canonical: FileConfig = toml::from_str(
|
||||
"command_timeout = '1234ms'\nenroll_token_ttl = '1h'\nhistory_retention = '30d'",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(legacy, canonical);
|
||||
let output = toml::to_string(&legacy).unwrap();
|
||||
for key in super::DURATION_KEYS {
|
||||
assert!(!output.contains(key.old));
|
||||
assert!(output.contains(key.new));
|
||||
}
|
||||
assert_eq!(toml::from_str::<FileConfig>(&output).unwrap(), canonical);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_durations_reject_duplicates_and_bare_numbers() {
|
||||
for key in super::DURATION_KEYS {
|
||||
let both = format!("{} = '1s'\n{} = 1", key.new, key.old);
|
||||
assert!(toml::from_str::<FileConfig>(&both).is_err());
|
||||
let number = format!("{} = 1", key.new);
|
||||
assert!(toml::from_str::<FileConfig>(&number).is_err());
|
||||
let negative = format!("{} = -1", key.old);
|
||||
assert!(toml::from_str::<FileConfig>(&negative).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -248,7 +248,7 @@ pub async fn serve(daemon: config::DaemonConfig) -> Result<()> {
|
||||
}
|
||||
Err(err) => warn!(error = %err, "periodic gc failed"),
|
||||
}
|
||||
match app_state.store.gc_retention(daemon.observation_retention).await {
|
||||
match app_state.store.gc_retention(daemon.history_retention).await {
|
||||
Ok(stats) => {
|
||||
if stats.audit_events_removed > 0 || stats.alert_transitions_removed > 0 {
|
||||
info!(
|
||||
|
||||
Reference in new issue
Block a user