feat(config): support human-readable durations with legacy compatibility

This commit is contained in:
lda committed 2026-10-10 23:37:04 +07:00 Verified
1 parent f91186a7b1
commit f098d85621
22 files changed
+715 -151

No files matched your search

+1
View File
@@ -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",
+3 -5
View File
@@ -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))) => {
+2
View File
@@ -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>,
+33 -23
View File
@@ -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())
+107 -29
View File
@@ -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()
);
}
}
+68 -6
View File
@@ -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());
}
}
}
+1 -1
View File
@@ -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!(