1use super::dog_table::DogTable;
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")]
106 #[cfg_attr(feature = "schema", schemars(
107 extend(
108 "init" = {
109 "example": "{ NODE_ENV = 'production' }",
110 "group": "inputs",
111 "blurb": "Environment variables for this app, layered over the daemon's own",
112 "accepts": ["a table of KEY = value pairs",
113 "a bare true or 8080, which arrives as text",
114 "{{instance}}, {{name}}, {{SHEP_HOME}} and {{secret:key}} expand in a value"],
115 "refuses": ["a float, since 1.10 would arrive as 1.1",
116 "SHEP_INSTANCE, SHEP_NAME, or SHEP_ENVIRONMENT, which shep sets itself",
117 "an unclosed {{ token",
118 "a token shep does not define"],
119 "neighbours": [{"field": "environment", "note": "which environment {{secret:...}} reads from"}]
120 },
121 "additionalProperties" = {
122 "anyOf": [{ "type": "string" }, { "type": "boolean" }, { "type": "integer" }]
123 }
124 )
125 ))]
126 pub env: BTreeMap<String, String>,
127 #[cfg_attr(feature = "schema", schemars(extend("init" = {
134 "example": "staging",
135 "group": "inputs",
136 "blurb": "Which environment this app resolves secrets in",
137 "accepts": ["a name from the secrets store"],
138 "refuses": ["all, the store's every-environment slot",
139 "a name outside letters, digits, dot, underscore, or dash"],
140 "neighbours": [{"field": "env", "note": "sets which environment its {{secret:...}} reads from"}]
141 })))]
142 pub environment: Option<String>,
143 #[cfg_attr(feature = "schema", schemars(extend("init" = {
145 "group": "process",
146 "blurb": "How many copies of this app to run"
147 })))]
148 pub instances: u32,
149 #[cfg_attr(feature = "schema", schemars(extend("init" = {
151 "group": "restart",
152 "blurb": "Restarts the process automatically when it exits unexpectedly"
153 })))]
154 pub autorestart: bool,
155 #[cfg_attr(feature = "schema", schemars(extend("init" = {
157 "group": "restart",
158 "blurb": "Start this app when the daemon starts, and on shep muster"
159 })))]
160 pub autostart: bool,
161 #[cfg_attr(feature = "schema", schemars(extend("init" = {
163 "group": "restart",
164 "blurb": "Exit codes that mean a clean stop, so shep will not restart",
165 "accepts": ["a list of exit codes"]
166 })))]
167 pub stop_exit_codes: Vec<i32>,
168 #[cfg_attr(feature = "schema", schemars(extend("init" = {
170 "group": "restart",
171 "blurb": "An exit sooner than this counts as unstable"
172 })))]
173 pub min_uptime: UpDuration,
174 #[cfg_attr(feature = "schema", schemars(extend("init" = {
176 "group": "restart",
177 "blurb": "How many unstable exits in a row before shep gives up"
178 })))]
179 pub max_restarts: u32,
180 #[cfg_attr(feature = "schema", schemars(extend("init" = {
182 "example": "3s",
183 "group": "restart",
184 "blurb": "A fixed wait before every restart, instead of growing backoff"
185 })))]
186 pub restart_delay: Option<UpDuration>,
187 #[cfg_attr(feature = "schema", schemars(extend("init" = {
203 "example": "5s",
204 "group": "restart",
205 "blurb": "Starting delay between restarts, growing each time it fails again"
206 })))]
207 pub exp_backoff_restart_delay: Option<UpDuration>,
208 #[cfg_attr(feature = "schema", schemars(extend("init" = {
216 "example": "SIGTERM",
217 "group": "shutdown",
218 "blurb": "Which signal shep sends first when stopping this app",
219 "suggest": ["SIGTERM", "SIGINT", "SIGQUIT", "SIGUSR2"],
220 "accepts": ["SIGTERM, SIGINT, SIGQUIT, or SIGUSR2",
221 "the SIG prefix and case are both optional"],
222 "refuses": ["a signal outside that list"]
223 })))]
224 pub kill_signal: Option<String>,
225 #[cfg_attr(feature = "schema", schemars(extend("init" = {
227 "group": "shutdown",
228 "blurb": "How long shep waits after the stop signal before SIGKILL"
229 })))]
230 pub kill_timeout: UpDuration,
231 #[cfg_attr(feature = "schema", schemars(extend("init" = {
233 "group": "shutdown",
234 "blurb": "Ask the app to stop over the channel instead of signalling it"
235 })))]
236 pub shutdown_with_message: bool,
237 #[cfg_attr(feature = "schema", schemars(extend("init" = {
239 "group": "readiness",
240 "blurb": "How long to wait for readiness when nothing else reports it"
241 })))]
242 pub listen_timeout: UpDuration,
243 #[cfg_attr(feature = "schema", schemars(extend("init" = {
245 "group": "shutdown",
246 "blurb": "How long the old instance gets to drain during a reload"
247 })))]
248 pub graceful_timeout: UpDuration,
249 #[cfg_attr(feature = "schema", schemars(extend("init" = {
267 "group": "shutdown",
268 "blurb": "How long a triggered action has to answer before shep gives up"
269 })))]
270 pub action_timeout: UpDuration,
271 #[cfg_attr(feature = "schema", schemars(extend("init" = {
273 "example": "512M",
274 "group": "restart",
275 "blurb": "Restart the app if it climbs above this much memory"
276 })))]
277 pub max_memory: Option<MemSize>,
278 #[cfg_attr(feature = "schema", schemars(extend("init" = {
280 "group": "watch",
281 "blurb": "Restart when a file changes"
282 })))]
283 pub watch: bool,
284 #[cfg_attr(feature = "schema", schemars(extend("init" = {
286 "group": "watch",
287 "blurb": "Paths watch should skip, on top of dotfiles and node_modules",
288 "accepts": ["a list of glob patterns"],
289 "refuses": ["a pattern globset cannot compile"],
290 "neighbours": [{"field": "cwd", "note": "globs are rooted here"},
291 {"field": "watch_options", "note": "removed from what this matches"}]
292 })))]
293 pub ignore_watch: Vec<String>,
294 #[cfg_attr(feature = "schema", schemars(extend("init" = {
296 "example": "500",
297 "group": "watch",
298 "blurb": "How long to wait after a change before restarting"
299 })))]
300 pub watch_delay: Option<UpDuration>,
301 #[cfg_attr(feature = "schema", schemars(extend("init" = {
303 "example": "* * * * *",
304 "group": "cron",
305 "blurb": "Restart on a schedule, written as a cron pattern",
306 "suggest": ["*/5 * * * *", "0 * * * *", "0 0 * * *", "0 0 * * 0"],
307 "accepts": ["five fields: minute hour day month weekday",
308 "a nickname like @daily or @hourly"],
309 "refuses": ["a field outside its valid range",
310 "a sixth seconds field, or L, W, # or ?",
311 "a pattern croner cannot parse"],
312 "neighbours": [{"field": "cron_timezone", "note": "sets which zone this pattern reads in"}]
313 })))]
314 pub cron_restart: Option<String>,
315 #[cfg_attr(feature = "schema", schemars(extend("init" = {
317 "example": "backend",
318 "group": "process",
319 "blurb": "A fold to group this app with others, for commands that take one"
320 })))]
321 pub fold: Option<String>,
322 #[cfg_attr(feature = "schema", schemars(extend("init" = {
332 "example": "[\"db\", \"cache\"]",
333 "group": "process",
334 "blurb": "Other sheep or dogs that must be up before this one starts",
335 "accepts": ["a list of sheep or dog names"],
336 "refuses": ["this sheep's own name", "a name:slot instance reference"]
337 })))]
338 pub depends_on: Vec<String>,
339 #[cfg_attr(feature = "schema", schemars(extend("init" = {
341 "example": "www-data",
342 "group": "process",
343 "blurb": "Run as this user, on unix",
344 "accepts": ["a unix user name"],
345 "refuses": ["a name with no passwd entry",
346 "another user, unless the shepherd runs as root"],
347 "neighbours": [{"field": "group", "note": "resolved together at spawn"}]
348 })))]
349 pub user: Option<String>,
350 #[cfg_attr(feature = "schema", schemars(extend("init" = {
352 "example": "www-data",
353 "group": "process",
354 "blurb": "Run as this group, on unix",
355 "accepts": ["a unix group name"],
356 "refuses": ["a name with no group entry",
357 "another group, unless the shepherd runs as root"],
358 "neighbours": [{"field": "user", "note": "resolved together at spawn"}]
359 })))]
360 pub group: Option<String>,
361 #[cfg_attr(feature = "schema", schemars(extend("init" = {
363 "example": "{{SHEP_HOME}}/logs/{{name}}-{{instance}}-out.log",
364 "group": "logging",
365 "blurb": "Where stdout goes. Written out, the default is {{SHEP_HOME}}/logs/{{name}}-{{instance}}-out.log",
366 "accepts": ["a path, relative paths follow cwd",
367 "{{instance}}, {{name}} and {{SHEP_HOME}} expand",
368 "a parent that does not exist yet: warned"],
369 "refuses": ["a {{secret:...}} token",
370 "one path for every instance, without merge_logs"],
371 "neighbours": [{"field": "err_file", "note": "shares the same collision rule"},
372 {"field": "merge_logs", "note": "shares one file, unless this path spells {{instance}} out"}]
373 })))]
374 pub out_file: Option<String>,
375 #[cfg_attr(feature = "schema", schemars(extend("init" = {
377 "example": "{{SHEP_HOME}}/logs/{{name}}-{{instance}}-err.log",
378 "group": "logging",
379 "blurb": "Where stderr goes. Written out, the default is {{SHEP_HOME}}/logs/{{name}}-{{instance}}-err.log",
380 "accepts": ["a path, relative paths follow cwd",
381 "{{instance}}, {{name}} and {{SHEP_HOME}} expand",
382 "a parent that does not exist yet: warned"],
383 "refuses": ["a {{secret:...}} token",
384 "one path for every instance, without merge_logs"],
385 "neighbours": [{"field": "out_file", "note": "shares the same collision rule"},
386 {"field": "merge_logs", "note": "shares one file, unless this path spells {{instance}} out"}]
387 })))]
388 pub err_file: Option<String>,
389 #[cfg_attr(feature = "schema", schemars(extend("init" = {
391 "group": "logging",
392 "blurb": "Put every instance's output in one pair of files"
393 })))]
394 pub merge_logs: bool,
395 #[cfg_attr(feature = "schema", schemars(extend("init" = {
401 "group": "logging",
402 "blurb": "Start every log line with the time it was written. On unless set false",
403 "accepts": ["true or false"],
404 "neighbours": [{"field": "out_file", "note": "the file whose lines carry the stamp"},
405 {"field": "err_file", "note": "likewise"}]
406 })))]
407 pub log_timestamps: bool,
408 #[cfg_attr(feature = "schema", schemars(extend("init" = {
417 "example": "[{ pattern = '\\[ERROR\\]', level = 'error' }]",
418 "group": "logging",
419 "blurb": "What this app's own log levels look like, for filtering",
420 "accepts": ["a list of { pattern, level } rules, tried in order",
421 "a regex matched against the whole line, (?i) folds case",
422 "a level of trace, debug, info, warn, or error"],
423 "refuses": ["an empty pattern, which would claim every line",
424 "a pattern regex cannot compile"],
425 "neighbours": [{"field": "out_file", "note": "the lines these rules read"}]
426 })))]
427 pub level_rules: Vec<LevelRule>,
428 #[cfg_attr(feature = "schema", schemars(extend("init" = {
436 "example": { "jobs": { "concurrency": 2 } },
437 "group": "dogs",
438 "blurb": "Settings for one dog on this sheep alone, opaque to shep",
439 "accepts": ["a table of dog name to a table of settings",
440 "a dog name shep does not know about yet"],
441 "refuses": ["a value that is not a table", "an empty dog name"]
442 })))]
443 pub dogs: BTreeMap<String, DogTable>,
444 #[cfg_attr(feature = "schema", schemars(extend("init" = {
451 "group": "inputs",
452 "blurb": "Opens fd 3 so the app can talk to shep directly"
453 })))]
454 pub channel: bool,
455 #[cfg_attr(feature = "schema", schemars(extend("init" = {
481 "group": "inputs",
482 "blurb": "Keeps stdin open so shep whisper can write to the process"
483 })))]
484 pub stdin: bool,
485 #[cfg_attr(feature = "schema", schemars(extend("init" = {
487 "group": "readiness",
488 "blurb": "Wait for the app to say it is ready on the channel"
489 })))]
490 pub wait_ready: bool,
491 #[cfg_attr(feature = "schema", schemars(extend("init" = {
527 "group": "process",
528 "blurb": "The app sets SO_REUSEPORT itself, so reload may overlap the two instances"
529 })))]
530 pub reuse_port: bool,
531 #[cfg_attr(feature = "schema", schemars(extend("init" = {
533 "example": { "kind": "http", "target": "http://127.0.0.1:8080/ready" },
534 "group": "readiness",
535 "blurb": "A health check shep waits on before it treats a reload as finished",
536 "accepts": ["kind: http, tcp, or exec",
537 "a target matching the kind: a url, host:port, or command"],
538 "refuses": ["a failure_threshold of 0", "an interval below its own floor"],
539 "neighbours": [{"field": "liveness_probe", "note": "uses a different interval floor"},
540 {"field": "reuse_port", "note": "governs whether reload overlaps it"}]
541 })))]
542 pub readiness_probe: Option<ProbeConfig>,
543 #[cfg_attr(feature = "schema", schemars(extend("init" = {
545 "example": { "kind": "http", "target": "http://127.0.0.1:8080/healthz" },
546 "group": "readiness",
547 "blurb": "A health check that triggers a restart when it keeps failing",
548 "accepts": ["kind: http, tcp, or exec",
549 "a target matching the kind: a url, host:port, or command"],
550 "refuses": ["a failure_threshold of 0", "an interval below its own floor"],
551 "neighbours": [{"field": "readiness_probe", "note": "uses a different interval floor"}]
552 })))]
553 pub liveness_probe: Option<ProbeConfig>,
554 #[cfg_attr(feature = "schema", schemars(extend("init" = {
556 "group": "watch",
557 "blurb": "Which paths to watch. Empty means the working directory",
558 "accepts": ["a list of glob patterns", "empty watches the whole cwd"],
559 "refuses": ["a pattern globset cannot compile"],
560 "neighbours": [{"field": "cwd", "note": "globs are rooted here"},
561 {"field": "watch", "note": "has no effect unless watch is on"},
562 {"field": "ignore_watch", "note": "skipped even when matched here"}]
563 })))]
564 pub watch_options: Vec<String>,
565 #[cfg_attr(feature = "schema", schemars(extend("init" = {
567 "example": "US/Eastern",
568 "group": "cron",
569 "blurb": "Which timezone cron_restart is read in, as an IANA name",
570 "accepts": ["an IANA zone name, like US/Eastern"],
571 "refuses": ["a name outside the IANA database"],
572 "neighbours": [{"field": "cron_restart", "note": "the pattern this zone is read against"}]
573 })))]
574 pub cron_timezone: Option<String>,
575}
576
577#[cfg(test)]
578mod tests {
579 use super::super::probe::ProbeKind;
580
581 use super::*;
584 use crate::values::{MemSize, UpDuration};
585
586 use super::super::testing::*;
587
588 #[test]
589 fn minimal_config_gets_spec_defaults() {
590 let app = AppConfig::minimal("web", "./server");
591 assert_eq!(app.name, "web");
592 assert_eq!(app.script, "./server");
593 assert!(app.autorestart);
594 assert!(app.autostart);
595 assert_eq!(app.instances, 1);
596 assert_eq!(app.min_uptime, UpDuration::from_millis(1000));
597 assert_eq!(app.max_restarts, 16);
598 assert_eq!(app.kill_timeout, UpDuration::from_millis(1600));
599 assert_eq!(app.listen_timeout, UpDuration::from_millis(3000));
600 assert_eq!(app.graceful_timeout, UpDuration::from_millis(8000));
601 assert_eq!(app.action_timeout, UpDuration::from_millis(3000));
602 assert!(app.max_memory.is_none());
603 assert!(app.fold.is_none());
604 assert!(!app.channel);
605 }
606
607 #[test]
608 fn unstable_restarts_are_throttled_by_default() {
609 let app = AppConfig::minimal("web", "./srv");
610 assert_eq!(
611 app.exp_backoff_restart_delay,
612 Some(UpDuration::from_millis(100))
613 );
614 }
615
616 #[test]
617 fn stdin_is_not_piped_unless_the_app_asks() {
618 let app = AppConfig::minimal("web", "./srv");
619 assert!(!app.stdin);
620 let parsed: AppConfig = toml::from_str("name = \"web\"\nscript = \"./srv\"").unwrap();
621 assert!(!parsed.stdin);
622 }
623
624 #[test]
625 fn the_flockfile_key_is_stdin() {
626 let parsed: AppConfig =
627 toml::from_str("name = \"web\"\nscript = \"./srv\"\nstdin = true").unwrap();
628 assert!(parsed.stdin);
629 }
630
631 #[test]
632 fn environment_defaults_to_absent_and_parses_from_a_flockfile() {
633 assert_eq!(AppConfig::default().environment, None);
634 let app: AppConfig =
635 toml::from_str("name = \"web\"\nscript = \"./srv\"\nenvironment = \"staging\"")
636 .unwrap();
637 assert_eq!(app.environment.as_deref(), Some("staging"));
638 }
639
640 #[test]
641 fn toml_round_trip_with_newtypes() {
642 let toml_src = r#"
643 name = "worker"
644 script = "python3"
645 args = ["job.py", "--fast"]
646 max_memory = "512M"
647 min_uptime = "5s"
648 fold = "backend"
649 env = { RUST_LOG = "info" }
650 "#;
651 let app: AppConfig = toml::from_str(toml_src).unwrap();
652 assert_eq!(app.max_memory, Some("512M".parse::<MemSize>().unwrap()));
653 assert_eq!(app.min_uptime, UpDuration::from_millis(5000));
654 assert_eq!(app.fold.as_deref(), Some("backend"));
655 assert_eq!(app.env.get("RUST_LOG").map(String::as_str), Some("info"));
656 assert_eq!(app.args, vec!["job.py", "--fast"]);
657 }
658
659 #[test]
665 fn env_coerces_raw_scalars_to_their_string_form() {
666 let src = r#"
667 name = "web"
668 script = "./srv"
669 env = { SOME_BOOL = true, PORT = 8080, NEG = -1, STR = "plain" }
670 "#;
671 let app: AppConfig = toml::from_str(src).unwrap();
672 assert_eq!(app.env["SOME_BOOL"], "true");
673 assert_eq!(app.env["PORT"], "8080");
674 assert_eq!(app.env["NEG"], "-1");
675 assert_eq!(app.env["STR"], "plain");
676 }
677
678 #[test]
688 fn a_protocol_8_payload_carrying_increment_var_still_deserializes() {
689 let src = r#"{ "name":"web","script":"./srv","increment_var":null }"#;
690 let app = serde_json::from_str::<AppConfig>(src)
691 .expect("a version-8 payload must not be refused for a field this build dropped");
692 assert_eq!(app.name, "web");
693 assert_eq!(app.script, "./srv");
694
695 let populated = r#"{ "name":"web","script":"./srv","increment_var":"WORKER_ID" }"#;
696 let app = serde_json::from_str::<AppConfig>(populated)
697 .expect("a populated one is ignored too, not refused");
698 assert_eq!(app.name, "web");
699 }
700
701 #[test]
708 fn env_refuses_a_float_because_its_written_form_would_not_survive() {
709 let src = r#"{ "name":"web","script":"./srv","env":{ "RATIO": 1.10 } }"#;
710 let err = serde_json::from_str::<AppConfig>(src)
711 .expect_err("a float env value must be refused, not rounded")
712 .to_string();
713 assert!(
714 err.contains("quote it"),
715 "the error must name the fix, got: {err}"
716 );
717 assert!(!err.contains("1.1"), "the error leaked the value: {err}");
718
719 let quoted = src.replace("1.10", r#""1.10""#);
720 let app: AppConfig = serde_json::from_str("ed).unwrap();
721 assert_eq!(app.env["RATIO"], "1.10");
722
723 assert!(
724 toml::from_str::<AppConfig>(
725 "name = \"web\"\nscript = \"./srv\"\nenv = { RATIO = 1.10 }\n"
726 )
727 .is_err(),
728 "TOML must refuse a float too"
729 );
730 }
731
732 #[test]
737 fn env_reads_a_number_beyond_i64_max_without_wrapping() {
738 let beyond_i64 = u64::MAX; let src = format!(r#"{{ "name":"web","script":"./srv","env":{{ "BIG": {beyond_i64} }} }}"#);
740 let app = serde_json::from_str::<AppConfig>(&src)
741 .expect("a u64 beyond i64::MAX is valid JSON and must load");
742 assert_eq!(app.env["BIG"], "18446744073709551615");
743 }
744
745 #[test]
750 fn env_serialization_is_always_string_regardless_of_input_form() {
751 let src = r#"
752 name = "web"
753 script = "./srv"
754 env = { SOME_BOOL = true, PORT = 8080, STR = "hello" }
755 "#;
756 let app: AppConfig = toml::from_str(src).unwrap();
757 let wire = serde_json::to_value(&app).unwrap();
758 for (key, expected) in [("SOME_BOOL", "true"), ("PORT", "8080"), ("STR", "hello")] {
759 assert_eq!(
760 wire["env"].get(key).and_then(serde_json::Value::as_str),
761 Some(expected),
762 "wire form of {key} must be a string, not a scalar"
763 );
764 }
765 }
766
767 #[test]
772 fn env_refuses_structural_values() {
773 for (label, inner) in [("array", r#"["a", "b"]"#), ("object", r#"{"k": "v"}"#)] {
774 let src = format!(r#"{{ "name":"web","script":"./srv","env":{{"X":{inner}}}}}"#);
775 assert!(
776 serde_json::from_str::<AppConfig>(&src).is_err(),
777 "a {label} env value must be refused"
778 );
779 }
780 }
781
782 #[test]
789 fn an_unknown_field_on_the_wire_is_ignored_rather_than_refused() {
790 let config: AppConfig =
791 serde_json::from_str(r#"{"name":"web","script":"./srv","invented_next_year":true}"#)
792 .expect("the wire path tolerates what it does not know");
793 assert_eq!(config.name, "web");
794 }
795
796 #[test]
797 fn probe_config_parses_with_defaults() {
798 let src = r#"
799 name = "api"
800 script = "./api"
801
802 [readiness_probe]
803 kind = "http"
804 target = "http://127.0.0.1:8080/healthz"
805 "#;
806 let app: AppConfig = toml::from_str(src).unwrap();
807 let probe = app.readiness_probe.unwrap();
808 assert_eq!(probe.kind, ProbeKind::Http);
809 assert_eq!(probe.target, "http://127.0.0.1:8080/healthz");
810 assert_eq!(probe.interval, UpDuration::from_millis(10_000));
811 assert_eq!(probe.timeout, UpDuration::from_millis(5_000));
812 assert_eq!(probe.failure_threshold, 3);
813 assert!(app.liveness_probe.is_none());
814 }
815
816 #[test]
817 fn debug_redacts_env_values() {
818 let mut app = AppConfig::minimal("web", "./srv");
820 app.env
821 .insert("DATABASE_URL".to_string(), "postgres://secret".to_string());
822 app.env.insert("RUST_LOG".to_string(), "info".to_string());
823 assert_eq!(
824 format!("{app:?}"),
825 "AppConfig { name: \"web\", script: \"./srv\", env: <2 vars>, .. }"
826 );
827 }
828
829 #[test]
833 fn debug_never_prints_a_dogs_table_value() {
834 let mut app = AppConfig::minimal("web", "./srv");
835 let mut table = serde_json::Map::new();
836 table.insert(
837 "token".to_string(),
838 serde_json::Value::String("super-secret-token".to_string()),
839 );
840 app.dogs
841 .insert("jobs".to_string(), crate::config::DogTable::from(table));
842 assert!(!format!("{app:?}").contains("super-secret-token"));
843 }
844
845 #[test]
848 fn the_path_fields_state_what_they_accept() {
849 let schema = crate::config::flockfile_schema_json().to_value();
850 let props = schema
851 .pointer("/$defs/AppConfig/properties")
852 .and_then(serde_json::Value::as_object)
853 .expect("app config properties must exist");
854 for name in ["cwd", "script", "out_file", "err_file"] {
855 let accepts = props[name]["init"]["accepts"].as_array();
856 assert!(
857 accepts.is_some_and(|forms| !forms.is_empty()),
858 "{name} carries no accepted forms"
859 );
860 }
861 }
862
863 #[test]
865 fn every_neighbour_entry_carries_a_field_and_a_note() {
866 let schema = crate::config::flockfile_schema_json().to_value();
867 let props = schema
868 .pointer("/$defs/AppConfig/properties")
869 .and_then(serde_json::Value::as_object)
870 .expect("app config properties must exist");
871 for (name, prop) in props {
872 let Some(entries) = prop["init"]["neighbours"].as_array() else {
873 continue;
874 };
875 for entry in entries {
876 assert!(
877 entry["field"].is_string(),
878 "{name} has a neighbour with no field"
879 );
880 assert!(
881 entry["note"].is_string(),
882 "{name} has a neighbour with no note"
883 );
884 }
885 }
886 }
887
888 #[test]
893 fn every_per_field_refusal_is_one_normalize_really_makes() {
894 for claim in refusal_claims() {
895 let Proof::Refused { value, matches } = claim.proof else {
896 continue;
897 };
898 let field = claim.field;
899 let refusal = claim.refusal;
900 match crate::config::normalize(*value) {
901 Ok(_) => panic!("{field} says it refuses {refusal}, and normalize accepted it"),
902 Err(err) => assert!(
903 matches(&err),
904 "{field}'s \"{refusal}\" was refused as {err:?}, which is not the variant \
905 the table names"
906 ),
907 }
908 }
909 }
910
911 #[test]
915 fn every_suggested_value_is_one_normalize_accepts() {
916 let schema = crate::config::flockfile_schema_json().to_value();
917 let props = schema
918 .pointer("/$defs/AppConfig/properties")
919 .and_then(serde_json::Value::as_object)
920 .expect("app config properties must exist");
921 for (field, prop) in props {
922 let Some(values) = prop["init"]["suggest"].as_array() else {
923 continue;
924 };
925 for value in values {
926 let value = value.as_str().expect("a suggestion is a string");
927 let app = sheep(|a| match field.as_str() {
928 "cron_restart" => a.cron_restart = Some(value.to_owned()),
929 "kill_signal" => a.kill_signal = Some(value.to_owned()),
930 other => panic!("{other} suggests values that this test cannot place"),
931 });
932 assert!(
933 crate::config::normalize(app).is_ok(),
934 "{field} suggests `{value}`, which normalize refuses"
935 );
936 }
937 }
938 }
939
940 #[test]
944 fn the_refusal_table_and_the_schema_name_the_same_clauses() {
945 let schema = crate::config::flockfile_schema_json().to_value();
946 let props = schema
947 .pointer("/$defs/AppConfig/properties")
948 .and_then(serde_json::Value::as_object)
949 .expect("app config properties must exist");
950 let in_schema: Vec<(&str, &str)> = props
951 .iter()
952 .filter_map(|(field, prop)| Some((field.as_str(), prop["init"]["refuses"].as_array()?)))
953 .flat_map(|(field, clauses)| {
954 clauses.iter().map(move |clause| {
955 (
956 field,
957 clause.as_str().expect("a refusal clause is a string"),
958 )
959 })
960 })
961 .collect();
962
963 let claims = refusal_claims();
964 let proved: Vec<(&str, &str)> = claims.iter().map(|c| (c.field, c.refusal)).collect();
965
966 let unproved: Vec<_> = in_schema
967 .iter()
968 .filter(|clause| !proved.contains(clause))
969 .collect();
970 let stale: Vec<_> = proved
971 .iter()
972 .filter(|clause| !in_schema.contains(clause))
973 .collect();
974 assert!(
975 unproved.is_empty() && stale.is_empty(),
976 "a refusal and its proof live together, in refusal_claims in this file.\n \
977 claimed by a field, proved nowhere: {unproved:?}\n \
978 proved here, claimed by no field: {stale:?}"
979 );
980 }
981}