1use super::env_value::deserialize_env;
2use super::probe::ProbeConfig;
3use std::collections::BTreeMap;
4use crate::config::LevelRule;
6use crate::values::{MemSize, UpDuration};
7use serde::{Deserialize, Serialize};
8
9#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
41#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
42#[cfg_attr(feature = "schema", schemars(deny_unknown_fields))]
43#[serde(default)]
44pub struct AppConfig {
45 #[cfg_attr(feature = "schema", schemars(extend("init" = {
47 "example": "my-first-sheep",
48 "group": "process",
49 "blurb": "A convenient and unique name for shep to display",
50 "accepts": ["letters, digits, and most punctuation"],
51 "refuses": ["a path separator or a colon", "a bare . or .."]
52 })))]
53 pub name: String,
54 #[cfg_attr(feature = "schema", schemars(extend("init" = {
56 "example": "./index.js",
57 "group": "process",
58 "blurb": "The script that shep should use to launch your app",
59 "accepts": ["an absolute or relative path, expanded from cwd",
60 "~ expands, $VARS do not",
61 "a script shep cannot resolve yet: warned"],
62 "neighbours": [{"field": "cwd", "note": "resolved against this cwd"},
63 {"field": "interpreter", "note": "picks what runs this script"}]
64 })))]
65 pub script: String,
66 #[cfg_attr(feature = "schema", schemars(extend("init" = {
68 "group": "inputs",
69 "blurb": "Arguments passed to the script, as a list",
70 "accepts": ["a list of strings, one argument each",
71 "{{instance}}, {{name}}, {{SHEP_HOME}} and {{secret:key}} expand"],
72 "refuses": ["an unclosed {{ token", "a token shep does not define"]
73 })))]
74 pub args: Vec<String>,
75 #[cfg_attr(feature = "schema", schemars(extend("init" = {
77 "example": "/srv/app",
78 "group": "process",
79 "blurb": "Where the process runs. Without it, the daemon's own directory",
80 "accepts": ["an absolute or relative path, expanded from cwd",
81 "~ expands, $VARS do not",
82 "a directory that does not exist yet: warned"],
83 "neighbours": [{"field": "script", "note": "resolved against this cwd"},
84 {"field": "out_file", "note": "relative paths follow it too"},
85 {"field": "watch_options", "note": "globs are rooted here"}]
86 })))]
87 pub cwd: Option<String>,
88 #[cfg_attr(feature = "schema", schemars(extend("init" = {
90 "example": "none",
91 "group": "process",
92 "blurb": "What runs the script. Set it to none to exec the file directly",
93 "accepts": ["a command name found on PATH", "none, to exec the script directly"]
94 })))]
95 pub interpreter: Option<String>,
96 #[serde(deserialize_with = "deserialize_env")]
105 #[cfg_attr(feature = "schema", schemars(
106 extend(
107 "init" = {
108 "example": "{ NODE_ENV = 'production' }",
109 "group": "inputs",
110 "blurb": "Environment variables for this app, layered over the daemon's own",
111 "accepts": ["a table of KEY = value pairs",
112 "a bare true or 8080, which arrives as text",
113 "{{instance}}, {{name}}, {{SHEP_HOME}} and {{secret:key}} expand in a value"],
114 "refuses": ["a float, since 1.10 would arrive as 1.1",
115 "SHEP_INSTANCE, SHEP_NAME, or SHEP_ENVIRONMENT, which shep sets itself",
116 "an unclosed {{ token",
117 "a token shep does not define"],
118 "neighbours": [{"field": "environment", "note": "which environment {{secret:...}} reads from"}]
119 },
120 "additionalProperties" = {
121 "anyOf": [{ "type": "string" }, { "type": "boolean" }, { "type": "integer" }]
122 }
123 )
124 ))]
125 pub env: BTreeMap<String, String>,
126 #[cfg_attr(feature = "schema", schemars(extend("init" = {
133 "example": "staging",
134 "group": "inputs",
135 "blurb": "Which environment this app resolves secrets in",
136 "accepts": ["a name from the secrets store"],
137 "refuses": ["all, the store's every-environment slot",
138 "a name outside letters, digits, dot, underscore, or dash"],
139 "neighbours": [{"field": "env", "note": "sets which environment its {{secret:...}} reads from"}]
140 })))]
141 pub environment: Option<String>,
142 #[cfg_attr(feature = "schema", schemars(extend("init" = {
144 "group": "process",
145 "blurb": "How many copies of this app to run"
146 })))]
147 pub instances: u32,
148 #[cfg_attr(feature = "schema", schemars(extend("init" = {
150 "group": "restart",
151 "blurb": "Restarts the process automatically when it exits unexpectedly"
152 })))]
153 pub autorestart: bool,
154 #[cfg_attr(feature = "schema", schemars(extend("init" = {
156 "group": "restart",
157 "blurb": "Start this app when the daemon starts, and on shep muster"
158 })))]
159 pub autostart: bool,
160 #[cfg_attr(feature = "schema", schemars(extend("init" = {
162 "group": "restart",
163 "blurb": "Exit codes that mean a clean stop, so shep will not restart",
164 "accepts": ["a list of exit codes"]
165 })))]
166 pub stop_exit_codes: Vec<i32>,
167 #[cfg_attr(feature = "schema", schemars(extend("init" = {
169 "group": "restart",
170 "blurb": "An exit sooner than this counts as unstable"
171 })))]
172 pub min_uptime: UpDuration,
173 #[cfg_attr(feature = "schema", schemars(extend("init" = {
175 "group": "restart",
176 "blurb": "How many unstable exits in a row before shep gives up"
177 })))]
178 pub max_restarts: u32,
179 #[cfg_attr(feature = "schema", schemars(extend("init" = {
181 "example": "3s",
182 "group": "restart",
183 "blurb": "A fixed wait before every restart, instead of growing backoff"
184 })))]
185 pub restart_delay: Option<UpDuration>,
186 #[cfg_attr(feature = "schema", schemars(extend("init" = {
202 "example": "5s",
203 "group": "restart",
204 "blurb": "Starting delay between restarts, growing each time it fails again"
205 })))]
206 pub exp_backoff_restart_delay: Option<UpDuration>,
207 #[cfg_attr(feature = "schema", schemars(extend("init" = {
215 "example": "SIGTERM",
216 "group": "shutdown",
217 "blurb": "Which signal shep sends first when stopping this app",
218 "suggest": ["SIGTERM", "SIGINT", "SIGQUIT", "SIGUSR2"],
219 "accepts": ["SIGTERM, SIGINT, SIGQUIT, or SIGUSR2",
220 "the SIG prefix and case are both optional"],
221 "refuses": ["a signal outside that list"]
222 })))]
223 pub kill_signal: Option<String>,
224 #[cfg_attr(feature = "schema", schemars(extend("init" = {
226 "group": "shutdown",
227 "blurb": "How long shep waits after the stop signal before SIGKILL"
228 })))]
229 pub kill_timeout: UpDuration,
230 #[cfg_attr(feature = "schema", schemars(extend("init" = {
232 "group": "shutdown",
233 "blurb": "Ask the app to stop over the channel instead of signalling it"
234 })))]
235 pub shutdown_with_message: bool,
236 #[cfg_attr(feature = "schema", schemars(extend("init" = {
238 "group": "readiness",
239 "blurb": "How long to wait for readiness when nothing else reports it"
240 })))]
241 pub listen_timeout: UpDuration,
242 #[cfg_attr(feature = "schema", schemars(extend("init" = {
244 "group": "shutdown",
245 "blurb": "How long the old instance gets to drain during a reload"
246 })))]
247 pub graceful_timeout: UpDuration,
248 #[cfg_attr(feature = "schema", schemars(extend("init" = {
266 "group": "shutdown",
267 "blurb": "How long a triggered action has to answer before shep gives up"
268 })))]
269 pub action_timeout: UpDuration,
270 #[cfg_attr(feature = "schema", schemars(extend("init" = {
272 "example": "512M",
273 "group": "restart",
274 "blurb": "Restart the app if it climbs above this much memory"
275 })))]
276 pub max_memory: Option<MemSize>,
277 #[cfg_attr(feature = "schema", schemars(extend("init" = {
279 "group": "watch",
280 "blurb": "Restart when a file changes"
281 })))]
282 pub watch: bool,
283 #[cfg_attr(feature = "schema", schemars(extend("init" = {
285 "group": "watch",
286 "blurb": "Paths watch should skip, on top of dotfiles and node_modules",
287 "accepts": ["a list of glob patterns"],
288 "refuses": ["a pattern globset cannot compile"],
289 "neighbours": [{"field": "cwd", "note": "globs are rooted here"},
290 {"field": "watch_options", "note": "removed from what this matches"}]
291 })))]
292 pub ignore_watch: Vec<String>,
293 #[cfg_attr(feature = "schema", schemars(extend("init" = {
295 "example": "500",
296 "group": "watch",
297 "blurb": "How long to wait after a change before restarting"
298 })))]
299 pub watch_delay: Option<UpDuration>,
300 #[cfg_attr(feature = "schema", schemars(extend("init" = {
302 "example": "* * * * *",
303 "group": "cron",
304 "blurb": "Restart on a schedule, written as a cron pattern",
305 "suggest": ["*/5 * * * *", "0 * * * *", "0 0 * * *", "0 0 * * 0"],
306 "accepts": ["five fields: minute hour day month weekday",
307 "a nickname like @daily or @hourly"],
308 "refuses": ["a field outside its valid range",
309 "a sixth seconds field, or L, W, # or ?",
310 "a pattern croner cannot parse"],
311 "neighbours": [{"field": "cron_timezone", "note": "sets which zone this pattern reads in"}]
312 })))]
313 pub cron_restart: Option<String>,
314 #[cfg_attr(feature = "schema", schemars(extend("init" = {
316 "example": "backend",
317 "group": "process",
318 "blurb": "A fold to group this app with others, for commands that take one"
319 })))]
320 pub fold: Option<String>,
321 #[cfg_attr(feature = "schema", schemars(extend("init" = {
331 "example": "[\"db\", \"cache\"]",
332 "group": "process",
333 "blurb": "Other sheep or dogs that must be up before this one starts",
334 "accepts": ["a list of sheep or dog names"],
335 "refuses": ["this sheep's own name", "a name:slot instance reference"]
336 })))]
337 pub depends_on: Vec<String>,
338 #[cfg_attr(feature = "schema", schemars(extend("init" = {
340 "example": "www-data",
341 "group": "process",
342 "blurb": "Run as this user, on unix",
343 "accepts": ["a unix user name"],
344 "refuses": ["a name with no passwd entry",
345 "another user, unless the shepherd runs as root"],
346 "neighbours": [{"field": "group", "note": "resolved together at spawn"}]
347 })))]
348 pub user: Option<String>,
349 #[cfg_attr(feature = "schema", schemars(extend("init" = {
351 "example": "www-data",
352 "group": "process",
353 "blurb": "Run as this group, on unix",
354 "accepts": ["a unix group name"],
355 "refuses": ["a name with no group entry",
356 "another group, unless the shepherd runs as root"],
357 "neighbours": [{"field": "user", "note": "resolved together at spawn"}]
358 })))]
359 pub group: Option<String>,
360 #[cfg_attr(feature = "schema", schemars(extend("init" = {
362 "example": "{{SHEP_HOME}}/logs/{{name}}-{{instance}}-out.log",
363 "group": "logging",
364 "blurb": "Where stdout goes. Written out, the default is {{SHEP_HOME}}/logs/{{name}}-{{instance}}-out.log",
365 "accepts": ["a path, relative paths follow cwd",
366 "{{instance}}, {{name}} and {{SHEP_HOME}} expand",
367 "a parent that does not exist yet: warned"],
368 "refuses": ["a {{secret:...}} token",
369 "one path for every instance, without merge_logs"],
370 "neighbours": [{"field": "err_file", "note": "shares the same collision rule"},
371 {"field": "merge_logs", "note": "shares one file, unless this path spells {{instance}} out"}]
372 })))]
373 pub out_file: Option<String>,
374 #[cfg_attr(feature = "schema", schemars(extend("init" = {
376 "example": "{{SHEP_HOME}}/logs/{{name}}-{{instance}}-err.log",
377 "group": "logging",
378 "blurb": "Where stderr goes. Written out, the default is {{SHEP_HOME}}/logs/{{name}}-{{instance}}-err.log",
379 "accepts": ["a path, relative paths follow cwd",
380 "{{instance}}, {{name}} and {{SHEP_HOME}} expand",
381 "a parent that does not exist yet: warned"],
382 "refuses": ["a {{secret:...}} token",
383 "one path for every instance, without merge_logs"],
384 "neighbours": [{"field": "out_file", "note": "shares the same collision rule"},
385 {"field": "merge_logs", "note": "shares one file, unless this path spells {{instance}} out"}]
386 })))]
387 pub err_file: Option<String>,
388 #[cfg_attr(feature = "schema", schemars(extend("init" = {
390 "group": "logging",
391 "blurb": "Put every instance's output in one pair of files"
392 })))]
393 pub merge_logs: bool,
394 #[cfg_attr(feature = "schema", schemars(extend("init" = {
403 "example": "[{ pattern = '\\[ERROR\\]', level = 'error' }]",
404 "group": "logging",
405 "blurb": "What this app's own log levels look like, for filtering",
406 "accepts": ["a list of { pattern, level } rules, tried in order",
407 "a regex matched against the whole line, (?i) folds case",
408 "a level of trace, debug, info, warn, or error"],
409 "refuses": ["an empty pattern, which would claim every line",
410 "a pattern regex cannot compile"],
411 "neighbours": [{"field": "out_file", "note": "the lines these rules read"}]
412 })))]
413 pub level_rules: Vec<LevelRule>,
414 #[cfg_attr(feature = "schema", schemars(extend("init" = {
421 "group": "inputs",
422 "blurb": "Opens fd 3 so the app can talk to shep directly"
423 })))]
424 pub channel: bool,
425 #[cfg_attr(feature = "schema", schemars(extend("init" = {
451 "group": "inputs",
452 "blurb": "Keeps stdin open so shep whisper can write to the process"
453 })))]
454 pub stdin: bool,
455 #[cfg_attr(feature = "schema", schemars(extend("init" = {
457 "group": "readiness",
458 "blurb": "Wait for the app to say it is ready on the channel"
459 })))]
460 pub wait_ready: bool,
461 #[cfg_attr(feature = "schema", schemars(extend("init" = {
497 "group": "process",
498 "blurb": "The app sets SO_REUSEPORT itself, so reload may overlap the two instances"
499 })))]
500 pub reuse_port: bool,
501 #[cfg_attr(feature = "schema", schemars(extend("init" = {
503 "example": { "kind": "http", "target": "http://127.0.0.1:8080/ready" },
504 "group": "readiness",
505 "blurb": "A health check shep waits on before it treats a reload as finished",
506 "accepts": ["kind: http, tcp, or exec",
507 "a target matching the kind: a url, host:port, or command"],
508 "refuses": ["a failure_threshold of 0", "an interval below its own floor"],
509 "neighbours": [{"field": "liveness_probe", "note": "uses a different interval floor"},
510 {"field": "reuse_port", "note": "governs whether reload overlaps it"}]
511 })))]
512 pub readiness_probe: Option<ProbeConfig>,
513 #[cfg_attr(feature = "schema", schemars(extend("init" = {
515 "example": { "kind": "http", "target": "http://127.0.0.1:8080/healthz" },
516 "group": "readiness",
517 "blurb": "A health check that triggers a restart when it keeps failing",
518 "accepts": ["kind: http, tcp, or exec",
519 "a target matching the kind: a url, host:port, or command"],
520 "refuses": ["a failure_threshold of 0", "an interval below its own floor"],
521 "neighbours": [{"field": "readiness_probe", "note": "uses a different interval floor"}]
522 })))]
523 pub liveness_probe: Option<ProbeConfig>,
524 #[cfg_attr(feature = "schema", schemars(extend("init" = {
526 "group": "watch",
527 "blurb": "Which paths to watch. Empty means the working directory",
528 "accepts": ["a list of glob patterns", "empty watches the whole cwd"],
529 "refuses": ["a pattern globset cannot compile"],
530 "neighbours": [{"field": "cwd", "note": "globs are rooted here"},
531 {"field": "watch", "note": "has no effect unless watch is on"},
532 {"field": "ignore_watch", "note": "skipped even when matched here"}]
533 })))]
534 pub watch_options: Vec<String>,
535 #[cfg_attr(feature = "schema", schemars(extend("init" = {
537 "example": "US/Eastern",
538 "group": "cron",
539 "blurb": "Which timezone cron_restart is read in, as an IANA name",
540 "accepts": ["an IANA zone name, like US/Eastern"],
541 "refuses": ["a name outside the IANA database"],
542 "neighbours": [{"field": "cron_restart", "note": "the pattern this zone is read against"}]
543 })))]
544 pub cron_timezone: Option<String>,
545}
546
547#[cfg(test)]
548mod tests {
549 use super::super::probe::ProbeKind;
550
551 use super::*;
554 use crate::values::{MemSize, UpDuration};
555
556 use super::super::testing::*;
557
558 #[test]
559 fn minimal_config_gets_spec_defaults() {
560 let app = AppConfig::minimal("web", "./server");
561 assert_eq!(app.name, "web");
562 assert_eq!(app.script, "./server");
563 assert!(app.autorestart);
564 assert!(app.autostart);
565 assert_eq!(app.instances, 1);
566 assert_eq!(app.min_uptime, UpDuration::from_millis(1000));
567 assert_eq!(app.max_restarts, 16);
568 assert_eq!(app.kill_timeout, UpDuration::from_millis(1600));
569 assert_eq!(app.listen_timeout, UpDuration::from_millis(3000));
570 assert_eq!(app.graceful_timeout, UpDuration::from_millis(8000));
571 assert_eq!(app.action_timeout, UpDuration::from_millis(3000));
572 assert!(app.max_memory.is_none());
573 assert!(app.fold.is_none());
574 assert!(!app.channel);
575 }
576
577 #[test]
578 fn unstable_restarts_are_throttled_by_default() {
579 let app = AppConfig::minimal("web", "./srv");
580 assert_eq!(
581 app.exp_backoff_restart_delay,
582 Some(UpDuration::from_millis(100))
583 );
584 }
585
586 #[test]
587 fn stdin_is_not_piped_unless_the_app_asks() {
588 let app = AppConfig::minimal("web", "./srv");
589 assert!(!app.stdin);
590 let parsed: AppConfig = toml::from_str("name = \"web\"\nscript = \"./srv\"").unwrap();
591 assert!(!parsed.stdin);
592 }
593
594 #[test]
595 fn the_flockfile_key_is_stdin() {
596 let parsed: AppConfig =
597 toml::from_str("name = \"web\"\nscript = \"./srv\"\nstdin = true").unwrap();
598 assert!(parsed.stdin);
599 }
600
601 #[test]
602 fn environment_defaults_to_absent_and_parses_from_a_flockfile() {
603 assert_eq!(AppConfig::default().environment, None);
604 let app: AppConfig =
605 toml::from_str("name = \"web\"\nscript = \"./srv\"\nenvironment = \"staging\"")
606 .unwrap();
607 assert_eq!(app.environment.as_deref(), Some("staging"));
608 }
609
610 #[test]
611 fn toml_round_trip_with_newtypes() {
612 let toml_src = r#"
613 name = "worker"
614 script = "python3"
615 args = ["job.py", "--fast"]
616 max_memory = "512M"
617 min_uptime = "5s"
618 fold = "backend"
619 env = { RUST_LOG = "info" }
620 "#;
621 let app: AppConfig = toml::from_str(toml_src).unwrap();
622 assert_eq!(app.max_memory, Some("512M".parse::<MemSize>().unwrap()));
623 assert_eq!(app.min_uptime, UpDuration::from_millis(5000));
624 assert_eq!(app.fold.as_deref(), Some("backend"));
625 assert_eq!(app.env.get("RUST_LOG").map(String::as_str), Some("info"));
626 assert_eq!(app.args, vec!["job.py", "--fast"]);
627 }
628
629 #[test]
635 fn env_coerces_raw_scalars_to_their_string_form() {
636 let src = r#"
637 name = "web"
638 script = "./srv"
639 env = { SOME_BOOL = true, PORT = 8080, NEG = -1, STR = "plain" }
640 "#;
641 let app: AppConfig = toml::from_str(src).unwrap();
642 assert_eq!(app.env["SOME_BOOL"], "true");
643 assert_eq!(app.env["PORT"], "8080");
644 assert_eq!(app.env["NEG"], "-1");
645 assert_eq!(app.env["STR"], "plain");
646 }
647
648 #[test]
658 fn a_protocol_8_payload_carrying_increment_var_still_deserializes() {
659 let src = r#"{ "name":"web","script":"./srv","increment_var":null }"#;
660 let app = serde_json::from_str::<AppConfig>(src)
661 .expect("a version-8 payload must not be refused for a field this build dropped");
662 assert_eq!(app.name, "web");
663 assert_eq!(app.script, "./srv");
664
665 let populated = r#"{ "name":"web","script":"./srv","increment_var":"WORKER_ID" }"#;
666 let app = serde_json::from_str::<AppConfig>(populated)
667 .expect("a populated one is ignored too, not refused");
668 assert_eq!(app.name, "web");
669 }
670
671 #[test]
678 fn env_refuses_a_float_because_its_written_form_would_not_survive() {
679 let src = r#"{ "name":"web","script":"./srv","env":{ "RATIO": 1.10 } }"#;
680 let err = serde_json::from_str::<AppConfig>(src)
681 .expect_err("a float env value must be refused, not rounded")
682 .to_string();
683 assert!(
684 err.contains("quote it"),
685 "the error must name the fix, got: {err}"
686 );
687 assert!(!err.contains("1.1"), "the error leaked the value: {err}");
688
689 let quoted = src.replace("1.10", r#""1.10""#);
690 let app: AppConfig = serde_json::from_str("ed).unwrap();
691 assert_eq!(app.env["RATIO"], "1.10");
692
693 assert!(
694 toml::from_str::<AppConfig>(
695 "name = \"web\"\nscript = \"./srv\"\nenv = { RATIO = 1.10 }\n"
696 )
697 .is_err(),
698 "TOML must refuse a float too"
699 );
700 }
701
702 #[test]
707 fn env_reads_a_number_beyond_i64_max_without_wrapping() {
708 let beyond_i64 = u64::MAX; let src = format!(r#"{{ "name":"web","script":"./srv","env":{{ "BIG": {beyond_i64} }} }}"#);
710 let app = serde_json::from_str::<AppConfig>(&src)
711 .expect("a u64 beyond i64::MAX is valid JSON and must load");
712 assert_eq!(app.env["BIG"], "18446744073709551615");
713 }
714
715 #[test]
720 fn env_serialization_is_always_string_regardless_of_input_form() {
721 let src = r#"
722 name = "web"
723 script = "./srv"
724 env = { SOME_BOOL = true, PORT = 8080, STR = "hello" }
725 "#;
726 let app: AppConfig = toml::from_str(src).unwrap();
727 let wire = serde_json::to_value(&app).unwrap();
728 for (key, expected) in [("SOME_BOOL", "true"), ("PORT", "8080"), ("STR", "hello")] {
729 assert_eq!(
730 wire["env"].get(key).and_then(serde_json::Value::as_str),
731 Some(expected),
732 "wire form of {key} must be a string, not a scalar"
733 );
734 }
735 }
736
737 #[test]
742 fn env_refuses_structural_values() {
743 for (label, inner) in [("array", r#"["a", "b"]"#), ("object", r#"{"k": "v"}"#)] {
744 let src = format!(r#"{{ "name":"web","script":"./srv","env":{{"X":{inner}}}}}"#);
745 assert!(
746 serde_json::from_str::<AppConfig>(&src).is_err(),
747 "a {label} env value must be refused"
748 );
749 }
750 }
751
752 #[test]
759 fn an_unknown_field_on_the_wire_is_ignored_rather_than_refused() {
760 let config: AppConfig =
761 serde_json::from_str(r#"{"name":"web","script":"./srv","invented_next_year":true}"#)
762 .expect("the wire path tolerates what it does not know");
763 assert_eq!(config.name, "web");
764 }
765
766 #[test]
767 fn probe_config_parses_with_defaults() {
768 let src = r#"
769 name = "api"
770 script = "./api"
771
772 [readiness_probe]
773 kind = "http"
774 target = "http://127.0.0.1:8080/healthz"
775 "#;
776 let app: AppConfig = toml::from_str(src).unwrap();
777 let probe = app.readiness_probe.unwrap();
778 assert_eq!(probe.kind, ProbeKind::Http);
779 assert_eq!(probe.target, "http://127.0.0.1:8080/healthz");
780 assert_eq!(probe.interval, UpDuration::from_millis(10_000));
781 assert_eq!(probe.timeout, UpDuration::from_millis(5_000));
782 assert_eq!(probe.failure_threshold, 3);
783 assert!(app.liveness_probe.is_none());
784 }
785
786 #[test]
787 fn debug_redacts_env_values() {
788 let mut app = AppConfig::minimal("web", "./srv");
790 app.env
791 .insert("DATABASE_URL".to_string(), "postgres://secret".to_string());
792 app.env.insert("RUST_LOG".to_string(), "info".to_string());
793 assert_eq!(
794 format!("{app:?}"),
795 "AppConfig { name: \"web\", script: \"./srv\", env: <2 vars>, .. }"
796 );
797 }
798
799 #[test]
802 fn the_path_fields_state_what_they_accept() {
803 let schema = crate::config::flockfile_schema_json().to_value();
804 let props = schema
805 .pointer("/$defs/AppConfig/properties")
806 .and_then(serde_json::Value::as_object)
807 .expect("app config properties must exist");
808 for name in ["cwd", "script", "out_file", "err_file"] {
809 let accepts = props[name]["init"]["accepts"].as_array();
810 assert!(
811 accepts.is_some_and(|forms| !forms.is_empty()),
812 "{name} carries no accepted forms"
813 );
814 }
815 }
816
817 #[test]
819 fn every_neighbour_entry_carries_a_field_and_a_note() {
820 let schema = crate::config::flockfile_schema_json().to_value();
821 let props = schema
822 .pointer("/$defs/AppConfig/properties")
823 .and_then(serde_json::Value::as_object)
824 .expect("app config properties must exist");
825 for (name, prop) in props {
826 let Some(entries) = prop["init"]["neighbours"].as_array() else {
827 continue;
828 };
829 for entry in entries {
830 assert!(
831 entry["field"].is_string(),
832 "{name} has a neighbour with no field"
833 );
834 assert!(
835 entry["note"].is_string(),
836 "{name} has a neighbour with no note"
837 );
838 }
839 }
840 }
841
842 #[test]
847 fn every_per_field_refusal_is_one_normalize_really_makes() {
848 for claim in refusal_claims() {
849 let Proof::Refused { value, matches } = claim.proof else {
850 continue;
851 };
852 let field = claim.field;
853 let refusal = claim.refusal;
854 match crate::config::normalize(*value) {
855 Ok(_) => panic!("{field} says it refuses {refusal}, and normalize accepted it"),
856 Err(err) => assert!(
857 matches(&err),
858 "{field}'s \"{refusal}\" was refused as {err:?}, which is not the variant \
859 the table names"
860 ),
861 }
862 }
863 }
864
865 #[test]
869 fn every_suggested_value_is_one_normalize_accepts() {
870 let schema = crate::config::flockfile_schema_json().to_value();
871 let props = schema
872 .pointer("/$defs/AppConfig/properties")
873 .and_then(serde_json::Value::as_object)
874 .expect("app config properties must exist");
875 for (field, prop) in props {
876 let Some(values) = prop["init"]["suggest"].as_array() else {
877 continue;
878 };
879 for value in values {
880 let value = value.as_str().expect("a suggestion is a string");
881 let app = sheep(|a| match field.as_str() {
882 "cron_restart" => a.cron_restart = Some(value.to_owned()),
883 "kill_signal" => a.kill_signal = Some(value.to_owned()),
884 other => panic!("{other} suggests values that this test cannot place"),
885 });
886 assert!(
887 crate::config::normalize(app).is_ok(),
888 "{field} suggests `{value}`, which normalize refuses"
889 );
890 }
891 }
892 }
893
894 #[test]
898 fn the_refusal_table_and_the_schema_name_the_same_clauses() {
899 let schema = crate::config::flockfile_schema_json().to_value();
900 let props = schema
901 .pointer("/$defs/AppConfig/properties")
902 .and_then(serde_json::Value::as_object)
903 .expect("app config properties must exist");
904 let in_schema: Vec<(&str, &str)> = props
905 .iter()
906 .filter_map(|(field, prop)| Some((field.as_str(), prop["init"]["refuses"].as_array()?)))
907 .flat_map(|(field, clauses)| {
908 clauses.iter().map(move |clause| {
909 (
910 field,
911 clause.as_str().expect("a refusal clause is a string"),
912 )
913 })
914 })
915 .collect();
916
917 let claims = refusal_claims();
918 let proved: Vec<(&str, &str)> = claims.iter().map(|c| (c.field, c.refusal)).collect();
919
920 let unproved: Vec<_> = in_schema
921 .iter()
922 .filter(|clause| !proved.contains(clause))
923 .collect();
924 let stale: Vec<_> = proved
925 .iter()
926 .filter(|clause| !in_schema.contains(clause))
927 .collect();
928 assert!(
929 unproved.is_empty() && stale.is_empty(),
930 "a refusal and its proof live together, in refusal_claims in this file.\n \
931 claimed by a field, proved nowhere: {unproved:?}\n \
932 proved here, claimed by no field: {stale:?}"
933 );
934 }
935}