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