Skip to main content

isoloom_spec/model/
process.rs

1//! What a machine runs and when it counts as healthy: its own command, the directory it runs
2//! in, the steps run before it at every start, and a healthcheck of its own.
3
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7use crate::shell;
8
9/// A command line: a text the shell runs, or a list of arguments run as they are.
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
11#[serde(untagged)]
12pub enum CommandLine {
13    /// A text run by the machine's shell (`/bin/sh -c`; PowerShell on a Windows VM), with its
14    /// variables, pipes and `&&`.
15    Shell(String),
16    /// Arguments run as they are, the program first (no shell in between).
17    Exec(Vec<String>),
18}
19
20impl CommandLine {
21    /// The command as a program and its arguments: a text goes through `/bin/sh -c`.
22    #[must_use]
23    pub fn argv(&self) -> Vec<String> {
24        match self {
25            CommandLine::Shell(text) => vec!["/bin/sh".into(), "-c".into(), text.clone()],
26            CommandLine::Exec(args) => args.clone(),
27        }
28    }
29
30    /// The command as one line of shell: a text as it is, arguments each quoted.
31    #[must_use]
32    pub fn shell_line(&self) -> String {
33        match self {
34            CommandLine::Shell(text) => text.clone(),
35            CommandLine::Exec(args) => args.iter().map(|a| shell::quote(a)).collect::<Vec<_>>().join(" "),
36        }
37    }
38
39    /// Whether there is nothing to run (an empty text or list), which validation rejects.
40    #[must_use]
41    pub fn is_empty(&self) -> bool {
42        match self {
43            CommandLine::Shell(text) => text.trim().is_empty(),
44            CommandLine::Exec(args) => args.first().is_none_or(|a| a.trim().is_empty()),
45        }
46    }
47}
48
49/// When a machine counts as healthy, besides its ports answering: a command that
50/// succeeds, or a page that answers. Each target waits for it where it waits for the machine
51/// (Compose's `depends_on` and `up --wait`, Kubernetes' readiness, the end of a VM's set-up).
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
53#[serde(deny_unknown_fields)]
54pub struct Healthcheck {
55    /// A command run inside the machine: healthy when it exits with 0 (exactly one of `test`
56    /// and `http`). The image's or the VM's own tools run it (`pg_isready`, `redis-cli ping`).
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub test: Option<CommandLine>,
59    /// A page the machine serves: healthy when it answers with a 2xx or 3xx status.
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub http: Option<HttpCheck>,
62    /// The time between two tries (`5s`, `1m`, `1m30s`). Default: 5s.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub interval: Option<String>,
65    /// How long one try may take before it counts as failed. Default: 3s.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub timeout: Option<String>,
68    /// Failed tries in a row before the machine counts as unhealthy (a VM's set-up then
69    /// fails). Default: 60.
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub retries: Option<u32>,
72    /// A grace period after start before the first try counts (`30s`). Default: 10s.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub start_period: Option<String>,
75}
76
77/// A page a healthcheck requests, on the machine itself.
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
79#[serde(deny_unknown_fields)]
80pub struct HttpCheck {
81    /// The port the page is served on.
82    pub port: u16,
83    /// The page's path. Default: `/`.
84    #[serde(default = "root")]
85    pub path: String,
86}
87
88fn root() -> String {
89    "/".into()
90}
91
92/// Seconds in a duration written `<number><unit>`, one or more times (`90s`, `1m30s`, `2h`):
93/// units `h`, `m` and `s`. `None` when it isn't one.
94#[must_use]
95pub fn seconds(duration: &str) -> Option<u32> {
96    let mut total: u32 = 0;
97    let mut number = String::new();
98    for c in duration.trim().chars() {
99        if c.is_ascii_digit() {
100            number.push(c);
101            continue;
102        }
103        let unit = match c {
104            'h' => 3600,
105            'm' => 60,
106            's' => 1,
107            _ => return None,
108        };
109        let n: u32 = number.parse().ok()?;
110        total = total.checked_add(n.checked_mul(unit)?)?;
111        number.clear();
112    }
113    (number.is_empty() && !duration.trim().is_empty()).then_some(total)
114}
115
116#[cfg(test)]
117mod tests {
118    use super::{CommandLine, seconds};
119
120    #[test]
121    fn durations_read_in_seconds() {
122        assert_eq!(seconds("5s"), Some(5));
123        assert_eq!(seconds("1m30s"), Some(90));
124        assert_eq!(seconds("2h"), Some(7200));
125        assert_eq!(seconds("0s"), Some(0));
126        for bad in ["", "5", "s", "5x", "1.5s", "-1s"] {
127            assert_eq!(seconds(bad), None, "{bad}");
128        }
129    }
130
131    #[test]
132    fn a_command_as_arguments_or_a_line() {
133        let text = CommandLine::Shell("echo $HOME && true".into());
134        assert_eq!(text.argv(), ["/bin/sh", "-c", "echo $HOME && true"]);
135        assert_eq!(text.shell_line(), "echo $HOME && true");
136        let args = CommandLine::Exec(vec!["nginx".into(), "-g".into(), "daemon off;".into()]);
137        assert_eq!(args.argv(), ["nginx", "-g", "daemon off;"]);
138        assert_eq!(args.shell_line(), "'nginx' '-g' 'daemon off;'");
139        assert!(CommandLine::Exec(Vec::new()).is_empty() && CommandLine::Shell(" ".into()).is_empty());
140    }
141}