Skip to main content

shep_core/config/normalize/
resolved_app.rs

1use std::collections::BTreeSet;
2use std::path::Path;
3
4use crate::config::{AppConfig, CronParseError, CronSchedule, KillSignal, LevelMatcher};
5use crate::secrets;
6use crate::values::UpDuration;
7
8use super::{NormalizeError, tilde, validate};
9
10/// A validated app config: only obtainable via [`normalize`].
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct ResolvedApp {
13    pub(super) config: AppConfig,
14}
15
16impl ResolvedApp {
17    /// Borrow the validated configuration
18    #[must_use]
19    pub fn config(&self) -> &AppConfig {
20        &self.config
21    }
22
23    /// Unwrap the validated configuration (consumes the proof token)
24    #[must_use]
25    pub fn into_config(self) -> AppConfig {
26        self.config
27    }
28}
29
30/// Validates one app config
31///
32/// # Errors
33///
34/// - [`NormalizeError::MissingName`]: `name` is empty.
35/// - [`NormalizeError::InvalidName`]: `name` contains a path separator or a colon, or is `.`/`..`.
36/// - [`NormalizeError::InvalidEnvironment`]: `environment` is `all`, the secrets store's every-environment slot, or falls outside the store's name grammar.
37/// - [`NormalizeError::ReservedEnvVar`]: `env` sets `SHEP_INSTANCE`, `SHEP_NAME` or `SHEP_ENVIRONMENT`, which shep injects itself.
38/// - [`NormalizeError::MissingScript`]: `script` is empty.
39/// - [`NormalizeError::ZeroInstances`]: `instances == 0`.
40/// - [`NormalizeError::InvalidCron`]: `cron_restart` is not valid in croner's dialect.
41/// - [`NormalizeError::InvalidTimezone`]: `cron_timezone` is not a name in the IANA time-zone database.
42/// - [`NormalizeError::InvalidProbe`]: a `readiness_probe`/`liveness_probe` target [`ProbeTarget::parse`](crate::config::ProbeTarget::parse) rejects.
43/// - [`NormalizeError::ZeroFailureThreshold`]: a probe's `failure_threshold` is explicitly `0`.
44/// - [`NormalizeError::IntervalBelowMinimum`]: a probe's `interval` is under the floor its own loop honours.
45/// - [`NormalizeError::ZeroMaxMemory`]: `max_memory` is `0`.
46/// - [`NormalizeError::ActionTimeoutTooLong`]: `action_timeout` is at or above the ceiling no RPC caller could wait past.
47/// - [`NormalizeError::InvalidKillSignal`]: `kill_signal` names a signal the daemon's stop ladder cannot send.
48/// - [`NormalizeError::WatchWithoutCwd`]: `watch` is `true` with no `cwd` set.
49/// - [`NormalizeError::ZeroWatchDelay`]: `watch_delay` is `0`.
50/// - [`NormalizeError::InvalidWatchGlob`]: a `watch_options` or `ignore_watch` pattern globset will not compile.
51/// - [`NormalizeError::InvalidLevelRule`]: a `level_rules` entry has an empty pattern, one regex will not compile, or the list is longer than the ceiling on how many rules an app may declare.
52/// - [`NormalizeError::EmptyDogName`]: a `dogs` key is the empty string.
53/// - [`NormalizeError::BadTemplate`]: an `env`/`args`/log-path value carries an undefined or unclosed `{{...}}` token.
54/// - [`NormalizeError::SecretInLogPath`]: `out_file` or `err_file` carries a `{{secret:...}}`.
55/// - [`NormalizeError::SharedLogPath`]: `out_file` or `err_file` renders to the same path for two instances.
56/// - [`NormalizeError::TildeUser`]: a path field names another user's `~user` home.
57/// - [`NormalizeError::NoHomeForTilde`]: a path field expands `~/` but no home directory could be found.
58/// - [`NormalizeError::NoShepHome`]: a templated value carries `{{SHEP_HOME}}` but no shep home could be found.
59/// - [`NormalizeError::SelfDependency`]: an app names itself in `depends_on`.
60/// - [`NormalizeError::InstanceDependency`]: a `depends_on` entry is written `name:slot`.
61pub fn normalize(app: AppConfig) -> Result<ResolvedApp, NormalizeError> {
62    let home = std::env::home_dir();
63    let shep_home = crate::paths::shep_home(&|key| std::env::var(key).ok(), home.as_deref());
64    normalize_with_home(app, home.as_deref(), shep_home.as_deref())
65}
66
67/// Validates a whole flock, rejecting duplicate sheep names
68///
69/// # Errors
70///
71/// Everything [`normalize`] returns, plus
72/// [`NormalizeError::DuplicateName`]: two apps share a `name`.
73/// [`NormalizeError::DependencyCycle`]: two apps in the document depend on each other.
74pub fn normalize_all(apps: Vec<AppConfig>) -> Result<Vec<ResolvedApp>, NormalizeError> {
75    let mut seen = BTreeSet::new();
76    let resolved: Vec<ResolvedApp> = apps
77        .into_iter()
78        .map(|app| {
79            if !seen.insert(app.name.clone()) {
80                return Err(NormalizeError::DuplicateName(app.name));
81            }
82            normalize(app)
83        })
84        .collect::<Result<_, _>>()?;
85    // Document-local only: a name this document does not hold is left to the
86    // daemon, which is the only place that knows the whole flock.
87    let nodes: Vec<crate::config::graph::BootNode> = resolved
88        .iter()
89        .map(|app| crate::config::graph::BootNode {
90            name: app.config().name.clone(),
91            depends_on: app.config().depends_on.clone(),
92            kind: crate::config::graph::NodeKind::Sheep,
93        })
94        .collect();
95    if let Some(cycle) = crate::config::graph::plan(&nodes).cycles.into_iter().next() {
96        return Err(NormalizeError::DependencyCycle(cycle));
97    }
98    Ok(resolved)
99}
100
101/// Shortest `interval` a `liveness_probe` may name.
102///
103/// The daemon's liveness loop floors whatever it is handed at this same
104/// value, so a smaller number would be silently honoured as this one with
105/// no warning.
106///
107/// One second is a floor no legitimate configuration wants to be under: for
108/// [`ProbeKind::Exec`](crate::config::ProbeKind::Exec) a shorter interval is
109/// that many process spawns per second, per sheep, for as long as it runs.
110pub(super) const MIN_LIVENESS_INTERVAL: UpDuration = UpDuration::from_millis(1_000);
111
112/// Shortest `interval` a `readiness_probe` may name.
113///
114/// A whole second lower than [`MIN_LIVENESS_INTERVAL`]: the readiness wait
115/// honours its `interval` exactly as written, bounded by `listen_timeout`,
116/// so only zero needs rejecting. A fast app polling every 20ms to leave
117/// `starting` sooner must not lose that.
118pub(super) const MIN_READINESS_INTERVAL: UpDuration = UpDuration::from_millis(1);
119
120/// Longest `action_timeout` an app may name.
121///
122/// The daemon clamps every RPC deadline to `MAX_DEADLINE_MS` (60s, in
123/// shep-daemon's `rpc` module), so a value at or above that line could
124/// never be honoured by any caller. Set 2s under the clamp so the daemon
125/// still has room to build the `TimedOut` row and send it back after the
126/// wait gives up.
127const MAX_ACTION_TIMEOUT: UpDuration = UpDuration::from_millis(58_000);
128
129/// Parameters so the `~/` expansion above is testable without mutating the
130/// process environment, which is racy under a parallel `cargo test`. This is
131/// also the seam that matters for correctness rather than only for tests:
132/// the daemon may run as a different user than the CLI, so `~` has to be
133/// resolved where the config is normalised, not where it is executed.
134///
135/// `shep_home` arrives the same way and for the same reason: it is the
136/// directory `{{SHEP_HOME}}` expands to, and reading it inside the renderer
137/// would resolve it wherever the value happened to be rendered.
138///
139/// [`normalize`], with both home directories supplied rather than read.
140///
141/// # Errors
142/// The same set [`normalize`] documents.
143pub fn normalize_with_home(
144    mut app: AppConfig,
145    home: Option<&Path>,
146    shep_home: Option<&Path>,
147) -> Result<ResolvedApp, NormalizeError> {
148    if app.name.is_empty() {
149        return Err(NormalizeError::MissingName);
150    }
151    if app.name.contains(['/', '\\', ':']) || app.name == "." || app.name == ".." {
152        return Err(NormalizeError::InvalidName(app.name));
153    }
154    if let Some(environment) = &app.environment
155        && (environment == secrets::ALL_ENVIRONMENTS || !secrets::is_name(environment))
156    {
157        return Err(NormalizeError::InvalidEnvironment {
158            name: app.name.clone(),
159            value: environment.clone(),
160        });
161    }
162    for var in ["SHEP_INSTANCE", "SHEP_NAME", "SHEP_ENVIRONMENT"] {
163        if app.env.contains_key(var) {
164            return Err(NormalizeError::ReservedEnvVar {
165                name: app.name.clone(),
166                var,
167            });
168        }
169    }
170    for (key, value) in &app.env {
171        validate::validate_template(&app.name, &format!("env.{key}"), value, shep_home)?;
172    }
173    for (index, value) in app.args.iter().enumerate() {
174        validate::validate_template(&app.name, &format!("args[{index}]"), value, shep_home)?;
175    }
176    for (field, value) in [("out_file", &app.out_file), ("err_file", &app.err_file)] {
177        if let Some(value) = value {
178            validate::validate_template(&app.name, field, value, shep_home)?;
179            if crate::config::template::holds_secret(value) {
180                return Err(NormalizeError::SecretInLogPath {
181                    name: app.name.clone(),
182                    field,
183                });
184            }
185        }
186    }
187    if app.script.is_empty() {
188        return Err(NormalizeError::MissingScript);
189    }
190    // After the emptiness checks, so a missing script is reported as missing
191    // rather than as a path problem, and before every check below that reads
192    // a path.
193    tilde::expand_paths(&mut app, home)?;
194    if app.instances == 0 {
195        return Err(NormalizeError::ZeroInstances);
196    }
197    // After the template validation above, so a malformed `out_file`/`err_file`
198    // is reported as a bad template rather than as a shared path.
199    if app.instances > 1 && !app.merge_logs {
200        for (field, path) in [("out_file", &app.out_file), ("err_file", &app.err_file)] {
201            // Rendered rather than searched for a substring: an escaped
202            // `{{{{instance}}}}` contains the token's spelling but renders to
203            // one literal path for every instance, which is exactly the
204            // collision this refuses. Two slots that render alike collide.
205            if let Some(path) = path
206                && crate::config::template::render_positional(path, &app.name, 0, shep_home)
207                    == crate::config::template::render_positional(path, &app.name, 1, shep_home)
208            {
209                return Err(NormalizeError::SharedLogPath {
210                    name: app.name.clone(),
211                    field,
212                });
213            }
214        }
215    }
216    if let Some(pattern) = &app.cron_restart {
217        CronSchedule::parse(pattern, app.cron_timezone.as_deref()).map_err(|e| match e {
218            CronParseError::Pattern { pattern, reason } => {
219                NormalizeError::InvalidCron { pattern, reason }
220            }
221            CronParseError::Timezone { name } => NormalizeError::InvalidTimezone { name },
222        })?;
223    } else if let Some(tz_name) = &app.cron_timezone {
224        // A Flockfile can carry `cron_timezone` with no `cron_restart` to
225        // pair it with, still a typo the user wants to hear about.
226        crate::config::cron::parse_timezone_name(tz_name).ok_or_else(|| {
227            NormalizeError::InvalidTimezone {
228                name: tz_name.clone(),
229            }
230        })?;
231    }
232    validate::validate_probe(
233        app.readiness_probe.as_ref(),
234        "readiness_probe",
235        MIN_READINESS_INTERVAL,
236    )?;
237    validate::validate_probe(
238        app.liveness_probe.as_ref(),
239        "liveness_probe",
240        MIN_LIVENESS_INTERVAL,
241    )?;
242    if app.max_memory.is_some_and(|limit| limit.bytes() == 0) {
243        // A ceiling every live process is already over: the enforcer would
244        // report a breach on every reading, and the automatic restart that
245        // follows resets the restart budget rather than spending it, so
246        // `max_restarts` can never end the loop.
247        return Err(NormalizeError::ZeroMaxMemory { name: app.name });
248    }
249    if let Some(name) = &app.kill_signal
250        && KillSignal::parse(name).is_none()
251    {
252        // Rejected rather than clamped: a typo silently falling back to
253        // SIGTERM would cost every stop and reload for the life of the
254        // process, with no evidence but a detached daemon's log.
255        return Err(NormalizeError::InvalidKillSignal {
256            name: app.name,
257            value: name.clone(),
258        });
259    }
260    if app.action_timeout > MAX_ACTION_TIMEOUT {
261        // Rejected rather than clamped: every value above the ceiling is
262        // equally unreachable by any caller, so there is no honest lowered
263        // value to silently fall back to.
264        return Err(NormalizeError::ActionTimeoutTooLong {
265            name: app.name,
266            value: app.action_timeout,
267            max: MAX_ACTION_TIMEOUT,
268        });
269    }
270    if app.watch && app.cwd.is_none() {
271        // `watch` asked for a feature the daemon has no directory to arm:
272        // there is no cwd in the Flockfile, and defaulting to the daemon's
273        // own cwd risks watching the whole filesystem under a systemd unit
274        // with no `WorkingDirectory=`.
275        return Err(NormalizeError::WatchWithoutCwd { name: app.name });
276    }
277    if app.watch_delay == Some(UpDuration::from_millis(0)) {
278        // notify's debouncer derives its own poll tick as `watch_delay / 4`
279        // on a dedicated OS thread, so a zero turns that thread into
280        // `loop { sleep(0); lock(); }`, a CPU-spinning busy loop.
281        return Err(NormalizeError::ZeroWatchDelay { name: app.name });
282    }
283    // Both lists are checked whether or not `watch` is on, so a typo'd
284    // pattern is named now rather than the day `watch` flips on and nothing
285    // happens.
286    validate::validate_watch_globs(&app.name, "watch_options", &app.watch_options)?;
287    validate::validate_watch_globs(&app.name, "ignore_watch", &app.ignore_watch)?;
288    // Compiled to reject, and the result discarded: whichever client
289    // classifies this sheep's lines compiles its own. A pattern refused
290    // here can never reach one, which is the point of refusing it at config
291    // time rather than at render time, where nobody would read the message.
292    LevelMatcher::compile(&app.level_rules).map_err(|err| NormalizeError::InvalidLevelRule {
293        name: app.name.clone(),
294        reason: err.to_string(),
295    })?;
296    if app.dogs.keys().any(String::is_empty) {
297        return Err(NormalizeError::EmptyDogName { name: app.name });
298    }
299    let mut seen = BTreeSet::new();
300    let mut deduped = Vec::with_capacity(app.depends_on.len());
301    for target in &app.depends_on {
302        if target == &app.name {
303            return Err(NormalizeError::SelfDependency(app.name.clone()));
304        }
305        if target.contains(':') {
306            return Err(NormalizeError::InstanceDependency {
307                sheep: app.name.clone(),
308                target: target.clone(),
309            });
310        }
311        if seen.insert(target.clone()) {
312            deduped.push(target.clone());
313        }
314    }
315    app.depends_on = deduped;
316    Ok(ResolvedApp { config: app })
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322    use crate::config::{LevelRule, LevelRuleError, LineLevel};
323
324    /// `reuse_port` loads because reload's overlap mode is chosen from it:
325    /// refusing it would deny an operator the only way to ask for an
326    /// overlapping reload.
327    #[test]
328    fn reuse_port_loads_now_that_reload_reads_it() {
329        let mut app = AppConfig::minimal("web", "./server");
330        app.reuse_port = true;
331
332        let resolved = normalize(app).expect("reuse_port is implemented");
333        assert!(resolved.config().reuse_port);
334    }
335
336    /// The default is off, so every Flockfile that does not mention it keeps
337    /// loading.
338    #[test]
339    fn an_app_that_never_mentions_reuse_port_still_normalizes() {
340        let resolved = normalize(AppConfig::minimal("web", "./server"))
341            .expect("the common case must be untouched");
342        assert!(!resolved.config().reuse_port);
343    }
344    use crate::config::AppConfig;
345
346    #[test]
347    fn a_colon_in_a_name_is_refused_because_it_is_the_instance_separator() {
348        let err = normalize(AppConfig::minimal("web:2", "./srv")).unwrap_err();
349        assert_eq!(err, NormalizeError::InvalidName("web:2".to_string()));
350
351        let rendered = err.to_string();
352        assert!(rendered.contains(':'), "says which character: {rendered}");
353        // Spec D3 requires the error to name the character and suggest `-`.
354        assert!(
355            rendered.contains("`-`"),
356            "suggests the stand-in: {rendered}"
357        );
358        assert!(
359            !rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
360            "no em or en dash in copy a user reads: {rendered}"
361        );
362
363        assert!(normalize(AppConfig::minimal("web-2", "./srv")).is_ok());
364    }
365
366    /// Each reserved variable is refused with advice that fits it (#284):
367    /// the `{{instance}}` and `{{name}}` templates for the two identity
368    /// variables, and the `environment` field for the one that is not a
369    /// template token at all. Nothing carries another variable's advice.
370    #[test]
371    fn the_reserved_env_vars_are_refused_rather_than_overwritten() {
372        // Every advice string the Display arm can produce, so each case can
373        // assert the absence of all the others. Naming one counterpart each
374        // left the environment advice unexcluded on both identity variables.
375        let advice = [
376            ("SHEP_INSTANCE", "or `{{instance}}` in your own variable"),
377            ("SHEP_NAME", "or `{{name}}` in your own variable"),
378            ("SHEP_ENVIRONMENT", "or set `environment` on the app"),
379        ];
380        for (var, fitting) in advice {
381            let mut app = AppConfig::minimal("web", "./srv");
382            app.env.insert(var.to_string(), "mine".to_string());
383            let err = normalize(app).unwrap_err();
384            let rendered = err.to_string();
385            assert!(rendered.contains(var), "names the variable: {rendered}");
386            assert!(
387                rendered.contains(fitting),
388                "{var} gets advice that fits it: {rendered}"
389            );
390            for (owner, unrelated) in advice {
391                assert!(
392                    owner == var || !rendered.contains(unrelated),
393                    "{var} carries {owner}'s advice: {rendered}"
394                );
395            }
396            // The environment is not a template token, so its advice offers
397            // no template and the message must not grow one from elsewhere.
398            assert_eq!(
399                rendered.contains("{{"),
400                fitting.contains("{{"),
401                "{var} offers a template only if its own advice does: {rendered}"
402            );
403            assert!(
404                !rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
405                "no em or en dash in copy a user reads: {rendered}"
406            );
407        }
408    }
409
410    #[test]
411    fn valid_minimal_config_normalizes() {
412        let resolved = normalize(AppConfig::minimal("web", "./srv")).unwrap();
413        assert_eq!(resolved.config().name, "web");
414    }
415
416    #[test]
417    fn names_that_reach_the_filesystem_are_rejected() {
418        // A name becomes a log/pid file stem via Path::join; a slash-prefixed
419        // or dotdot name would escape $SHEP_HOME. Reject at the config boundary.
420        for bad in ["/etc/passwd", "..", ".", "a/b", "a\\b"] {
421            assert_eq!(
422                normalize(AppConfig::minimal(bad, "./srv")).unwrap_err(),
423                NormalizeError::InvalidName(bad.to_string())
424            );
425        }
426        assert!(normalize(AppConfig::minimal("web-1", "./srv")).is_ok());
427    }
428
429    #[test]
430    fn missing_name_and_script_are_distinct_errors() {
431        assert_eq!(
432            normalize(AppConfig::minimal("", "./srv")).unwrap_err(),
433            NormalizeError::MissingName
434        );
435        assert_eq!(
436            normalize(AppConfig::minimal("web", "")).unwrap_err(),
437            NormalizeError::MissingScript
438        );
439    }
440
441    #[test]
442    fn zero_instances_rejected() {
443        let mut app = AppConfig::minimal("web", "./srv");
444        app.instances = 0;
445        assert_eq!(normalize(app).unwrap_err(), NormalizeError::ZeroInstances);
446    }
447
448    #[test]
449    fn bad_cron_pattern_rejected_with_pattern_and_reason_carried_through() {
450        // fails if the reason is not carried through from croner.
451        let mut app = AppConfig::minimal("web", "./srv");
452        app.cron_restart = Some("not a cron".to_string());
453        match normalize(app).unwrap_err() {
454            NormalizeError::InvalidCron { pattern, reason } => {
455                assert_eq!(pattern, "not a cron");
456                assert!(!reason.is_empty());
457            }
458            other => panic!("expected InvalidCron, got {other:?}"),
459        }
460    }
461
462    #[test]
463    fn five_tokens_of_garbage_cron_pattern_rejected() {
464        // fails if the validator only counts whitespace-separated tokens
465        // instead of checking each field's range: five numeric-looking
466        // tokens, all out of range.
467        let mut app = AppConfig::minimal("web", "./srv");
468        app.cron_restart = Some("99 99 99 99 99".to_string());
469        match normalize(app).unwrap_err() {
470            NormalizeError::InvalidCron { pattern, .. } => {
471                assert_eq!(pattern, "99 99 99 99 99");
472            }
473            other => panic!("expected InvalidCron, got {other:?}"),
474        }
475    }
476
477    #[test]
478    fn bad_cron_timezone_rejected_alongside_a_valid_cron_restart() {
479        // fails if the `cron_restart` branch maps CronParseError::Timezone to
480        // anything but NormalizeError::InvalidTimezone. CronSchedule::parse
481        // resolves the zone before the pattern, so a valid pattern paired
482        // with a bad zone is the only input that reaches that arm.
483        let mut app = AppConfig::minimal("web", "./srv");
484        app.cron_restart = Some("0 3 * * *".to_string());
485        app.cron_timezone = Some("Mars/Olympus".to_string());
486        match normalize(app).unwrap_err() {
487            NormalizeError::InvalidTimezone { name } => assert_eq!(name, "Mars/Olympus"),
488            other => panic!("expected InvalidTimezone, got {other:?}"),
489        }
490    }
491
492    #[test]
493    fn cron_timezone_validated_even_without_cron_restart() {
494        // fails if timezone validation is skipped when there's no pattern to
495        // pair it with: a Flockfile with only a bad `cron_timezone` is a
496        // typo the user wants to hear about.
497        let mut app = AppConfig::minimal("web", "./srv");
498        app.cron_timezone = Some("Mars/Olympus".to_string());
499        match normalize(app).unwrap_err() {
500            NormalizeError::InvalidTimezone { name } => assert_eq!(name, "Mars/Olympus"),
501            other => panic!("expected InvalidTimezone, got {other:?}"),
502        }
503    }
504
505    #[test]
506    fn duplicate_names_rejected_across_a_flock() {
507        let apps = vec![
508            AppConfig::minimal("web", "./a"),
509            AppConfig::minimal("web", "./b"),
510        ];
511        assert_eq!(
512            normalize_all(apps).unwrap_err(),
513            NormalizeError::DuplicateName("web".to_string())
514        );
515    }
516
517    #[test]
518    fn a_cycle_inside_one_document_is_refused_and_named() {
519        // fails if normalize_all accepts a cycle, or reports it without a path
520        let mut a = AppConfig::minimal("a", "./a");
521        a.depends_on = vec!["b".to_string()];
522        let mut b = AppConfig::minimal("b", "./b");
523        b.depends_on = vec!["a".to_string()];
524        match normalize_all(vec![a, b]) {
525            Err(NormalizeError::DependencyCycle(cycle)) => {
526                let rendered = NormalizeError::DependencyCycle(cycle).to_string();
527                assert!(rendered.contains("a"), "{rendered}");
528                assert!(rendered.contains("b"), "{rendered}");
529                assert!(
530                    rendered.contains(" -> "),
531                    "the path must be shown: {rendered}"
532                );
533            }
534            other => panic!("expected DependencyCycle, got {other:?}"),
535        }
536    }
537
538    #[test]
539    fn a_dependency_on_an_app_outside_the_document_is_accepted() {
540        // fails if the document-local check refuses a cross-repository
541        // dependency, which would make depends_on unusable across Flockfiles
542        let mut api = AppConfig::minimal("api", "./api");
543        api.depends_on = vec!["db-in-another-repo".to_string()];
544        normalize_all(vec![api]).expect("an unresolved name is not a document error");
545    }
546
547    #[test]
548    fn zero_max_memory_rejected() {
549        // fails if `max_memory` is never inspected: zero is a ceiling every
550        // live process is already over, so the enforcer breaches on every
551        // reading and the automatic restart that follows resets the
552        // restart budget instead of spending it.
553        let mut app = AppConfig::minimal("web", "./srv");
554        app.max_memory = Some(crate::values::MemSize::from_bytes(0));
555        let err = normalize(app).unwrap_err();
556        assert_eq!(
557            err,
558            NormalizeError::ZeroMaxMemory {
559                name: "web".to_string()
560            }
561        );
562        // fails if the message regresses to a bare variant name with no
563        // explanation.
564        assert!(err.to_string().contains("max_memory"), "{err}");
565    }
566
567    #[test]
568    fn a_nonzero_max_memory_is_accepted() {
569        // fails if the check fires on `max_memory` being set at all rather
570        // than on its being zero: that would refuse every app that
571        // configures a limit, which is the whole feature
572        let mut app = AppConfig::minimal("web", "./srv");
573        app.max_memory = Some("512M".parse().unwrap());
574        assert!(normalize(app).is_ok());
575    }
576
577    /// fails if a `kill_signal` shep cannot send is accepted here: that puts
578    /// SIGTERM on the wire for the life of the process with nothing but one
579    /// daemon log line to say so.
580    #[test]
581    fn a_kill_signal_shep_cannot_send_is_refused_by_name() {
582        let mut app = AppConfig::minimal("web", "./srv");
583        app.kill_signal = Some("SIGUSR1".to_string());
584
585        let err = normalize(app).unwrap_err();
586
587        assert_eq!(
588            err,
589            NormalizeError::InvalidKillSignal {
590                name: "web".to_string(),
591                value: "SIGUSR1".to_string(),
592            }
593        );
594        // The message has to name the accepted set, because the operator's next
595        // move is picking a different word and there is nowhere else to look.
596        let rendered = err.to_string();
597        assert!(rendered.contains("SIGUSR1"), "{rendered}");
598        assert!(rendered.contains("SIGTERM"), "{rendered}");
599        assert!(rendered.contains("SIGUSR2"), "{rendered}");
600    }
601
602    /// fails if the four supported names, their bare forms, or a lowercase
603    /// spelling stop being accepted. This is the compatibility half: every
604    /// spelling `stop_signal` accepted before this task must still normalize.
605    #[test]
606    fn every_spelling_the_daemon_already_accepted_still_normalizes() {
607        for name in [
608            "SIGTERM", "TERM", "sigterm", "term", "SIGINT", "INT", "SIGQUIT", "QUIT", "SIGUSR2",
609            "USR2", "sigusr2",
610        ] {
611            let mut app = AppConfig::minimal("web", "./srv");
612            app.kill_signal = Some(name.to_string());
613            assert!(
614                normalize(app).is_ok(),
615                "`{name}` was accepted before this task and must still be"
616            );
617        }
618    }
619
620    /// fails if an unset `kill_signal` is refused: the overwhelmingly common
621    /// case, and the one a validation pass is most likely to break by
622    /// treating `None` as an empty string.
623    #[test]
624    fn an_unset_kill_signal_is_not_a_config_error() {
625        let app = AppConfig::minimal("web", "./srv");
626        assert!(app.kill_signal.is_none());
627        assert!(normalize(app).is_ok());
628    }
629
630    #[test]
631    fn a_sheep_cannot_claim_the_all_environment() {
632        // `all` is the store's every-environment slot. A sheep resolving in
633        // it would read that slot twice and never its own.
634        let mut app = AppConfig::minimal("web", "./srv");
635        app.environment = Some("all".to_string());
636        let err = normalize(app).unwrap_err();
637        assert!(err.to_string().contains("all"), "{err}");
638    }
639
640    #[test]
641    fn an_environment_outside_the_grammar_is_refused() {
642        for bad in ["", "has space", "has/slash"] {
643            let mut app = AppConfig::minimal("web", "./srv");
644            app.environment = Some(bad.to_string());
645            assert!(normalize(app).is_err(), "{bad:?} must be refused");
646        }
647    }
648
649    #[test]
650    fn action_timeout_past_the_ceiling_is_rejected() {
651        // fails if `action_timeout` is never inspected. One millisecond over
652        // the ceiling is deliberate: a test at a round number like 60s could
653        // pass by coincidence if the check used the wrong constant entirely
654        // (`MAX_DEADLINE_MS` itself, say, instead of the margin under it).
655        let mut app = AppConfig::minimal("web", "./srv");
656        app.action_timeout = UpDuration::from_millis(MAX_ACTION_TIMEOUT.as_millis() + 1);
657        let err = normalize(app).unwrap_err();
658        assert_eq!(
659            err,
660            NormalizeError::ActionTimeoutTooLong {
661                name: "web".to_string(),
662                value: UpDuration::from_millis(MAX_ACTION_TIMEOUT.as_millis() + 1),
663                max: MAX_ACTION_TIMEOUT,
664            }
665        );
666        // fails if the message regresses to a bare variant name with no
667        // explanation.
668        assert!(err.to_string().contains("action_timeout"), "{err}");
669    }
670
671    #[test]
672    fn action_timeout_at_the_ceiling_is_accepted() {
673        // fails if the comparison is `>=` rather than `>`: the ceiling
674        // itself still leaves the daemon its full margin under the hard
675        // clamp, so it is not one of the values nothing could ever satisfy.
676        let mut app = AppConfig::minimal("web", "./srv");
677        app.action_timeout = MAX_ACTION_TIMEOUT;
678        assert!(normalize(app).is_ok());
679    }
680
681    #[test]
682    fn the_default_action_timeout_is_accepted() {
683        // fails if `AppConfig::default()`'s own value ever drifts past the
684        // ceiling normalize enforces: the one combination that must never
685        // reject the config nobody customized.
686        assert!(normalize(AppConfig::minimal("web", "./srv")).is_ok());
687    }
688
689    #[test]
690    fn zero_watch_delay_rejected() {
691        // fails if `watch_delay` is never inspected. notify's debouncer
692        // derives its poll tick as `watch_delay / 4` and sleeps it on its own
693        // OS thread, so zero is `loop { sleep(0); lock(); }`, a CPU-spinning
694        // busy loop.
695        let mut app = AppConfig::minimal("web", "./srv");
696        app.watch = true;
697        app.cwd = Some("/srv/web".to_string());
698        app.watch_delay = Some(UpDuration::from_millis(0));
699        let err = normalize(app).unwrap_err();
700        assert_eq!(
701            err,
702            NormalizeError::ZeroWatchDelay {
703                name: "web".to_string()
704            }
705        );
706        // fails if the message regresses to a bare variant name with no
707        // explanation.
708        assert!(err.to_string().contains("watch_delay"), "{err}");
709    }
710
711    #[test]
712    fn a_zero_watch_delay_is_rejected_with_watch_off() {
713        // fails if the check is nested inside the `watch` block: an app
714        // carrying `watch_delay = "0"` with `watch = false` would normalize
715        // clean, and the spin would arrive the day someone flips `watch =
716        // true`
717        let mut app = AppConfig::minimal("web", "./srv");
718        app.watch_delay = Some(UpDuration::from_millis(0));
719        assert!(matches!(
720            normalize(app).unwrap_err(),
721            NormalizeError::ZeroWatchDelay { .. }
722        ));
723    }
724
725    #[test]
726    fn a_nonzero_watch_delay_is_accepted() {
727        // fails if the check fires on `watch_delay` being set at all rather
728        // than on its being zero: that would refuse every app that tunes
729        // its own debounce
730        let mut app = AppConfig::minimal("web", "./srv");
731        app.watch = true;
732        app.cwd = Some("/srv/web".to_string());
733        app.watch_delay = Some(UpDuration::from_millis(1));
734        assert!(normalize(app).is_ok());
735    }
736
737    #[test]
738    fn watch_true_without_cwd_rejected_naming_the_app() {
739        // fails if a validator never looks at `watch`, or looks at it but
740        // carries no app name, leaving the user unable to tell which
741        // Flockfile entry to edit
742        let mut app = AppConfig::minimal("web", "./srv");
743        app.watch = true;
744        let err = normalize(app).unwrap_err();
745        assert_eq!(
746            err,
747            NormalizeError::WatchWithoutCwd {
748                name: "web".to_string()
749            }
750        );
751        // fails if the message regresses to a bare variant name with no
752        // explanation.
753        assert!(err.to_string().contains("no cwd to watch"), "{err}");
754    }
755
756    #[test]
757    fn watch_true_with_cwd_accepted() {
758        // fails if the check fires on `watch` alone, ignoring that a cwd was
759        // actually provided
760        let mut app = AppConfig::minimal("web", "./srv");
761        app.watch = true;
762        app.cwd = Some("/srv/web".to_string());
763        assert!(normalize(app).is_ok());
764    }
765
766    #[test]
767    fn level_rules_survive_normalization_in_the_order_they_were_written() {
768        let mut app = AppConfig::minimal("web", "./srv");
769        app.level_rules = vec![
770            LevelRule {
771                pattern: r"\[ERROR\]".to_string(),
772                level: LineLevel::Error,
773            },
774            LevelRule {
775                pattern: r"\[WARN\]".to_string(),
776                level: LineLevel::Warn,
777            },
778        ];
779        let resolved = normalize(app.clone()).unwrap();
780        assert_eq!(resolved.config().level_rules, app.level_rules);
781    }
782
783    /// The whole reason patterns are compiled here: a Flockfile that reaches
784    /// the shepherd with a pattern nobody can compile would fail at draw
785    /// time, in a client, where the message reaches nobody.
786    #[test]
787    fn a_level_rule_pattern_that_will_not_compile_is_rejected() {
788        let mut app = AppConfig::minimal("web", "./srv");
789        app.level_rules = vec![LevelRule {
790            pattern: "[unclosed".to_string(),
791            level: LineLevel::Error,
792        }];
793        let err = normalize(app).unwrap_err();
794        let rendered = err.to_string();
795        for expected in ["web", "[unclosed"] {
796            assert!(
797                rendered.contains(expected),
798                "{expected} missing: {rendered}"
799            );
800        }
801    }
802
803    /// An empty pattern matches every line, so the rule would claim the feed
804    /// and leave every rule after it dead.
805    #[test]
806    fn an_empty_level_rule_pattern_is_rejected() {
807        let mut app = AppConfig::minimal("web", "./srv");
808        app.level_rules = vec![LevelRule {
809            pattern: String::new(),
810            level: LineLevel::Warn,
811        }];
812        let err = normalize(app).unwrap_err();
813        assert_eq!(
814            err,
815            NormalizeError::InvalidLevelRule {
816                name: "web".to_string(),
817                reason: LevelRuleError::EmptyPattern {
818                    level: LineLevel::Warn
819                }
820                .to_string(),
821            }
822        );
823    }
824
825    #[test]
826    fn watch_options_without_watch_or_cwd_accepted() {
827        // fails if the check is keyed on `watch_options` being non-empty
828        // rather than on `watch` being true: that would reject a Flockfile
829        // that never asked to be watched
830        let mut app = AppConfig::minimal("web", "./srv");
831        app.watch_options = vec!["src/**".to_string()];
832        assert!(normalize(app).is_ok());
833    }
834
835    #[test]
836    fn a_sheep_that_depends_on_itself_is_refused_by_name() {
837        // fails if the self-edge check is missing, or if it reports a bare
838        // cycle rather than naming the sheep
839        let mut app = AppConfig::minimal("api", "./api");
840        app.depends_on = vec!["api".to_string()];
841        match normalize(app) {
842            Err(NormalizeError::SelfDependency(name)) => assert_eq!(name, "api"),
843            other => panic!("expected SelfDependency, got {other:?}"),
844        }
845    }
846
847    #[test]
848    fn a_dependency_on_one_instance_is_refused_naming_the_app_form() {
849        // fails if `name:slot` is accepted as a dependency target
850        let mut app = AppConfig::minimal("api", "./api");
851        app.depends_on = vec!["db:2".to_string()];
852        match normalize(app) {
853            Err(NormalizeError::InstanceDependency { sheep, target }) => {
854                assert_eq!(sheep, "api");
855                assert_eq!(target, "db:2");
856            }
857            other => panic!("expected InstanceDependency, got {other:?}"),
858        }
859        let rendered = NormalizeError::InstanceDependency {
860            sheep: "api".to_string(),
861            target: "db:2".to_string(),
862        }
863        .to_string();
864        assert!(
865            rendered.contains("`db`"),
866            "the refusal must name the app-level form: {rendered}"
867        );
868    }
869
870    #[test]
871    fn duplicate_dependencies_dedupe_rather_than_refusing() {
872        // fails if a repeated name is an error, or if it survives into the
873        // normalized config twice
874        let mut app = AppConfig::minimal("api", "./api");
875        app.depends_on = vec!["db".to_string(), "db".to_string()];
876        let resolved = normalize(app).expect("a repeated name is not an error");
877        assert_eq!(resolved.config().depends_on, vec!["db".to_string()]);
878    }
879
880    #[test]
881    fn an_ordinary_dependency_list_survives_normalize() {
882        // fails if the field is dropped or reordered
883        let mut app = AppConfig::minimal("api", "./api");
884        app.depends_on = vec!["db".to_string(), "cache".to_string()];
885        let resolved = normalize(app).expect("an ordinary list normalizes");
886        assert_eq!(
887            resolved.config().depends_on,
888            vec!["db".to_string(), "cache".to_string()]
889        );
890    }
891
892    #[test]
893    fn an_empty_dog_name_is_refused_naming_the_sheep() {
894        // fails if a dogs key of "" is accepted, or if the error drops
895        // which sheep's Flockfile entry to edit
896        let mut app = AppConfig::minimal("web", "./srv");
897        app.dogs.insert(
898            String::new(),
899            crate::config::DogTable::from(serde_json::Map::new()),
900        );
901        let err = normalize(app).unwrap_err();
902        assert_eq!(
903            err,
904            NormalizeError::EmptyDogName {
905                name: "web".to_string()
906            }
907        );
908        assert!(err.to_string().contains("web"));
909    }
910
911    #[test]
912    fn a_named_dog_table_survives_normalize() {
913        // fails if a well-formed dogs table is refused, or dropped from the
914        // resolved config
915        let mut app = AppConfig::minimal("web", "./srv");
916        let mut table = serde_json::Map::new();
917        table.insert("concurrency".to_string(), serde_json::json!(2));
918        app.dogs
919            .insert("jobs".to_string(), crate::config::DogTable::from(table));
920        let resolved = normalize(app).expect("a named dog table normalizes");
921        assert!(resolved.config().dogs.contains_key("jobs"));
922    }
923}