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            log_timestamps: true,
57            level_rules: Vec::new(),
58            dogs: BTreeMap::new(),
59            channel: false,
60            stdin: false,
61            wait_ready: false,
62            reuse_port: false,
63            readiness_probe: None,
64            liveness_probe: None,
65            watch_options: Vec::new(),
66            cron_timezone: None,
67        }
68    }
69}
70
71impl AppConfig {
72    /// A minimal config with spec defaults, the programmatic entry point.
73    #[must_use]
74    pub fn minimal(name: &str, script: &str) -> Self {
75        Self {
76            name: name.to_string(),
77            script: script.to_string(),
78            ..Self::default()
79        }
80    }
81
82    /// The names of the fields whose values differ between `self` and
83    /// `other`, in field-name order.
84    ///
85    /// Names only, never values. The one caller sends this list across the
86    /// wire to be printed at an operator, and [`AppConfig::env`] carries
87    /// secrets, so a differing `env` reports `"env"` and stops there.
88    ///
89    /// Compare configs that have both been through
90    /// [`normalize`](fn@crate::config::normalize). Two configs differing only
91    /// in what normalization would have filled in are not a difference an
92    /// operator can act on, and reporting them would make the caller noisy
93    /// about nothing.
94    ///
95    /// # Example
96    ///
97    /// ```
98    /// use shep_core::config::AppConfig;
99    ///
100    /// let stored = AppConfig::minimal("web", "./srv");
101    /// let mut edited = stored.clone();
102    /// edited.cwd = Some("/srv".to_string());
103    ///
104    /// assert_eq!(stored.drifted_fields(&edited), vec!["cwd".to_string()]);
105    /// assert!(stored.drifted_fields(&stored).is_empty());
106    /// ```
107    #[must_use]
108    pub fn drifted_fields(&self, other: &Self) -> Vec<String> {
109        if self == other {
110            return Vec::new();
111        }
112        // Serde-compared, not field by field: a new field needs no edit here.
113        // Sorted since `serde_json::Map` is a `BTreeMap` only while
114        // `preserve_order` is off crate-wide. An empty result means no
115        // drift, or none could be computed.
116        let (Ok(serde_json::Value::Object(mine)), Ok(serde_json::Value::Object(theirs))) =
117            (serde_json::to_value(self), serde_json::to_value(other))
118        else {
119            return Vec::new();
120        };
121        let mut fields: Vec<String> = mine
122            .iter()
123            .filter(|(key, value)| theirs.get(key.as_str()) != Some(value))
124            .map(|(key, _)| key.clone())
125            .collect();
126        fields.sort_unstable();
127        fields
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::super::schema::AppConfig;
134    use crate::config::{FlockFormat, Flockfile};
135
136    // use schemars::generate
137
138    #[test]
139    fn log_lines_are_stamped_unless_a_flockfile_says_false() {
140        let parse = |extra: &str| {
141            let src = format!("[[app]]\nname = \"web\"\nscript = \"./srv\"\n{extra}");
142            Flockfile::parse(&src, FlockFormat::Toml)
143                .expect("parses")
144                .apps[0]
145                .log_timestamps
146        };
147
148        assert!(AppConfig::minimal("web", "./srv").log_timestamps);
149        assert!(parse(""));
150        assert!(parse("log_timestamps = true"));
151        assert!(!parse("log_timestamps = false"));
152    }
153
154    #[test]
155    fn an_unedited_config_has_drifted_in_no_field() {
156        let app = AppConfig::minimal("web", "./srv");
157
158        assert!(app.drifted_fields(&app.clone()).is_empty());
159    }
160
161    #[test]
162    fn drift_names_every_edited_field_and_no_other() {
163        // Two fields, not one, so a comparator that stopped at the first
164        // difference fails here.
165        let stored = AppConfig::minimal("proto-api", "./proto-enum-api");
166        let mut edited = stored.clone();
167        edited.cwd = Some("/srv/pogo-proto-api".to_string());
168        edited.args = vec!["-config".to_string(), "config.toml".to_string()];
169
170        assert_eq!(
171            stored.drifted_fields(&edited),
172            vec!["args".to_string(), "cwd".to_string()]
173        );
174    }
175
176    #[test]
177    fn drift_reports_env_by_name_and_never_by_value() {
178        let stored = AppConfig::minimal("web", "./srv");
179        let mut edited = stored.clone();
180        edited
181            .env
182            .insert("DATABASE_URL".to_string(), "postgres://hunter2".to_string());
183
184        let fields = edited.drifted_fields(&stored);
185
186        assert_eq!(fields, vec!["env".to_string()]);
187        // Names go to an operator; a value never should.
188        assert!(!fields.concat().contains("hunter2"));
189    }
190
191    #[test]
192    fn drift_is_symmetric() {
193        let stored = AppConfig::minimal("web", "./srv");
194        let mut edited = stored.clone();
195        edited.instances = 4;
196
197        assert_eq!(
198            stored.drifted_fields(&edited),
199            edited.drifted_fields(&stored)
200        );
201        assert_eq!(
202            stored.drifted_fields(&edited),
203            vec!["instances".to_string()]
204        );
205    }
206}