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

Generated
+19
View File
@@ -1031,6 +1031,22 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "humantime"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15"
[[package]]
name = "humantime-serde"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57a3db5ea5923d99402c94e9feb261dc5ee9b4efa158b0315f788cf549cc200c"
dependencies = [
"humantime",
"serde",
]
[[package]]
name = "hyper"
version = "1.9.0"
@@ -3615,6 +3631,7 @@ dependencies = [
"clap",
"futures-util",
"http",
"humantime-serde",
"macaddr",
"nix 0.31.2",
"reqwest",
@@ -3638,6 +3655,7 @@ dependencies = [
"axum",
"clap",
"futures-util",
"humantime-serde",
"macaddr",
"nix 0.31.2",
"reqwest",
@@ -3658,6 +3676,7 @@ name = "wakey-core"
version = "0.3.6"
dependencies = [
"anyhow",
"humantime",
"macaddr",
"opentelemetry",
"opentelemetry-appender-tracing",
+1 -1
View File
@@ -84,7 +84,7 @@ The agent keeps its terminal manager outside the control-WebSocket reconnect loo
Closing a session must terminate the entire PTY process group, not only the shell process. Cleanup should send a hangup first, then escalate to termination and forced kill after bounded grace periods. Explicit operator close, absolute session timeout, agent process exit, and machine reboot close the session.
The agent configuration sets `terminal.session_ttl_seconds` for newly created PTYs. The default is 43,200 seconds (12 hours); explicit zero disables automatic expiry. The agent owns and enforces this deadline locally because PTYs survive browser and control-plane disconnection. It advertises the policy to the control plane and includes the creation-time TTL in terminal inventory, allowing CC to mirror expiry and preserve it across CC restart. Missing fields from older agents retain the 12-hour default. Positive values are exact and values that cannot fit the platform timer are rejected rather than clamped.
The agent configuration sets `terminal.session_ttl` (for example, `"12h"`) for newly created PTYs. The default is 12 hours; `"0s"` disables automatic expiry. Legacy `session_ttl_seconds` is accepted with a deprecation warning. The wire protocol still uses integer seconds, so fractional-second TTLs are rejected. The agent owns and enforces this deadline locally because PTYs survive browser and control-plane disconnection. It advertises the policy to the control plane and includes the creation-time TTL in terminal inventory, allowing CC to mirror expiry and preserve it across CC restart. Missing fields from older agents retain the 12-hour default. Positive values are exact and values that cannot fit the platform timer are rejected rather than clamped.
An agent process restart or machine reboot still ends every terminal session because PTY file descriptors cannot be recovered by a new process. Surviving that boundary would require an external session host such as `tmux` or a separate terminal daemon and is not part of this design.
+74
View File
@@ -0,0 +1,74 @@
# Configuration Durations
Agent and control-plane TOML duration settings use strings with explicit units.
Runtime code receives `std::time::Duration`; API and WebSocket numeric fields
such as `timeout_ms`, `session_ttl_seconds`, and `latency_ms` are unchanged.
## Agent
```toml
reconnect_base = "1s"
reconnect_max = "30s"
observation_sync_interval = "60s"
observation_retention = "7d"
[terminal]
session_ttl = "12h"
```
`observation_retention` prunes the agent's local hotplug memory during inventory
collection. It does not delete the control plane's last snapshot for an agent.
`"0d"` disables this local pruning. `terminal.session_ttl = "0s"` disables PTY
expiry. Reconnect and sync timers must be positive and fit the platform timer.
Terminal TTL and hook retention must resolve to whole seconds because their
protocol/storage timestamps use seconds. Reconnect and sync intervals can use
subsecond values.
Reconnect scheduling retains its existing 100ms minimum backoff.
## Control Plane
```toml
command_timeout = "30s"
enroll_token_ttl = "1d"
history_retention = "30d"
```
All three settings must be positive. Enrollment TTL and history retention must
resolve to whole seconds. Command timeout can use subsecond values.
`history_retention` deletes old audit events and alert transitions, not device
snapshots or saved identities. Device retention is a separate follow-up.
## Legacy Files
| Old Key | New Key | Numeric Legacy Unit |
| --- | --- | --- |
| Agent `reconnect_base_ms` | `reconnect_base` | milliseconds |
| Agent `reconnect_max_ms` | `reconnect_max` | milliseconds |
| Agent `observation_sync_interval_seconds` | `observation_sync_interval` | seconds |
| Agent `observation_retention_days` | `observation_retention` | days |
| Agent `terminal.session_ttl_seconds` | `terminal.session_ttl` | seconds |
| CC `command_timeout_ms` | `command_timeout` | milliseconds |
| CC `enroll_token_ttl_seconds` | `enroll_token_ttl` | seconds |
| CC `observation_retention_seconds` | `history_retention` | seconds |
Old keys accept non-negative integers in their original units, or explicit
duration strings, and produce a warning on stderr when loaded. New keys require
strings; bare numbers are rejected. Supplying both keys for the same setting is
an error, even when their values agree. Negative, malformed, and overflowing
values are rejected; zero active timers are rejected rather than silently
clamped. No secrets are included in deprecation warnings.
`init-config --from-config ...` reads legacy files and writes only canonical
keys and formatted duration strings. Existing numeric CLI flags are unchanged
in this slice, and their overrides also serialize in the new format.
For example, legacy `reconnect_base_ms = 1500` is read as 1500 milliseconds
and written back as `reconnect_base = "1s 500ms"`. New-key
`reconnect_base = 1500` is rejected: no implicit numeric unit is assigned.
Serialization always writes duration strings, regardless of the accepted input
format, using `humantime-serde`.
The parser is [humantime](https://docs.rs/humantime/latest/humantime/fn.parse_duration.html).
Examples include `"14d"`, `"168h"`, `"250ms"`, and `"1h 30m"`. These are elapsed
durations, not calendar scheduling rules; use days/hours for retention rather
than assuming calendar-month semantics.
+4
View File
@@ -22,6 +22,8 @@ live browser recovery and shutdown behavior are not established by those checks.
## Device Management And Presentation
- [x] Endpoint-derived presence on snapshot writes and fleet reads; endpoint-less legacy rows retain their summary. CLI ordering matches the core signal order.
- [x] Make **forget remembered device** reachable in the frontend, with confirmation. The existing backend removes the known identity and its identifiers, not agent observations. An observed device may reappear as unknown.
- [x] Add remembered-device rename support, with backend validation and frontend editing (`PATCH /api/v1/control/devices/{device_id}`). Pinned state and identifiers are preserved.
- [ ] Check and complete editing of remembered-device notes.
@@ -34,6 +36,8 @@ of remembered identifiers based on observation expiry.
## Telemetry Follow-Ups
- [x] Human-readable agent/CC config durations with unit-aware legacy loading, stderr deprecation warnings, and canonical init-config output. See [configuration durations](config-durations.md). History retention remains separate from future device expiry.
- [x] Separate console, OTLP log, and trace filters using per-layer filtering. Explicit signal config overrides `RUST_LOG`, then CLI verbosity supplies defaults. Unconfigured defaults remain info-level; exporter-failure logs stay local. Live validation pending.
- [ ] Record hostname-resolution success/failure without exporting hostnames or resolved addresses. Current spans show timing but do not explicitly classify the result.
- [ ] Investigate the slow resolver with a reproducible device-side check. Timing isolated the phase, not the cause; do not treat instrumentation as a fix.
+1
View File
@@ -9,6 +9,7 @@ anyhow = "1"
clap = { version = "4", features = ["derive"] }
futures-util = "0.3"
http = "1"
humantime-serde = "1"
macaddr = { version = "1", features = ["serde", "serde_std"] }
nix = { version = "0.31", default-features = false, features = [
"signal",
+221 -51
View File
@@ -3,6 +3,8 @@ use serde::{Deserialize, Serialize};
use std::fmt;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use std::time::Duration;
use wakey_core::config_duration::{self, LegacyDuration};
use crate::protocol::{
DEFAULT_TERMINAL_MAX_SESSIONS, DEFAULT_TERMINAL_SESSION_TTL_SECONDS,
@@ -19,19 +21,25 @@ const DEFAULT_MAC_NAME_CACHE_PATH: &str = "/tmp/wakey_mac_names.json";
const DEFAULT_OBSERVATION_STORE_PATH: &str = "/tmp/wakey_observations.json";
pub const DEFAULT_OBSERVATION_RETENTION_DAYS: u64 = 7;
// Derive canonical serde functions once; the trait adapter below translates
// legacy keys without maintaining another copy of every config field.
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(remote = "Self")]
pub struct AgentConfig {
pub server_url: String,
pub agent_id: String,
pub agent_token: String,
#[serde(default = "default_reconnect_base_ms")]
pub reconnect_base_ms: u64,
#[serde(default = "default_reconnect_max_ms")]
pub reconnect_max_ms: u64,
#[serde(default = "default_observation_sync_interval_seconds")]
pub observation_sync_interval_seconds: u64,
#[serde(default = "default_observation_retention_days")]
pub observation_retention_days: u64,
#[serde(default = "default_reconnect_base", with = "humantime_serde")]
pub reconnect_base: Duration,
#[serde(default = "default_reconnect_max", with = "humantime_serde")]
pub reconnect_max: Duration,
#[serde(
default = "default_observation_sync_interval",
with = "humantime_serde"
)]
pub observation_sync_interval: Duration,
#[serde(default = "default_observation_retention", with = "humantime_serde")]
pub observation_retention: Duration,
#[serde(default = "default_pid_file")]
pub pid_file: PathBuf,
#[serde(default = "default_dhcp_leases_path")]
@@ -47,7 +55,7 @@ pub struct AgentConfig {
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
#[serde(remote = "Self", deny_unknown_fields)]
pub struct TerminalConfig {
#[serde(default)]
pub enabled: bool,
@@ -60,8 +68,8 @@ pub struct TerminalConfig {
#[serde(default = "default_terminal_max_sessions")]
pub max_sessions: usize,
/// Maximum lifetime of a PTY process. Zero disables automatic expiry.
#[serde(default = "default_terminal_session_ttl_seconds")]
pub session_ttl_seconds: u64,
#[serde(default = "default_terminal_session_ttl", with = "humantime_serde")]
pub session_ttl: Duration,
}
impl Default for TerminalConfig {
@@ -72,7 +80,7 @@ impl Default for TerminalConfig {
args: Vec::new(),
current_dir: None,
max_sessions: default_terminal_max_sessions(),
session_ttl_seconds: default_terminal_session_ttl_seconds(),
session_ttl: default_terminal_session_ttl(),
}
}
}
@@ -81,10 +89,10 @@ pub static DEFAULT_CONFIG: LazyLock<AgentConfig> = LazyLock::new(|| AgentConfig
server_url: "https://wakey.ldlda.com".to_string(),
agent_id: "REPLACE_ME_AGENT_ID".to_string(),
agent_token: "REPLACE_ME_AGENT_TOKEN".to_string(),
reconnect_base_ms: default_reconnect_base_ms(),
reconnect_max_ms: default_reconnect_max_ms(),
observation_sync_interval_seconds: default_observation_sync_interval_seconds(),
observation_retention_days: default_observation_retention_days(),
reconnect_base: default_reconnect_base(),
reconnect_max: default_reconnect_max(),
observation_sync_interval: default_observation_sync_interval(),
observation_retention: default_observation_retention(),
pid_file: default_pid_file(),
dhcp_leases_path: default_dhcp_leases_path(),
mac_name_cache_path: default_mac_name_cache_path(),
@@ -99,16 +107,10 @@ impl fmt::Debug for AgentConfig {
.field("server_url", &self.server_url)
.field("agent_id", &self.agent_id)
.field("agent_token", &"<redacted>")
.field("reconnect_base_ms", &self.reconnect_base_ms)
.field("reconnect_max_ms", &self.reconnect_max_ms)
.field(
"observation_sync_interval_seconds",
&self.observation_sync_interval_seconds,
)
.field(
"observation_retention_days",
&self.observation_retention_days,
)
.field("reconnect_base", &self.reconnect_base)
.field("reconnect_max", &self.reconnect_max)
.field("observation_sync_interval", &self.observation_sync_interval)
.field("observation_retention", &self.observation_retention)
.field("pid_file", &self.pid_file)
.field("dhcp_leases_path", &self.dhcp_leases_path)
.field("mac_name_cache_path", &self.mac_name_cache_path)
@@ -119,20 +121,20 @@ impl fmt::Debug for AgentConfig {
}
}
const fn default_reconnect_base_ms() -> u64 {
1_000
const fn default_reconnect_base() -> Duration {
Duration::from_secs(1)
}
const fn default_reconnect_max_ms() -> u64 {
30_000
const fn default_reconnect_max() -> Duration {
Duration::from_secs(30)
}
const fn default_observation_sync_interval_seconds() -> u64 {
60
const fn default_observation_sync_interval() -> Duration {
Duration::from_secs(60)
}
const fn default_observation_retention_days() -> u64 {
DEFAULT_OBSERVATION_RETENTION_DAYS
const fn default_observation_retention() -> Duration {
Duration::from_secs(DEFAULT_OBSERVATION_RETENTION_DAYS * 86_400)
}
fn default_pid_file() -> PathBuf {
@@ -159,17 +161,43 @@ const fn default_terminal_max_sessions() -> usize {
DEFAULT_TERMINAL_MAX_SESSIONS
}
const fn default_terminal_session_ttl_seconds() -> u64 {
DEFAULT_TERMINAL_SESSION_TTL_SECONDS
const fn default_terminal_session_ttl() -> Duration {
Duration::from_secs(DEFAULT_TERMINAL_SESSION_TTL_SECONDS)
}
impl TerminalConfig {
pub(crate) fn session_ttl(&self) -> Result<Option<std::time::Duration>> {
checked_terminal_session_ttl(self.session_ttl_seconds).map_err(anyhow::Error::msg)
anyhow::ensure!(
self.session_ttl.subsec_nanos() == 0,
"terminal.session_ttl must use whole seconds (the terminal protocol uses seconds)"
);
checked_terminal_session_ttl(self.session_ttl.as_secs()).map_err(anyhow::Error::msg)
}
}
impl AgentConfig {
/// Validate scheduling bounds and second-based storage/protocol boundaries.
/// Zero is allowed for hook retention and terminal TTL, not active timers.
fn validate(&self) -> Result<()> {
for (name, duration) in [
("reconnect_base", self.reconnect_base),
("reconnect_max", self.reconnect_max),
("observation_sync_interval", self.observation_sync_interval),
] {
anyhow::ensure!(!duration.is_zero(), "{name} must be positive");
anyhow::ensure!(
std::time::Instant::now().checked_add(duration).is_some(),
"{name} exceeds the platform timer range"
);
}
anyhow::ensure!(
self.observation_retention.subsec_nanos() == 0,
"observation_retention must use whole seconds (hook timestamps use seconds)"
);
self.terminal.session_ttl()?;
Ok(())
}
pub fn local_path_envs(&self) -> Vec<(&'static str, &Path)> {
vec![
(WAKEY_DHCP_LEASES_ENV, self.dhcp_leases_path.as_path()),
@@ -191,13 +219,99 @@ pub fn apply_local_path_env_to_command(cmd: &mut std::process::Command, config:
pub fn load_config(path: &Path) -> Result<AgentConfig> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("failed to read agent config {}", path.display()))?;
let config: AgentConfig = toml::from_str(&content)
let raw: toml::Value = toml::from_str(&content)
.with_context(|| format!("failed to parse agent config {}", path.display()))?;
config.terminal.session_ttl()?;
let mut value = serde_json::to_value(raw)?;
let mut deprecated = config_duration::normalize(&mut value, AGENT_DURATION_KEYS)
.map_err(anyhow::Error::msg)?
.into_iter()
.map(|key| ("", key))
.collect::<Vec<_>>();
if let Some(terminal) = value.get_mut("terminal") {
deprecated.extend(
config_duration::normalize(terminal, TERMINAL_DURATION_KEYS)
.map_err(anyhow::Error::msg)?
.into_iter()
.map(|key| ("terminal.", key)),
);
}
let config: AgentConfig = serde_json::from_value(value)
.with_context(|| format!("failed to parse agent config {}", path.display()))?;
config.validate()?;
for (prefix, key) in deprecated {
// Initial config loading precedes tracing setup; stderr also keeps
// init-config stdout valid TOML and never prints config secrets.
eprintln!(
"warning: {}: `{prefix}{}` is deprecated; use `{prefix}{}` with a duration string",
path.display(),
key.old,
key.new
);
}
Ok(config)
}
const AGENT_DURATION_KEYS: &[LegacyDuration] = &[
LegacyDuration {
old: "reconnect_base_ms",
new: "reconnect_base",
unit: Duration::from_millis(1),
},
LegacyDuration {
old: "reconnect_max_ms",
new: "reconnect_max",
unit: Duration::from_millis(1),
},
LegacyDuration {
old: "observation_sync_interval_seconds",
new: "observation_sync_interval",
unit: Duration::from_secs(1),
},
LegacyDuration {
old: "observation_retention_days",
new: "observation_retention",
unit: Duration::from_secs(86_400),
},
];
const TERMINAL_DURATION_KEYS: &[LegacyDuration] = &[LegacyDuration {
old: "session_ttl_seconds",
new: "session_ttl",
unit: Duration::from_secs(1),
}];
impl Serialize for AgentConfig {
fn serialize<S: serde::Serializer>(
&self,
serializer: S,
) -> std::result::Result<S::Ok, S::Error> {
Self::serialize(self, serializer)
}
}
impl<'de> Deserialize<'de> for AgentConfig {
fn deserialize<D: serde::Deserializer<'de>>(
deserializer: D,
) -> std::result::Result<Self, D::Error> {
config_duration::deserialize_legacy(deserializer, AGENT_DURATION_KEYS, Self::deserialize)
}
}
impl Serialize for TerminalConfig {
fn serialize<S: serde::Serializer>(
&self,
serializer: S,
) -> std::result::Result<S::Ok, S::Error> {
Self::serialize(self, serializer)
}
}
impl<'de> Deserialize<'de> for TerminalConfig {
fn deserialize<D: serde::Deserializer<'de>>(
deserializer: D,
) -> std::result::Result<Self, D::Error> {
config_duration::deserialize_legacy(deserializer, TERMINAL_DURATION_KEYS, Self::deserialize)
}
}
pub fn save_config(path: &Path, config: &AgentConfig) -> Result<()> {
config.validate()?;
let content = toml::to_string_pretty(config).context("failed to serialize agent config")?;
write_config_atomically(path, &content)
}
@@ -262,6 +376,62 @@ fn write_config_atomically(path: &Path, content: &str) -> Result<()> {
mod tests {
use super::*;
const HEADER: &str =
"server_url = 'https://example.com'\nagent_id = 'agent-1'\nagent_token = 'secret'\n";
#[test]
fn legacy_duration_keys_convert_and_serialize_canonically() {
let legacy: AgentConfig = toml::from_str(&format!("{HEADER}\nreconnect_base_ms = 123\nreconnect_max_ms = 456\nobservation_sync_interval_seconds = 7\nobservation_retention_days = 3\n[terminal]\nsession_ttl_seconds = 600\n")).unwrap();
let canonical: AgentConfig = toml::from_str(&format!("{HEADER}\nreconnect_base = '123ms'\nreconnect_max = '456ms'\nobservation_sync_interval = '7s'\nobservation_retention = '3d'\n[terminal]\nsession_ttl = '10m'\n")).unwrap();
assert_eq!(legacy, canonical);
legacy.validate().unwrap();
let output = toml::to_string(&legacy).unwrap();
for key in AGENT_DURATION_KEYS {
assert!(!output.contains(key.old));
assert!(output.contains(key.new));
}
assert!(!output.contains("session_ttl_seconds"));
assert_eq!(toml::from_str::<AgentConfig>(&output).unwrap(), canonical);
}
#[test]
fn config_rejects_ambiguous_units_and_invalid_timer_values() {
for fields in [
"reconnect_base = '1s'\nreconnect_base_ms = 1000",
"observation_retention = '7d'\nobservation_retention_days = 7",
"reconnect_base = 1000",
"observation_retention_days = -1",
"observation_retention_days = 9223372036854775807",
"reconnect_base = 'nonsense'",
"[terminal]\nsession_ttl = '1h'\nsession_ttl_seconds = 3600",
] {
assert!(
toml::from_str::<AgentConfig>(&format!("{HEADER}{fields}")).is_err(),
"{fields}"
);
}
for fields in [
"observation_sync_interval = '0s'",
"reconnect_base = '0s'",
"[terminal]\nsession_ttl = '500ms'",
] {
let config: AgentConfig = toml::from_str(&format!("{HEADER}{fields}")).unwrap();
assert!(config.validate().is_err(), "{fields}");
}
let config: AgentConfig = toml::from_str(&format!(
"{HEADER}observation_retention = '0d'\n[terminal]\nsession_ttl = '0s'"
))
.unwrap();
config.validate().unwrap();
assert!(config.observation_retention.is_zero());
assert_eq!(config.terminal.session_ttl().unwrap(), None);
let oversized = AgentConfig {
reconnect_max: Duration::from_secs(u64::MAX),
..config
};
assert!(oversized.validate().is_err());
}
#[test]
fn config_roundtrip() {
let dir = std::env::temp_dir().join(format!("wakey-agent-config-{}", std::process::id()));
@@ -270,10 +440,10 @@ mod tests {
server_url: "https://example.com".into(),
agent_id: "agent-1".into(),
agent_token: "secret".into(),
reconnect_base_ms: 123,
reconnect_max_ms: 456,
observation_sync_interval_seconds: 7,
observation_retention_days: 3,
reconnect_base: Duration::from_millis(123),
reconnect_max: Duration::from_millis(456),
observation_sync_interval: Duration::from_secs(7),
observation_retention: Duration::from_secs(3 * 86_400),
pid_file: "/tmp/test-wakey-agent.pid".into(),
dhcp_leases_path: "/tmp/test-dhcp.leases".into(),
mac_name_cache_path: "/tmp/test-names.json".into(),
@@ -293,7 +463,7 @@ mod tests {
args: vec!["-l".into()],
current_dir: Some("/tmp".into()),
max_sessions: 2,
session_ttl_seconds: 600,
session_ttl: Duration::from_secs(600),
},
};
@@ -323,8 +493,8 @@ agent_token = "secret"
.expect("config should parse");
assert_eq!(
config.observation_retention_days,
DEFAULT_OBSERVATION_RETENTION_DAYS
config.observation_retention,
DEFAULT_CONFIG.observation_retention
);
assert_eq!(config.terminal, TerminalConfig::default());
}
@@ -348,8 +518,8 @@ max_sessions = 2
assert!(config.terminal.args.is_empty());
assert!(config.terminal.current_dir.is_none());
assert_eq!(
config.terminal.session_ttl_seconds,
DEFAULT_TERMINAL_SESSION_TTL_SECONDS
config.terminal.session_ttl,
TerminalConfig::default().session_ttl
);
}
@@ -374,18 +544,18 @@ max_session = 67
#[test]
fn terminal_ttl_zero_is_unlimited_and_positive_is_exact() {
let mut terminal = TerminalConfig {
session_ttl_seconds: 0,
session_ttl: Duration::ZERO,
..TerminalConfig::default()
};
assert_eq!(terminal.session_ttl().expect("unlimited TTL"), None);
terminal.session_ttl_seconds = 67;
terminal.session_ttl = Duration::from_secs(67);
assert_eq!(
terminal.session_ttl().expect("finite TTL"),
Some(std::time::Duration::from_secs(67))
);
terminal.session_ttl_seconds = u64::MAX;
terminal.session_ttl = Duration::from_secs(u64::MAX);
assert!(terminal.session_ttl().is_err());
}
}
+2 -2
View File
@@ -89,9 +89,9 @@ pub async fn inventory_for_config(
.instrument(tracing::info_span!("inventory_dhcp_leases"))
.await?;
tracing::Span::current().record("leases", leases.len());
match wakey::wakey_linux::observations::prune_stale_observations_from_path(
match wakey::wakey_linux::observations::prune_expired(
&config.observation_store_path,
config.observation_retention_days,
config.observation_retention,
)
.instrument(tracing::info_span!("inventory_observation_prune"))
.await
+9 -6
View File
@@ -143,10 +143,10 @@ mod tests {
server_url: "https://old.example.com".into(),
agent_id: "old-agent".into(),
agent_token: "old-token".into(),
reconnect_base_ms: 2_000,
reconnect_max_ms: 60_000,
observation_sync_interval_seconds: 30,
observation_retention_days: 11,
reconnect_base: std::time::Duration::from_secs(2),
reconnect_max: std::time::Duration::from_secs(60),
observation_sync_interval: std::time::Duration::from_secs(30),
observation_retention: std::time::Duration::from_secs(11 * 86_400),
pid_file: "/tmp/custom-wakey-agent.pid".into(),
dhcp_leases_path: "/tmp/custom-dhcp.leases".into(),
mac_name_cache_path: "/tmp/custom-names.json".into(),
@@ -164,7 +164,7 @@ mod tests {
args: vec!["-l".into()],
current_dir: Some("/root".into()),
max_sessions: 2,
session_ttl_seconds: 600,
session_ttl: std::time::Duration::from_secs(600),
},
};
@@ -176,7 +176,10 @@ mod tests {
assert_eq!(config.agent_id, "agent-123");
assert_eq!(config.agent_token, "token-xyz");
assert_eq!(config.server_url, "https://control.example.com");
assert_eq!(config.observation_retention_days, 11);
assert_eq!(
config.observation_retention,
base_config.observation_retention
);
assert_eq!(config.pid_file, base_config.pid_file);
assert_eq!(config.dhcp_leases_path, base_config.dhcp_leases_path);
assert_eq!(config.terminal, base_config.terminal);
+4 -1
View File
@@ -290,7 +290,10 @@ fn init_config(args: InitConfigArgs) -> Result<()> {
cfg.agent_token = agent_token;
}
if let Some(days) = args.observation_retention_days {
cfg.observation_retention_days = days;
cfg.observation_retention = std::time::Duration::from_secs(
days.checked_mul(86_400)
.ok_or_else(|| anyhow::anyhow!("observation retention is too large"))?,
);
}
if let Some(path) = &args.config {
+29 -17
View File
@@ -18,16 +18,16 @@ pub async fn run(config: AgentConfig) -> Result<()> {
// Terminal workers belong to the agent process, not a single control socket.
// Keeping this manager outside the reconnect loop lets PTYs survive CC loss.
let (terminal_manager, mut terminal_events) = TerminalManager::new(&config);
let mut backoff = config.reconnect_base_ms.max(100);
let mut backoff = config.reconnect_base.max(Duration::from_millis(100));
loop {
match run_once(&config, &terminal_manager, &mut terminal_events).await {
Ok(()) => {
backoff = config.reconnect_base_ms.max(100);
backoff = config.reconnect_base.max(Duration::from_millis(100));
}
Err(err) => {
warn!(agent_id = %config.agent_id, error = %err, backoff_ms = backoff, "agent session ended; reconnecting");
sleep(Duration::from_millis(backoff)).await;
backoff = next_backoff_ms(backoff, config.reconnect_max_ms);
warn!(agent_id = %config.agent_id, error = %err, backoff_ms = backoff.as_millis() as u64, "agent session ended; reconnecting");
sleep(backoff).await;
backoff = next_backoff(backoff, config.reconnect_max);
}
}
}
@@ -94,7 +94,7 @@ async fn run_once(
.enabled
.then_some(TerminalCapabilityOptions {
max_sessions: config.terminal.max_sessions.max(1),
session_ttl_seconds: config.terminal.session_ttl_seconds,
session_ttl_seconds: config.terminal.session_ttl.as_secs(),
}),
},
},
@@ -117,9 +117,7 @@ async fn run_once(
let mut heartbeat = interval(Duration::from_secs(30));
heartbeat.set_missed_tick_behavior(MissedTickBehavior::Skip);
let mut snapshot_sync = interval(Duration::from_secs(
config.observation_sync_interval_seconds.max(1),
));
let mut snapshot_sync = interval(config.observation_sync_interval);
snapshot_sync.set_missed_tick_behavior(MissedTickBehavior::Skip);
snapshot_sync.reset();
loop {
@@ -238,9 +236,9 @@ where
Ok(count)
}
pub fn next_backoff_ms(current_ms: u64, max_ms: u64) -> u64 {
let cap = max_ms.max(current_ms);
current_ms.saturating_mul(2).min(cap)
pub fn next_backoff(current: Duration, max: Duration) -> Duration {
let cap = max.max(current);
current.saturating_mul(2).min(cap)
}
async fn handle_server_message<S>(
@@ -474,7 +472,7 @@ mod tests {
use serde_json::Value;
use tokio_tungstenite::tungstenite::Message;
use super::{next_backoff_ms, send_command_result, websocket_url};
use super::{next_backoff, send_command_result, websocket_url};
use crate::protocol::{CommandResult, RequestId};
struct RecordingSink {
@@ -533,14 +531,28 @@ mod tests {
#[test]
fn backoff_doubles_until_cap() {
assert_eq!(next_backoff_ms(1_000, 30_000), 2_000);
assert_eq!(next_backoff_ms(16_000, 30_000), 30_000);
assert_eq!(next_backoff_ms(30_000, 30_000), 30_000);
use std::time::Duration;
assert_eq!(
next_backoff(Duration::from_secs(1), Duration::from_secs(30)),
Duration::from_secs(2)
);
assert_eq!(
next_backoff(Duration::from_secs(16), Duration::from_secs(30)),
Duration::from_secs(30)
);
assert_eq!(
next_backoff(Duration::from_secs(30), Duration::from_secs(30)),
Duration::from_secs(30)
);
}
#[test]
fn backoff_never_shrinks_when_max_is_lower() {
assert_eq!(next_backoff_ms(8_000, 1_000), 8_000);
use std::time::Duration;
assert_eq!(
next_backoff(Duration::from_secs(8), Duration::from_secs(1)),
Duration::from_secs(8)
);
}
#[tokio::test]
+1 -1
View File
@@ -224,7 +224,7 @@ impl TerminalManager {
cancel: Some(cancel_tx),
relay_credentials: relay_tx.clone(),
created_at_unix,
session_ttl_seconds: config.terminal.session_ttl_seconds,
session_ttl_seconds: config.terminal.session_ttl.as_secs(),
},
);
}
+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!(
+2
View File
@@ -17,6 +17,8 @@ time = { version = "0.3", features = ["formatting", "local-offset"], optional =
tonic = { version = "0.14", default-features = false, optional = true }
macaddr = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
humantime = "2"
serde_with = { version = "3", features = ["json"] }
strum = { version = "0", features = ["derive", "strum_macros"] }
thiserror = "2"
+114
View File
@@ -0,0 +1,114 @@
//! Human-readable config durations and the unit-aware legacy key adapter.
//! Protocol numbers and metric units deliberately do not use this adapter.
use std::time::Duration;
use serde::{Deserialize, Deserializer, de::Error};
use serde_json::Value;
#[derive(Clone, Copy)]
pub struct LegacyDuration {
pub old: &'static str,
pub new: &'static str,
pub unit: Duration,
}
/// Normalize one config table before normal serde decoding. The returned keys
/// let loaders warn only after validation, even before tracing is initialized.
pub fn normalize(
value: &mut Value,
keys: &[LegacyDuration],
) -> Result<Vec<LegacyDuration>, String> {
let table = value.as_object_mut().ok_or("expected a config table")?;
let mut deprecated = Vec::new();
for key in keys {
if table.contains_key(key.old) && table.contains_key(key.new) {
return Err(format!(
"configure only one of `{}` and `{}`",
key.old, key.new
));
}
if let Some(old) = table.remove(key.old) {
let duration = if let Some(number) = old.as_u64() {
// u64 multiplication must not wrap for large legacy day values.
let nanos = key
.unit
.as_nanos()
.checked_mul(u128::from(number))
.ok_or_else(|| format!("`{}` is too large", key.old))?;
let seconds = u64::try_from(nanos / 1_000_000_000)
.map_err(|_| format!("`{}` is too large", key.old))?;
Duration::new(seconds, (nanos % 1_000_000_000) as u32)
} else if let Some(text) = old.as_str() {
humantime::parse_duration(text).map_err(|err| format!("`{}`: {err}", key.old))?
} else {
return Err(format!(
"`{}` requires a non-negative integer or duration string",
key.old
));
};
table.insert(
key.new.into(),
Value::String(humantime::format_duration(duration).to_string()),
);
deprecated.push(*key);
}
if let Some(value) = table.get(key.new) {
let text = value
.as_str()
.ok_or_else(|| format!("`{}` requires a duration string with units", key.new))?;
humantime::parse_duration(text).map_err(|err| format!("`{}`: {err}", key.new))?;
}
}
Ok(deprecated)
}
/// Decode through the canonical serde definition after translating old keys.
/// The callback is a serde-generated associated function (`remote = "Self"`),
/// avoiding a second copy of every unrelated config field in a legacy DTO.
pub fn deserialize_legacy<'de, D, T>(
deserializer: D,
keys: &[LegacyDuration],
decode: impl FnOnce(Value) -> Result<T, serde_json::Error>,
) -> Result<T, D::Error>
where
D: Deserializer<'de>,
{
let mut value = Value::deserialize(deserializer)?;
normalize(&mut value, keys).map_err(D::Error::custom)?;
decode(value).map_err(D::Error::custom)
}
#[cfg(test)]
mod tests {
use super::*;
const KEYS: &[LegacyDuration] = &[LegacyDuration {
old: "interval_ms",
new: "interval",
unit: Duration::from_millis(1),
}];
#[test]
fn legacy_units_are_exact_and_conflicts_are_rejected() {
let mut value = serde_json::json!({"interval_ms": 1234});
assert_eq!(normalize(&mut value, KEYS).unwrap().len(), 1);
assert_eq!(
humantime::parse_duration(value["interval"].as_str().unwrap()).unwrap(),
Duration::from_millis(1234)
);
assert!(
normalize(
&mut serde_json::json!({"interval_ms": 1, "interval": "1s"}),
KEYS
)
.is_err()
);
for invalid in [
serde_json::json!({"interval": 5}),
serde_json::json!({"interval_ms": -1}),
serde_json::json!({"interval": "potato"}),
] {
assert!(normalize(&mut invalid.clone(), KEYS).is_err());
}
}
}
+1
View File
@@ -1,5 +1,6 @@
//! Shared domain types and parsing for DHCP, neighbors, interfaces, devices, and wake flows.
pub mod config_duration;
pub mod model;
pub mod parse;
+18 -8
View File
@@ -165,17 +165,27 @@ pub async fn prune_stale_observations_from_path(
path: impl AsRef<Path>,
retention_days: u64,
) -> io::Result<usize> {
if retention_days == 0 {
return Ok(0);
}
let max_age_seconds = retention_days.saturating_mul(24 * 60 * 60);
prune_observations_older_than_from_path(path, now_unix().saturating_sub(max_age_seconds)).await
prune_expired(
path,
std::time::Duration::from_secs(retention_days.saturating_mul(24 * 60 * 60)),
)
.await
}
async fn prune_observations_older_than_from_path(
/// Remove hook observations older than `retention` from the given store.
/// Zero disables expiry; age is measured from each row's last-seen timestamp.
pub async fn prune_expired(
path: impl AsRef<Path>,
cutoff_unix: u64,
retention: std::time::Duration,
) -> io::Result<usize> {
if retention.is_zero() {
return Ok(0);
}
let max_age_seconds = retention.as_secs();
prune_before(path, now_unix().saturating_sub(max_age_seconds)).await
}
async fn prune_before(path: impl AsRef<Path>, cutoff_unix: u64) -> io::Result<usize> {
let path = path.as_ref();
let mut store = load_observation_store_from_path(path).await?;
let before = store.dhcp_clients.len() + store.neighbors.len();
@@ -648,7 +658,7 @@ mod tests {
.await
.expect("fixture should write");
let removed = prune_observations_older_than_from_path(&observation_path, 15)
let removed = prune_before(&observation_path, 15)
.await
.expect("prune should succeed");