Skip to main content

shep_core/config/app/
behavior.rs

1use super::schema::AppConfig;
2use core::fmt;
3use std::collections::BTreeMap;
4// use schemars::generate
5use crate::values::UpDuration;
6
7/// Redacts `env`: only its length is printed.
8impl fmt::Debug for AppConfig {
9    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
10        f.debug_struct("AppConfig")
11            .field("name", &self.name)
12            .field("script", &self.script)
13            .field("env", &format_args!("<{} vars>", self.env.len()))
14            .finish_non_exhaustive()
15    }
16}
17
18impl Default for AppConfig {
19    fn default() -> Self {
20        Self {
21            name: String::new(),
22            script: String::new(),
23            args: Vec::new(),
24            cwd: None,
25            interpreter: None,
26            env: BTreeMap::new(),
27            environment: None,
28            instances: 1,
29            autorestart: true,
30            autostart: true,
31            stop_exit_codes: Vec::new(),
32            min_uptime: UpDuration::from_millis(1000),
33            max_restarts: 16,
34            restart_delay: None,
35            // Not None: see the field's doc comment. An unstable exit with
36            // no restart policy configured must not restart instantly.
37            exp_backoff_restart_delay: Some(UpDuration::from_millis(100)),
38            kill_signal: None,
39            kill_timeout: UpDuration::from_millis(1600),
40            shutdown_with_message: false,
41            listen_timeout: UpDuration::from_millis(3000),
42            graceful_timeout: UpDuration::from_millis(8000),
43            action_timeout: UpDuration::from_millis(3000),
44            max_memory: None,
45            watch: false,
46            ignore_watch: Vec::new(),
47            watch_delay: None,
48            cron_restart: None,
49            fold: None,
50            depends_on: Vec::new(),
51            user: None,
52            group: None,
53            out_file: None,
54            err_file: None,
55            merge_logs: false,
56            level_rules: Vec::new(),
57            channel: false,
58            stdin: false,
59            wait_ready: false,
60            reuse_port: false,
61            readiness_probe: None,
62            liveness_probe: None,
63            watch_options: Vec::new(),
64            cron_timezone: None,
65        }
66    }
67}
68
69impl AppConfig {
70    /// A minimal config with spec defaults, the programmatic entry point.
71    #[must_use]
72    pub fn minimal(name: &str, script: &str) -> Self {
73        Self {
74            name: name.to_string(),
75            script: script.to_string(),
76            ..Self::default()
77        }
78    }
79
80    /// The names of the fields whose values differ between `self` and
81    /// `other`, in field-name order.
82    ///
83    /// Names only, never values. The one caller sends this list across the
84    /// wire to be printed at an operator, and [`AppConfig::env`] carries
85    /// secrets, so a differing `env` reports `"env"` and stops there.
86    ///
87    /// Compare configs that have both been through
88    /// [`normalize`](fn@crate::config::normalize). Two configs differing only
89    /// in what normalization would have filled in are not a difference an
90    /// operator can act on, and reporting them would make the caller noisy
91    /// about nothing.
92    ///
93    /// # Example
94    ///
95    /// ```
96    /// use shep_core::config::AppConfig;
97    ///
98    /// let stored = AppConfig::minimal("web", "./srv");
99    /// let mut edited = stored.clone();
100    /// edited.cwd = Some("/srv".to_string());
101    ///
102    /// assert_eq!(stored.drifted_fields(&edited), vec!["cwd".to_string()]);
103    /// assert!(stored.drifted_fields(&stored).is_empty());
104    /// ```
105    #[must_use]
106    pub fn drifted_fields(&self, other: &Self) -> Vec<String> {
107        if self == other {
108            return Vec::new();
109        }
110        // Serde-compared, not field by field: a new field needs no edit here.
111        // Sorted since `serde_json::Map` is a `BTreeMap` only while
112        // `preserve_order` is off crate-wide. An empty result means no
113        // drift, or none could be computed.
114        let (Ok(serde_json::Value::Object(mine)), Ok(serde_json::Value::Object(theirs))) =
115            (serde_json::to_value(self), serde_json::to_value(other))
116        else {
117            return Vec::new();
118        };
119        let mut fields: Vec<String> = mine
120            .iter()
121            .filter(|(key, value)| theirs.get(key.as_str()) != Some(value))
122            .map(|(key, _)| key.clone())
123            .collect();
124        fields.sort_unstable();
125        fields
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::super::schema::AppConfig;
132
133    // use schemars::generate
134
135    #[test]
136    fn an_unedited_config_has_drifted_in_no_field() {
137        let app = AppConfig::minimal("web", "./srv");
138
139        assert!(app.drifted_fields(&app.clone()).is_empty());
140    }
141
142    #[test]
143    fn drift_names_every_edited_field_and_no_other() {
144        // Two fields, not one, so a comparator that stopped at the first
145        // difference fails here.
146        let stored = AppConfig::minimal("proto-api", "./proto-enum-api");
147        let mut edited = stored.clone();
148        edited.cwd = Some("/srv/pogo-proto-api".to_string());
149        edited.args = vec!["-config".to_string(), "config.toml".to_string()];
150
151        assert_eq!(
152            stored.drifted_fields(&edited),
153            vec!["args".to_string(), "cwd".to_string()]
154        );
155    }
156
157    #[test]
158    fn drift_reports_env_by_name_and_never_by_value() {
159        let stored = AppConfig::minimal("web", "./srv");
160        let mut edited = stored.clone();
161        edited
162            .env
163            .insert("DATABASE_URL".to_string(), "postgres://hunter2".to_string());
164
165        let fields = edited.drifted_fields(&stored);
166
167        assert_eq!(fields, vec!["env".to_string()]);
168        // Names go to an operator; a value never should.
169        assert!(!fields.concat().contains("hunter2"));
170    }
171
172    #[test]
173    fn drift_is_symmetric() {
174        let stored = AppConfig::minimal("web", "./srv");
175        let mut edited = stored.clone();
176        edited.instances = 4;
177
178        assert_eq!(
179            stored.drifted_fields(&edited),
180            edited.drifted_fields(&stored)
181        );
182        assert_eq!(
183            stored.drifted_fields(&edited),
184            vec!["instances".to_string()]
185        );
186    }
187}