Skip to main content

nexo_memory_snapshot/
config.rs

1//! Operator-facing YAML schema for the snapshot subsystem.
2//!
3//! Lives here as a self-contained `serde::Deserialize` struct so the
4//! boot wire (in the binary crate) can construct a
5//! [`crate::local_fs::LocalFsSnapshotter`] +
6//! [`crate::retention::RetentionConfig`] from a single YAML block
7//! without dragging the schema into every consumer.
8//!
9//! Intended placement in `config/memory.yaml`:
10//!
11//! ```yaml
12//! memory:
13//!   snapshot:
14//!     enabled: true
15//!     root: ${NEXO_HOME}/state
16//!     auto_pre_dream: false
17//!     auto_pre_restore: true
18//!     auto_pre_mutating_tool: false
19//!     lock_timeout_secs: 60
20//!     redact_secrets_default: true
21//!     encryption:
22//!       enabled: false
23//!       recipients: []
24//!       identity_path: ${NEXO_HOME}/secret/snapshot-identity.txt
25//!     retention:
26//!       keep_count: 30
27//!       max_age_days: 90
28//!       gc_interval_secs: 3600
29//!     events:
30//!       mutation_subject_prefix: "nexo.memory.mutated"
31//!       lifecycle_subject_prefix: "nexo.memory.snapshot"
32//!       mutation_publish_enabled: true
33//! ```
34
35use std::path::PathBuf;
36use std::time::Duration;
37
38use serde::{Deserialize, Serialize};
39
40use crate::retention::RetentionConfig;
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43#[serde(deny_unknown_fields)]
44pub struct MemorySnapshotConfig {
45    #[serde(default = "default_enabled")]
46    pub enabled: bool,
47    #[serde(default = "default_root")]
48    pub root: PathBuf,
49    #[serde(default)]
50    pub auto_pre_dream: bool,
51    #[serde(default = "default_true")]
52    pub auto_pre_restore: bool,
53    #[serde(default)]
54    pub auto_pre_mutating_tool: bool,
55    #[serde(default = "default_lock_timeout_secs")]
56    pub lock_timeout_secs: u64,
57    #[serde(default = "default_true")]
58    pub redact_secrets_default: bool,
59    #[serde(default)]
60    pub encryption: EncryptionSection,
61    #[serde(default)]
62    pub retention: RetentionSection,
63    #[serde(default)]
64    pub events: EventsSection,
65}
66
67impl Default for MemorySnapshotConfig {
68    fn default() -> Self {
69        Self {
70            enabled: default_enabled(),
71            root: default_root(),
72            auto_pre_dream: false,
73            auto_pre_restore: true,
74            auto_pre_mutating_tool: false,
75            lock_timeout_secs: default_lock_timeout_secs(),
76            redact_secrets_default: true,
77            encryption: EncryptionSection::default(),
78            retention: RetentionSection::default(),
79            events: EventsSection::default(),
80        }
81    }
82}
83
84impl MemorySnapshotConfig {
85    pub fn lock_timeout(&self) -> Duration {
86        Duration::from_secs(self.lock_timeout_secs)
87    }
88
89    pub fn retention_runtime(&self) -> RetentionConfig {
90        RetentionConfig {
91            keep_count: self.retention.keep_count,
92            max_age_days: self.retention.max_age_days,
93            gc_interval_secs: self.retention.gc_interval_secs,
94        }
95    }
96
97    /// Reject combinations that would violate the operator's intent
98    /// well before the runtime hits them. The boot wire calls this
99    /// after YAML deserialization and before constructing the
100    /// snapshotter so a malformed config fails loudly at startup.
101    pub fn validate(&self) -> Result<(), String> {
102        if self.lock_timeout_secs == 0 {
103            return Err("memory.snapshot.lock_timeout_secs must be >= 1".into());
104        }
105        if self.retention.gc_interval_secs == 0 {
106            return Err("memory.snapshot.retention.gc_interval_secs must be >= 1".into());
107        }
108        if self.encryption.enabled && self.encryption.recipients.is_empty() {
109            return Err(
110                "memory.snapshot.encryption.enabled = true requires at least one recipient".into(),
111            );
112        }
113        Ok(())
114    }
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize, Default)]
118#[serde(deny_unknown_fields, default)]
119pub struct EncryptionSection {
120    pub enabled: bool,
121    pub recipients: Vec<String>,
122    pub identity_path: Option<PathBuf>,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
126#[serde(deny_unknown_fields, default)]
127pub struct RetentionSection {
128    pub keep_count: u32,
129    pub max_age_days: u32,
130    pub gc_interval_secs: u64,
131}
132
133impl Default for RetentionSection {
134    fn default() -> Self {
135        let r = RetentionConfig::default();
136        Self {
137            keep_count: r.keep_count,
138            max_age_days: r.max_age_days,
139            gc_interval_secs: r.gc_interval_secs,
140        }
141    }
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize)]
145#[serde(deny_unknown_fields, default)]
146pub struct EventsSection {
147    pub mutation_subject_prefix: String,
148    pub lifecycle_subject_prefix: String,
149    pub mutation_publish_enabled: bool,
150}
151
152impl Default for EventsSection {
153    fn default() -> Self {
154        Self {
155            mutation_subject_prefix: crate::events::MUTATION_SUBJECT_PREFIX.to_string(),
156            lifecycle_subject_prefix: crate::events::LIFECYCLE_SUBJECT_PREFIX.to_string(),
157            mutation_publish_enabled: true,
158        }
159    }
160}
161
162fn default_enabled() -> bool {
163    true
164}
165
166fn default_true() -> bool {
167    true
168}
169
170fn default_root() -> PathBuf {
171    PathBuf::from("./state")
172}
173
174fn default_lock_timeout_secs() -> u64 {
175    60
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    #[test]
183    fn defaults_match_spec() {
184        let c = MemorySnapshotConfig::default();
185        assert!(c.enabled);
186        assert!(c.auto_pre_restore);
187        assert!(!c.auto_pre_dream);
188        assert!(!c.auto_pre_mutating_tool);
189        assert_eq!(c.lock_timeout_secs, 60);
190        assert!(c.redact_secrets_default);
191        assert_eq!(c.retention.keep_count, 30);
192        assert_eq!(c.retention.max_age_days, 90);
193        assert_eq!(c.retention.gc_interval_secs, 3600);
194        assert!(c.events.mutation_publish_enabled);
195        assert!(!c.encryption.enabled);
196    }
197
198    #[test]
199    fn parses_minimal_yaml_with_defaults() {
200        let c: MemorySnapshotConfig = serde_yaml::from_str("enabled: true\n").unwrap();
201        assert!(c.enabled);
202        assert_eq!(c.retention.keep_count, 30);
203    }
204
205    #[test]
206    fn parses_full_yaml_block() {
207        let yaml = r#"
208enabled: true
209root: /var/lib/nexo
210auto_pre_dream: true
211auto_pre_restore: false
212auto_pre_mutating_tool: true
213lock_timeout_secs: 30
214redact_secrets_default: false
215encryption:
216  enabled: true
217  recipients:
218    - age1abc
219  identity_path: /etc/nexo/snapshot.key
220retention:
221  keep_count: 5
222  max_age_days: 7
223  gc_interval_secs: 600
224events:
225  mutation_subject_prefix: "x.memory.mutated"
226  lifecycle_subject_prefix: "x.memory.snapshot"
227  mutation_publish_enabled: false
228"#;
229        let c: MemorySnapshotConfig = serde_yaml::from_str(yaml).unwrap();
230        assert_eq!(c.lock_timeout_secs, 30);
231        assert!(c.encryption.enabled);
232        assert_eq!(c.encryption.recipients, vec!["age1abc"]);
233        assert_eq!(c.retention.keep_count, 5);
234        assert_eq!(c.events.mutation_subject_prefix, "x.memory.mutated");
235        c.validate().unwrap();
236    }
237
238    #[test]
239    fn rejects_unknown_fields() {
240        let yaml = "enabled: true\nbogus_key: 1\n";
241        let r: Result<MemorySnapshotConfig, _> = serde_yaml::from_str(yaml);
242        assert!(r.is_err());
243    }
244
245    #[test]
246    fn validate_rejects_zero_lock_timeout() {
247        let c = MemorySnapshotConfig {
248            lock_timeout_secs: 0,
249            ..Default::default()
250        };
251        assert!(c.validate().is_err());
252    }
253
254    #[test]
255    fn validate_rejects_zero_gc_interval() {
256        let c = MemorySnapshotConfig {
257            retention: RetentionSection {
258                gc_interval_secs: 0,
259                ..Default::default()
260            },
261            ..Default::default()
262        };
263        assert!(c.validate().is_err());
264    }
265
266    #[test]
267    fn validate_rejects_encryption_enabled_without_recipients() {
268        let c = MemorySnapshotConfig {
269            encryption: EncryptionSection {
270                enabled: true,
271                recipients: Vec::new(),
272                identity_path: None,
273            },
274            ..Default::default()
275        };
276        assert!(c.validate().is_err());
277    }
278
279    #[test]
280    fn retention_runtime_round_trips_to_runtime_struct() {
281        let mut c = MemorySnapshotConfig::default();
282        c.retention.keep_count = 7;
283        c.retention.max_age_days = 14;
284        let r = c.retention_runtime();
285        assert_eq!(r.keep_count, 7);
286        assert_eq!(r.max_age_days, 14);
287    }
288}