Skip to main content

cfgd_core/config/
daemon.rs

1use serde::{Deserialize, Serialize};
2
3use super::sync_secrets::{NotifyConfig, SyncConfig};
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
6#[serde(rename_all = "camelCase", deny_unknown_fields)]
7pub struct DaemonConfig {
8    #[serde(default)]
9    pub enabled: bool,
10    #[serde(default)]
11    pub reconcile: Option<ReconcileConfig>,
12    #[serde(default)]
13    pub sync: Option<SyncConfig>,
14    #[serde(default)]
15    pub notify: Option<NotifyConfig>,
16    /// Mirror daemon log output into the Windows Event Log under the `cfgd`
17    /// source, in addition to the default file appender at
18    /// `%LOCALAPPDATA%\cfgd\daemon.log`. No effect on Unix. Read by
19    /// `cfgd daemon install` to bake `--enable-event-log` into the service
20    /// binPath; changes require reinstalling the service to take effect.
21    #[serde(default)]
22    pub windows_event_log: bool,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26#[serde(rename_all = "camelCase", deny_unknown_fields)]
27pub struct ReconcileConfig {
28    #[serde(default = "default_reconcile_interval")]
29    pub interval: String,
30    #[serde(default)]
31    pub on_change: bool,
32    #[serde(default)]
33    pub auto_apply: bool,
34    #[serde(default)]
35    pub policy: Option<AutoApplyPolicyConfig>,
36    /// Policy for daemon auto-reconciliation of detected drift.
37    /// `Auto` = silently apply (must opt-in), `NotifyOnly` = notify but don't
38    /// apply (safe default), `Prompt` = future interactive approval.
39    #[serde(default)]
40    pub drift_policy: DriftPolicy,
41    /// Per-module or per-profile reconcile overrides (kustomize-style patches).
42    /// Each patch targets a specific Module or Profile by name and overrides
43    /// individual reconcile fields. Precedence: Module patch > Profile patch > global.
44    #[serde(default)]
45    pub patches: Vec<ReconcilePatch>,
46}
47
48/// A kustomize-style reconcile patch targeting a specific module or profile.
49/// When `name` is omitted, the patch applies to all entities of the given kind.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51#[serde(rename_all = "camelCase", deny_unknown_fields)]
52pub struct ReconcilePatch {
53    pub kind: ReconcilePatchKind,
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub name: Option<String>,
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub interval: Option<String>,
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub auto_apply: Option<bool>,
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub drift_policy: Option<DriftPolicy>,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65pub enum ReconcilePatchKind {
66    Module,
67    Profile,
68}
69
70/// Daemon drift reconciliation policy. PascalCase values match K8s enum conventions.
71#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
72pub enum DriftPolicy {
73    /// Apply drift corrections automatically (current behavior, now opt-in).
74    Auto,
75    /// Notify and record drift, but do not apply. User must run `cfgd apply`.
76    #[default]
77    NotifyOnly,
78    /// Future: notify with actionable prompt.
79    Prompt,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
83#[serde(rename_all = "camelCase", deny_unknown_fields)]
84pub struct AutoApplyPolicyConfig {
85    #[serde(default = "default_policy_notify")]
86    pub new_recommended: PolicyAction,
87    #[serde(default = "default_policy_ignore")]
88    pub new_optional: PolicyAction,
89    #[serde(default = "default_policy_notify")]
90    pub locked_conflict: PolicyAction,
91}
92
93impl Default for AutoApplyPolicyConfig {
94    fn default() -> Self {
95        Self {
96            new_recommended: PolicyAction::Notify,
97            new_optional: PolicyAction::Ignore,
98            locked_conflict: PolicyAction::Notify,
99        }
100    }
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104pub enum PolicyAction {
105    Notify,
106    Accept,
107    Reject,
108    Ignore,
109}
110
111fn default_policy_notify() -> PolicyAction {
112    PolicyAction::Notify
113}
114
115fn default_policy_ignore() -> PolicyAction {
116    PolicyAction::Ignore
117}
118
119fn default_reconcile_interval() -> String {
120    "5m".to_string()
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn auto_apply_policy_default_values() {
129        let policy = AutoApplyPolicyConfig::default();
130        assert!(matches!(policy.new_recommended, PolicyAction::Notify));
131        assert!(matches!(policy.new_optional, PolicyAction::Ignore));
132        assert!(matches!(policy.locked_conflict, PolicyAction::Notify));
133    }
134
135    #[test]
136    fn drift_policy_default_is_notify_only() {
137        let dp = DriftPolicy::default();
138        assert_eq!(dp, DriftPolicy::NotifyOnly);
139    }
140
141    #[test]
142    fn reconcile_config_deserializes_with_defaults() {
143        let yaml = "onChange: true";
144        let config: ReconcileConfig = serde_yaml::from_str(yaml).unwrap();
145        assert!(config.on_change);
146        assert!(!config.auto_apply);
147        assert_eq!(config.interval, "5m");
148        assert_eq!(config.drift_policy, DriftPolicy::NotifyOnly);
149        assert!(config.patches.is_empty());
150    }
151
152    #[test]
153    fn auto_apply_policy_deserializes_with_serde_defaults() {
154        let yaml = "newRecommended: Accept";
155        let config: AutoApplyPolicyConfig = serde_yaml::from_str(yaml).unwrap();
156        assert!(matches!(config.new_recommended, PolicyAction::Accept));
157        assert!(matches!(config.new_optional, PolicyAction::Ignore));
158        assert!(matches!(config.locked_conflict, PolicyAction::Notify));
159    }
160
161    #[test]
162    fn daemon_config_deserializes_minimal() {
163        let yaml = "enabled: true";
164        let config: DaemonConfig = serde_yaml::from_str(yaml).unwrap();
165        assert!(config.enabled);
166        assert!(config.reconcile.is_none());
167        assert!(config.sync.is_none());
168        assert!(config.notify.is_none());
169        assert!(!config.windows_event_log);
170    }
171
172    #[test]
173    fn reconcile_patch_deserializes() {
174        let yaml = "kind: Module\nname: docker\ninterval: 10m\nautoApply: true\ndriftPolicy: Auto";
175        let patch: ReconcilePatch = serde_yaml::from_str(yaml).unwrap();
176        assert_eq!(patch.kind, ReconcilePatchKind::Module);
177        assert_eq!(patch.name.as_deref(), Some("docker"));
178        assert_eq!(patch.interval.as_deref(), Some("10m"));
179        assert_eq!(patch.auto_apply, Some(true));
180        assert_eq!(patch.drift_policy, Some(DriftPolicy::Auto));
181    }
182
183    #[test]
184    fn drift_policy_all_variants_deserialize() {
185        let auto: DriftPolicy = serde_yaml::from_str("Auto").unwrap();
186        let notify: DriftPolicy = serde_yaml::from_str("NotifyOnly").unwrap();
187        let prompt: DriftPolicy = serde_yaml::from_str("Prompt").unwrap();
188        assert_eq!(auto, DriftPolicy::Auto);
189        assert_eq!(notify, DriftPolicy::NotifyOnly);
190        assert_eq!(prompt, DriftPolicy::Prompt);
191    }
192
193    #[test]
194    fn daemon_config_rejects_unknown_field() {
195        let yaml = "enabled: true\nbogus: 1\n";
196        let err = serde_yaml::from_str::<DaemonConfig>(yaml)
197            .expect_err("expected deny_unknown_fields to reject bogus");
198        assert!(format!("{}", err).contains("unknown field"));
199    }
200
201    #[test]
202    fn reconcile_config_rejects_drift_policy_typo() {
203        // Exactly the example called out in the v0.4 finding: `dirft_policy`
204        // typo silently became a no-op; with deny_unknown_fields it must fail
205        // loudly so the operator notices.
206        let yaml = "interval: 5m\ndirftPolicy: Auto\n";
207        let err = serde_yaml::from_str::<ReconcileConfig>(yaml)
208            .expect_err("expected deny_unknown_fields to reject dirftPolicy");
209        let msg = format!("{}", err);
210        assert!(
211            msg.contains("unknown field") && msg.contains("dirftPolicy"),
212            "expected unknown-field error mentioning dirftPolicy, got: {msg}"
213        );
214    }
215
216    #[test]
217    fn policy_action_all_variants_deserialize() {
218        let notify: PolicyAction = serde_yaml::from_str("Notify").unwrap();
219        let accept: PolicyAction = serde_yaml::from_str("Accept").unwrap();
220        let reject: PolicyAction = serde_yaml::from_str("Reject").unwrap();
221        let ignore: PolicyAction = serde_yaml::from_str("Ignore").unwrap();
222        assert_eq!(notify, PolicyAction::Notify);
223        assert_eq!(accept, PolicyAction::Accept);
224        assert_eq!(reject, PolicyAction::Reject);
225        assert_eq!(ignore, PolicyAction::Ignore);
226    }
227}