use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ApplyGroup {
Live,
NextSpawn,
NeedsRespawn,
Structural,
}
struct FieldRow {
field: &'static str,
group: ApplyGroup,
reaches_running: bool,
}
#[rustfmt::skip]
const FIELDS: &[FieldRow] = &[
FieldRow { field: "autorestart", group: ApplyGroup::Live, reaches_running: true },
FieldRow { field: "max_restarts", group: ApplyGroup::Live, reaches_running: true },
FieldRow { field: "min_uptime", group: ApplyGroup::Live, reaches_running: true },
FieldRow { field: "restart_delay", group: ApplyGroup::Live, reaches_running: true },
FieldRow { field: "exp_backoff_restart_delay", group: ApplyGroup::Live, reaches_running: true },
FieldRow { field: "stop_exit_codes", group: ApplyGroup::Live, reaches_running: true },
FieldRow { field: "kill_timeout", group: ApplyGroup::Live, reaches_running: true },
FieldRow { field: "graceful_timeout", group: ApplyGroup::Live, reaches_running: true },
FieldRow { field: "max_memory", group: ApplyGroup::Live, reaches_running: true },
FieldRow { field: "watch", group: ApplyGroup::Live, reaches_running: true },
FieldRow { field: "ignore_watch", group: ApplyGroup::Live, reaches_running: true },
FieldRow { field: "watch_delay", group: ApplyGroup::Live, reaches_running: true },
FieldRow { field: "watch_options", group: ApplyGroup::Live, reaches_running: true },
FieldRow { field: "cron_restart", group: ApplyGroup::Live, reaches_running: true },
FieldRow { field: "cron_timezone", group: ApplyGroup::Live, reaches_running: true },
FieldRow { field: "liveness_probe", group: ApplyGroup::Live, reaches_running: true },
FieldRow { field: "fold", group: ApplyGroup::Live, reaches_running: true },
FieldRow { field: "reuse_port", group: ApplyGroup::Live, reaches_running: true },
FieldRow { field: "level_rules", group: ApplyGroup::Live, reaches_running: true },
FieldRow { field: "dogs", group: ApplyGroup::Live, reaches_running: true },
FieldRow { field: "action_timeout", group: ApplyGroup::Live, reaches_running: true },
FieldRow { field: "kill_signal", group: ApplyGroup::NextSpawn, reaches_running: false },
FieldRow { field: "listen_timeout", group: ApplyGroup::NextSpawn, reaches_running: false },
FieldRow { field: "readiness_probe", group: ApplyGroup::NextSpawn, reaches_running: false },
FieldRow { field: "autostart", group: ApplyGroup::NextSpawn, reaches_running: true },
FieldRow { field: "depends_on", group: ApplyGroup::NextSpawn, reaches_running: true },
FieldRow { field: "script", group: ApplyGroup::NeedsRespawn, reaches_running: false },
FieldRow { field: "args", group: ApplyGroup::NeedsRespawn, reaches_running: false },
FieldRow { field: "cwd", group: ApplyGroup::NeedsRespawn, reaches_running: false },
FieldRow { field: "interpreter", group: ApplyGroup::NeedsRespawn, reaches_running: false },
FieldRow { field: "env", group: ApplyGroup::NeedsRespawn, reaches_running: false },
FieldRow { field: "environment", group: ApplyGroup::NeedsRespawn, reaches_running: false },
FieldRow { field: "user", group: ApplyGroup::NeedsRespawn, reaches_running: false },
FieldRow { field: "group", group: ApplyGroup::NeedsRespawn, reaches_running: false },
FieldRow { field: "out_file", group: ApplyGroup::NeedsRespawn, reaches_running: false },
FieldRow { field: "err_file", group: ApplyGroup::NeedsRespawn, reaches_running: false },
FieldRow { field: "merge_logs", group: ApplyGroup::NeedsRespawn, reaches_running: false },
FieldRow { field: "log_timestamps", group: ApplyGroup::NeedsRespawn, reaches_running: false },
FieldRow { field: "channel", group: ApplyGroup::NeedsRespawn, reaches_running: false },
FieldRow { field: "stdin", group: ApplyGroup::NeedsRespawn, reaches_running: false },
FieldRow { field: "wait_ready", group: ApplyGroup::NeedsRespawn, reaches_running: false },
FieldRow { field: "shutdown_with_message", group: ApplyGroup::NeedsRespawn, reaches_running: false },
FieldRow { field: "name", group: ApplyGroup::Structural, reaches_running: false },
FieldRow { field: "instances", group: ApplyGroup::Structural, reaches_running: false },
];
#[must_use]
pub fn apply_group(field: &str) -> ApplyGroup {
FIELDS
.iter()
.find(|row| row.field == field)
.map_or(ApplyGroup::NeedsRespawn, |row| row.group)
}
#[must_use]
pub fn reaches_running(field: &str) -> bool {
FIELDS
.iter()
.find(|row| row.field == field)
.is_some_and(|row| row.reaches_running)
}
#[must_use]
pub fn is_classified(field: &str) -> bool {
FIELDS.iter().any(|row| row.field == field)
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ResetDepth {
#[default]
None,
File,
Policy,
Env,
All,
}
#[cfg(test)]
mod tests {
use super::{ApplyGroup, FIELDS, apply_group, is_classified, reaches_running};
use crate::config::AppConfig;
fn appconfig_fields() -> serde_json::Map<String, serde_json::Value> {
let serde_json::Value::Object(fields) = serde_json::to_value(AppConfig::default()).unwrap()
else {
panic!("AppConfig must serialize as an object");
};
fields
}
#[test]
fn every_appconfig_field_has_a_group() {
let fields = appconfig_fields();
let missing: Vec<&String> = fields.keys().filter(|k| !is_classified(k)).collect();
assert!(
missing.is_empty(),
"unclassified AppConfig fields: {missing:?}"
);
}
#[test]
fn every_group_row_names_a_real_field() {
let fields = appconfig_fields();
let strays: Vec<&str> = FIELDS
.iter()
.map(|row| row.field)
.filter(|name| !fields.contains_key(*name))
.collect();
assert!(strays.is_empty(), "FIELDS rows naming no field: {strays:?}");
}
#[test]
fn kill_signal_reaches_the_next_spawn_not_the_next_kill() {
assert_eq!(apply_group("kill_signal"), ApplyGroup::NextSpawn);
assert_eq!(apply_group("kill_timeout"), ApplyGroup::Live);
assert_eq!(apply_group("graceful_timeout"), ApplyGroup::Live);
}
#[test]
fn dogs_is_live_since_the_daemon_never_reads_it() {
assert_eq!(apply_group("dogs"), ApplyGroup::Live);
assert!(reaches_running("dogs"));
}
#[test]
fn shutdown_with_message_is_baked_into_the_child() {
assert_eq!(
apply_group("shutdown_with_message"),
ApplyGroup::NeedsRespawn
);
}
#[test]
fn depends_on_applies_at_the_next_spawn() {
assert_eq!(apply_group("depends_on"), ApplyGroup::NextSpawn);
assert!(is_classified("depends_on"));
}
#[test]
fn the_split_is_21_5_16_2() {
let fields = appconfig_fields();
let count = |want: ApplyGroup| fields.keys().filter(|k| apply_group(k) == want).count();
assert_eq!(count(ApplyGroup::Live), 21, "Live");
assert_eq!(count(ApplyGroup::NextSpawn), 5, "NextSpawn");
assert_eq!(count(ApplyGroup::NeedsRespawn), 16, "NeedsRespawn");
assert_eq!(count(ApplyGroup::Structural), 2, "Structural");
}
#[test]
fn a_live_field_reaches_a_running_child() {
assert!(reaches_running("max_restarts"));
}
#[test]
fn autostart_and_depends_on_reach_one_without_a_respawn() {
assert!(reaches_running("autostart"));
assert!(reaches_running("depends_on"));
}
#[test]
fn the_other_next_spawn_fields_do_not() {
assert!(!reaches_running("kill_signal"));
assert!(!reaches_running("listen_timeout"));
assert!(!reaches_running("readiness_probe"));
}
#[test]
fn a_needs_respawn_field_does_not() {
assert!(!reaches_running("cwd"));
}
#[test]
fn an_unknown_field_does_not_reach_a_running_child() {
assert!(!reaches_running("a_field_from_a_later_shep"));
}
#[test]
fn reaches_running_agrees_with_the_group_table_for_every_field() {
let mut next_spawn_reaching = 0;
for row in FIELDS {
assert_eq!(
reaches_running(row.field),
row.reaches_running,
"{field} is classified {group:?}",
field = row.field,
group = row.group,
);
match row.group {
ApplyGroup::Live => assert!(
row.reaches_running,
"{field} is Live but its row says a respawn applies it",
field = row.field
),
ApplyGroup::NeedsRespawn | ApplyGroup::Structural => assert!(
!row.reaches_running,
"{field} is {group:?} but its row says a running child sees it",
field = row.field,
group = row.group
),
ApplyGroup::NextSpawn => next_spawn_reaching += usize::from(row.reaches_running),
}
}
assert_eq!(
next_spawn_reaching, 2,
"NextSpawn rows claiming a running child sees them"
);
}
}