feat(state): expire stale agent devices with configurable retention

This commit is contained in:
lda committed 2026-10-10 23:59:12 +07:00 Verified
1 parent f098d85621
commit ce3ef13b73
9 files changed
+274 -3

No files matched your search

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM agent_devices WHERE last_seen_unix < ?1",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "cc4b885c31f965013ccaa6f8ae33f6c8d68898654fc0873682c8212339088306"
}
+14 -1
View File
@@ -31,12 +31,25 @@ Reconnect scheduling retains its existing 100ms minimum backoff.
command_timeout = "30s"
enroll_token_ttl = "1d"
history_retention = "30d"
device_retention = "60d"
```
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.
snapshots or saved identities.
`device_retention` expires agent-collected device rows and their child endpoints,
addresses, names and facts. The default is `"60d"`; `"0d"` disables expiry.
It must resolve to whole seconds. Age is measured from the last CC-received
snapshot that included the device, not endpoint timestamps or online presence.
An offline device included in new snapshots therefore stays retained. Saved
devices and their manually attached identifiers are never deleted by this cleanup.
Complete snapshots still remove devices omitted by that agent immediately;
retention handles old rows left behind when an agent stops syncing.
Expired rows are removed by the Unix daemon's existing maintenance loop every
five minutes, including an initial tick at startup. Restart CC to apply changes
to this setting; the state-only reload does not reload configuration.
## Legacy Files
+2 -1
View File
@@ -36,7 +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] 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).
- [x] CC device retention by last snapshot inclusion (`device_retention = "60d"`, zero disables), independent of audit history and preserving saved identities.
- [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.
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM agent_devices WHERE last_seen_unix < ?1",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "cc4b885c31f965013ccaa6f8ae33f6c8d68898654fc0873682c8212339088306"
}
+6
View File
@@ -103,6 +103,8 @@ pub fn write_init_config(args: &InitConfigArgs) -> Result<Option<PathBuf>> {
.or(base.history_retention)
.unwrap_or(defaults.history_retention);
super::resolve::validate_durations(command_timeout, enroll_token_ttl, history_retention)?;
let device_retention = base.device_retention.unwrap_or(defaults.device_retention);
super::resolve::validate_device_retention(device_retention)?;
let body = FileConfig {
data_dir: Some(data_dir),
@@ -112,6 +114,7 @@ pub fn write_init_config(args: &InitConfigArgs) -> Result<Option<PathBuf>> {
command_timeout: Some(command_timeout),
enroll_token_ttl: Some(enroll_token_ttl),
history_retention: Some(history_retention),
device_retention: Some(device_retention),
pid_file: Some(pid_file),
ui_dist_dir: Some(ui_dist_dir),
// Preserve the previous generated template's omission of an empty list.
@@ -237,6 +240,7 @@ public_url = "https://old.example/"
state_file = "old.sqlite3"
command_timeout_ms = 1234
enroll_token_ttl_seconds = 5678
device_retention = "0d"
pid_file = "old.pid"
ui_dist_dir = "/opt/wakey/ui"
bootstrap_enroll_tokens = ["old-token"]
@@ -288,6 +292,7 @@ json_logs = true
Some(std::time::Duration::from_secs(5678))
);
assert!(parsed.get("command_timeout_ms").is_none());
assert_eq!(converted.device_retention, Some(std::time::Duration::ZERO));
assert!(parsed.get("enroll_token_ttl_seconds").is_none());
assert_eq!(
parsed["state_file"].as_str(),
@@ -349,6 +354,7 @@ json_logs = true
);
let converted: super::FileConfig = toml::from_str(&rendered).unwrap();
assert_eq!(converted.command_timeout, Some(defaults.command_timeout));
assert_eq!(converted.device_retention, Some(defaults.device_retention));
assert_eq!(
parsed["telemetry"]["service_name"].as_str(),
Some(defaults.telemetry.service_name.as_str())
+28 -1
View File
@@ -67,6 +67,8 @@ impl DaemonConfig {
.or(file.history_retention)
.unwrap_or(defaults.history_retention);
validate_durations(command_timeout, enroll_token_ttl, history_retention)?;
let device_retention = file.device_retention.unwrap_or(defaults.device_retention);
validate_device_retention(device_retention)?;
let pid_file_raw = args
.pid_file
@@ -98,6 +100,7 @@ impl DaemonConfig {
command_timeout,
enroll_token_ttl,
history_retention,
device_retention,
pid_file,
ui_dist_dir,
bootstrap_enroll_tokens,
@@ -223,6 +226,20 @@ pub(crate) fn load_file_config(path: &Path) -> Result<FileConfig> {
Ok(config)
}
/// Device expiry uses whole-second snapshot timestamps, not an active timer.
/// Unlike history retention, zero is a supported opt-out.
pub(crate) fn validate_device_retention(retention: Duration) -> Result<()> {
anyhow::ensure!(
retention.subsec_nanos() == 0,
"device_retention must use whole seconds"
);
anyhow::ensure!(
i64::try_from(retention.as_secs()).is_ok(),
"device_retention is too large"
);
Ok(())
}
pub(crate) fn validate_durations(
command_timeout: Duration,
enroll_token_ttl: Duration,
@@ -327,11 +344,12 @@ fn resolve_state_access(
#[cfg(test)]
mod tests {
use super::{DaemonConfig, normalize_public_url, resolve_path};
use super::{DaemonConfig, normalize_public_url, resolve_path, validate_device_retention};
use crate::cli::ServeArgs;
use crate::config::types::DEFAULT_CONFIG;
use std::path::Path;
use std::path::PathBuf;
use std::time::Duration;
#[test]
fn normalize_public_url_trims_trailing_slash() {
@@ -383,12 +401,21 @@ mod tests {
defaults.data_dir.join(&defaults.state_file)
);
assert_eq!(resolved.command_timeout, defaults.command_timeout);
assert_eq!(resolved.device_retention, defaults.device_retention);
assert_eq!(
resolved.telemetry.service_name,
defaults.telemetry.service_name
);
}
#[test]
fn device_retention_accepts_disabled_and_whole_seconds_only() {
assert!(validate_device_retention(Duration::ZERO).is_ok());
assert!(validate_device_retention(DEFAULT_CONFIG.device_retention).is_ok());
assert!(validate_device_retention(Duration::from_millis(1_500)).is_err());
assert!(validate_device_retention(Duration::from_secs(u64::MAX)).is_err());
}
#[test]
fn invalid_duration_policies_are_rejected_without_clamping() {
use std::time::Duration;
+21
View File
@@ -16,6 +16,8 @@ pub struct DaemonConfig {
pub command_timeout: Duration,
pub enroll_token_ttl: Duration,
pub history_retention: Duration,
/// Maximum age since an agent snapshot included a device; zero disables expiry.
pub device_retention: Duration,
pub pid_file: PathBuf,
pub ui_dist_dir: PathBuf,
pub bootstrap_enroll_tokens: Vec<String>,
@@ -37,6 +39,8 @@ pub(crate) struct FileConfig {
pub(crate) enroll_token_ttl: Option<Duration>,
#[serde(default, with = "humantime_serde::option")]
pub(crate) history_retention: Option<Duration>,
#[serde(default, with = "humantime_serde::option")]
pub(crate) device_retention: Option<Duration>,
pub(crate) pid_file: Option<PathBuf>,
pub(crate) ui_dist_dir: Option<PathBuf>,
#[serde(alias = "enroll_tokens")]
@@ -95,6 +99,7 @@ pub(crate) static DEFAULT_CONFIG: LazyLock<DaemonConfig> = LazyLock::new(|| Daem
command_timeout: Duration::from_millis(30_000),
enroll_token_ttl: Duration::from_secs(86_400),
history_retention: Duration::from_secs(2_592_000),
device_retention: Duration::from_secs(60 * 86_400),
pid_file: "wakey-control-plane.pid".into(),
ui_dist_dir: "ui/dist".into(),
bootstrap_enroll_tokens: Vec::new(),
@@ -186,4 +191,20 @@ mod tests {
assert!(toml::from_str::<FileConfig>(&negative).is_err());
}
}
#[test]
fn device_retention_round_trips_and_requires_units() {
for text in ["60d", "0d"] {
let file: FileConfig = toml::from_str(&format!("device_retention = '{text}'")).unwrap();
let output = toml::to_string(&file).unwrap();
assert_eq!(toml::from_str::<FileConfig>(&output).unwrap(), file);
}
for input in [
"device_retention = 60",
"device_retention = 1.5",
"device_retention = '-1d'",
] {
assert!(toml::from_str::<FileConfig>(input).is_err());
}
}
}
+5
View File
@@ -260,6 +260,11 @@ pub async fn serve(daemon: config::DaemonConfig) -> Result<()> {
}
Err(err) => warn!(error = %err, "periodic retention gc failed"),
}
match app_state.store.prune_devices(daemon.device_retention).await {
Ok(removed) if removed > 0 => info!(removed, "periodic gc pruned expired agent devices"),
Ok(_) => {},
Err(err) => warn!(error = %err, "periodic device retention gc failed"),
}
}
join = &mut server => {
let _ = remove_pid_file(&daemon.pid_file);
@@ -6,6 +6,35 @@ use super::helpers::core::*;
use crate::state::types::*;
impl Store {
/// Expire agent-reported devices, preserving all operator-saved identities.
/// `last_seen_unix` on the parent is CC snapshot inclusion time, not proof
/// of online presence. Cascades remove child addresses, endpoints and facts.
#[tracing::instrument(name = "db_prune_devices", skip_all)]
pub async fn prune_devices(&self, retention: std::time::Duration) -> Result<u64> {
self.prune_devices_at(retention, now_unix()).await
}
async fn prune_devices_at(&self, retention: std::time::Duration, now: u64) -> Result<u64> {
if retention.is_zero() {
return Ok(0);
}
let cutoff = i64::try_from(now.saturating_sub(retention.as_secs()))
.context("device retention cutoff overflow")?;
let mut tx = self.begin_write().await?;
let removed = sqlx::query!(
"DELETE FROM agent_devices WHERE last_seen_unix < ?1",
cutoff
)
.execute(&mut *tx)
.await
.context("failed pruning expired agent devices")?
.rows_affected();
tx.commit()
.await
.context("failed committing device retention cleanup")?;
Ok(removed)
}
/// Replace the complete device snapshot for an agent.
///
/// This is the only write path for agent device state. Every sync source
@@ -566,6 +595,151 @@ mod tests {
}
}
#[tokio::test]
async fn device_retention_uses_snapshot_age_and_preserves_saved_identity() {
use crate::state::types::{DeviceIdentifierInput, KnownDeviceInput};
let ts = TestStore::new().await;
let store = ts.store();
let mut device = sample_device("aa:bb:cc:dd:ee:01", "192.168.1.10", "pc");
device.neighbors[0].state = NeighborState::Failed;
device.observations.push(DeviceObservationFact {
kind: "neigh".into(),
action: "remove".into(),
mac: device.macs.first().copied(),
ip: device.ips.first().copied(),
hostname: None,
first_seen_unix: Some(1),
last_seen_unix: Some(1),
});
let saved = store
.create_known_device(KnownDeviceInput {
display_name: "saved pc".into(),
pinned: true,
notes: None,
identifiers: vec![DeviceIdentifierInput {
kind: "mac".into(),
value: "aa:bb:cc:dd:ee:01".into(),
}],
})
.await
.unwrap();
// Explicit timestamps exercise the exact boundary and future rows
// without sleeping or relying on the test machine's wall clock.
for (agent, seen) in [("expired", 99_i64), ("boundary", 100), ("fresh", 101)] {
store
.replace_agent_device_snapshot(agent, &[device.clone()])
.await
.unwrap();
sqlx::query("UPDATE agent_devices SET last_seen_unix = ?1 WHERE agent_id = ?2")
.bind(seen)
.bind(agent)
.execute(&store.pool)
.await
.unwrap();
sqlx::query("UPDATE agent_device_endpoints SET last_seen_unix = 1 WHERE agent_id = ?1")
.bind(agent)
.execute(&store.pool)
.await
.unwrap();
}
assert_eq!(
store.prune_devices_at(Duration::ZERO, 1_000).await.unwrap(),
0
);
assert_eq!(
store
.prune_devices_at(Duration::from_secs(900), 1_000)
.await
.unwrap(),
1
);
assert_eq!(
store
.prune_devices_at(Duration::from_secs(900), 1_000)
.await
.unwrap(),
0
);
for table in [
"agent_devices",
"agent_device_macs",
"agent_device_ips",
"agent_device_hostnames",
"agent_device_endpoints",
"agent_device_facts",
] {
let count: i64 = sqlx::query_scalar(&format!(
"SELECT COUNT(*) FROM {table} WHERE agent_id = 'expired'"
))
.fetch_one(&store.pool)
.await
.unwrap();
assert_eq!(count, 0, "expired rows remain in {table}");
}
for agent in ["boundary", "fresh"] {
let rows = store.list_agent_device_rows_for_agent(agent).await.unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].presence(), Presence::Offline);
}
let retained = store
.get_known_device(&saved.device_id)
.await
.unwrap()
.unwrap();
assert_eq!(retained.identifiers.len(), 1);
assert!(retained.pinned);
// Retention longer than the current epoch must not wrap the cutoff.
assert_eq!(
store
.prune_devices_at(Duration::from_secs(2_000), 1_000)
.await
.unwrap(),
0
);
}
#[tokio::test]
async fn a_new_snapshot_renews_device_retention_even_when_offline() {
let ts = TestStore::new().await;
let store = ts.store();
let mut device = sample_device("aa:bb:cc:dd:ee:01", "192.168.1.10", "pc");
device.neighbors[0].state = NeighborState::Failed;
store
.replace_agent_device_snapshot("agent-a", &[device.clone()])
.await
.unwrap();
sqlx::query("UPDATE agent_devices SET last_seen_unix = 1")
.execute(&store.pool)
.await
.unwrap();
store
.replace_agent_device_snapshot("agent-a", &[device])
.await
.unwrap();
let rows = store
.list_agent_device_rows_for_agent("agent-a")
.await
.unwrap();
let included_at = u64::try_from(rows[0].device.last_seen_unix).unwrap();
assert_eq!(
store
.prune_devices_at(Duration::from_secs(60), included_at + 60)
.await
.unwrap(),
0
);
assert_eq!(
store
.prune_devices_at(Duration::from_secs(60), included_at + 61)
.await
.unwrap(),
1
);
}
#[tokio::test]
async fn snapshot_presence_is_derived_from_stored_endpoints() {
let ts = TestStore::new().await;