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