Skip to main content

isoloom_spec/syntax/
process.rs

1//! What a machine runs and when it is ready: its `command` (with the directory it runs in), its
2//! `healthcheck` (a port by name or number), and its `environment` values.
3
4use std::borrow::Cow;
5use std::fmt;
6use std::num::NonZeroU16;
7
8use schemars::{JsonSchema, Schema, SchemaGenerator};
9use serde::de::{self, MapAccess, SeqAccess, Visitor};
10use serde::{Deserialize, Deserializer};
11
12use super::names::{InputName, PortName};
13use crate::Error;
14use crate::model::CommandLine;
15
16/// A machine's own command: a text the shell runs, or a list run as it is, and the directory
17/// it runs in.
18#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
19#[serde(from = "CommandInput")]
20pub struct Command {
21    /// What runs.
22    pub run: CommandLine,
23    /// The directory it runs in (absolute), when not the image's `WORKDIR` (or the project
24    /// folder on a VM).
25    pub cwd: Option<String>,
26}
27
28/// A command written with its directory.
29#[derive(Deserialize, JsonSchema)]
30#[serde(deny_unknown_fields)]
31struct CommandFull {
32    /// A text run by the shell, or a list of arguments run as they are.
33    run: CommandLine,
34    /// The directory the command, the healthcheck and the `setup` scripts run in (absolute).
35    #[serde(default)]
36    cwd: Option<String>,
37}
38
39// What the schema shows and the reader returns.
40/// A command as written, for the schema.
41#[derive(JsonSchema)]
42#[serde(untagged)]
43enum CommandInput {
44    /// A text run by the machine's shell (`/bin/sh -c`; PowerShell on Windows).
45    Shell(String),
46    /// Arguments run as they are, the program first.
47    Exec(Vec<String>),
48    /// The command and the directory it runs in.
49    Full(CommandFull),
50}
51
52impl<'de> Deserialize<'de> for CommandInput {
53    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<CommandInput, D::Error> {
54        struct V;
55        impl<'de> Visitor<'de> for V {
56            type Value = CommandInput;
57            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
58                f.write_str("a text, a list of arguments, or `{ run, cwd }`")
59            }
60            fn visit_str<E: de::Error>(self, v: &str) -> Result<CommandInput, E> {
61                Ok(CommandInput::Shell(v.to_owned()))
62            }
63            fn visit_seq<A: SeqAccess<'de>>(self, seq: A) -> Result<CommandInput, A::Error> {
64                Vec::<String>::deserialize(de::value::SeqAccessDeserializer::new(seq)).map(CommandInput::Exec)
65            }
66            fn visit_map<A: MapAccess<'de>>(self, map: A) -> Result<CommandInput, A::Error> {
67                CommandFull::deserialize(de::value::MapAccessDeserializer::new(map)).map(CommandInput::Full)
68            }
69        }
70        d.deserialize_any(V)
71    }
72}
73
74impl From<CommandInput> for Command {
75    fn from(written: CommandInput) -> Command {
76        match written {
77            CommandInput::Shell(text) => Command {
78                run: CommandLine::Shell(text),
79                cwd: None,
80            },
81            CommandInput::Exec(args) => Command {
82                run: CommandLine::Exec(args),
83                cwd: None,
84            },
85            CommandInput::Full(c) => Command { run: c.run, cwd: c.cwd },
86        }
87    }
88}
89
90impl JsonSchema for Command {
91    fn schema_name() -> Cow<'static, str> {
92        "Command".into()
93    }
94
95    fn json_schema(g: &mut SchemaGenerator) -> Schema {
96        CommandInput::json_schema(g)
97    }
98}
99
100/// A port a healthcheck names: one of the machine's ports by name, or a port number.
101#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)]
102#[serde(untagged)]
103pub enum PortRef {
104    /// A port number.
105    Number(NonZeroU16),
106    /// One of the machine's ports, by name.
107    Name(PortName),
108}
109
110/// A page a healthcheck requests on the machine itself.
111#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)]
112#[serde(deny_unknown_fields)]
113pub struct HealthPage {
114    /// The port it is served on: a declared port's name or a number.
115    pub port: PortRef,
116    /// The page's path. Default: `/`.
117    #[serde(default = "root")]
118    pub path: String,
119}
120
121fn root() -> String {
122    "/".into()
123}
124
125/// What a healthcheck tries: a command, or a page.
126#[derive(Debug, Clone, PartialEq, Eq)]
127pub enum HealthProbe {
128    /// A command run inside the machine: healthy when it exits with 0.
129    Test(CommandLine),
130    /// A page the machine serves: healthy when it answers with a 2xx or 3xx status.
131    Http(HealthPage),
132}
133
134/// When a machine counts as healthy, besides its ports answering: a command that succeeds, or
135/// a page that answers, tried at its own pace.
136#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
137#[serde(try_from = "HealthcheckInput")]
138pub struct Healthcheck {
139    /// What it tries.
140    pub probe: HealthProbe,
141    /// The time between two tries (`5s`, `1m30s`). Default: 5s.
142    pub interval: Option<String>,
143    /// How long one try may take. Default: 3s.
144    pub timeout: Option<String>,
145    /// Failed tries in a row before the machine counts as unhealthy. Default: 60.
146    pub retries: Option<u32>,
147    /// A grace period after start before the first try counts (`30s`). Default: 10s.
148    pub start_period: Option<String>,
149}
150
151// A healthcheck as written, what the schema shows: exactly one of `test` and `http`.
152/// When a machine counts as healthy, besides its ports answering: a command that succeeds, or
153/// a page that answers.
154#[derive(Deserialize, JsonSchema)]
155#[serde(deny_unknown_fields)]
156struct HealthcheckInput {
157    /// A command run inside the machine: healthy when it exits with 0 (exactly one of `test`
158    /// and `http`).
159    #[serde(default)]
160    test: Option<CommandLine>,
161    /// A page the machine serves: healthy when it answers with a 2xx or 3xx status.
162    #[serde(default)]
163    http: Option<HealthPage>,
164    /// The time between two tries (`5s`, `1m30s`). Default: 5s.
165    #[serde(default)]
166    interval: Option<String>,
167    /// How long one try may take. Default: 3s.
168    #[serde(default)]
169    timeout: Option<String>,
170    /// Failed tries in a row before the machine counts as unhealthy. Default: 60.
171    #[serde(default)]
172    retries: Option<u32>,
173    /// A grace period after start before the first try counts (`30s`). Default: 10s.
174    #[serde(default)]
175    start_period: Option<String>,
176}
177
178impl TryFrom<HealthcheckInput> for Healthcheck {
179    type Error = Error;
180
181    fn try_from(h: HealthcheckInput) -> Result<Healthcheck, Error> {
182        let probe = match (h.test, h.http) {
183            (Some(test), None) => HealthProbe::Test(test),
184            (None, Some(page)) => HealthProbe::Http(page),
185            (Some(_), Some(_)) => return Err(Error::TestAndHttp),
186            (None, None) => return Err(Error::NoHealthProbe),
187        };
188        Ok(Healthcheck {
189            probe,
190            interval: h.interval,
191            timeout: h.timeout,
192            retries: h.retries,
193            start_period: h.start_period,
194        })
195    }
196}
197
198impl JsonSchema for Healthcheck {
199    fn schema_name() -> Cow<'static, str> {
200        "Healthcheck".into()
201    }
202
203    fn json_schema(g: &mut SchemaGenerator) -> Schema {
204        HealthcheckInput::json_schema(g)
205    }
206}
207
208/// An environment variable's value: a fixed text (a number or a boolean read as its text), or a
209/// non-sensitive input's value.
210#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
211#[serde(try_from = "EnvInput")]
212pub enum EnvValue {
213    /// A fixed value.
214    Text(String),
215    /// An input's value, given at each start (only a `sensitive: false` one).
216    Input(InputName),
217    /// A secret: refused (secrets are files), read so the problem can say so.
218    Secret(String),
219}
220
221/// An environment value written as a reference.
222#[derive(Deserialize, JsonSchema)]
223#[serde(deny_unknown_fields)]
224struct EnvReference {
225    /// A non-sensitive input, by name.
226    #[serde(default)]
227    input: Option<InputName>,
228    /// Not allowed: a secret reaches a machine as a file (`files: [{ path, secret }]`).
229    #[serde(default)]
230    secret: Option<String>,
231}
232
233// What the schema shows and the reader returns.
234/// An environment value as written, for the schema.
235#[derive(JsonSchema)]
236#[serde(untagged)]
237enum EnvInput {
238    /// A fixed text.
239    Text(String),
240    /// A number, read as its text.
241    Number(f64),
242    /// A boolean, read as `true` or `false`.
243    Bool(bool),
244    /// `{ input: NAME }`: a non-sensitive input's value.
245    Reference(EnvReference),
246}
247
248impl<'de> Deserialize<'de> for EnvInput {
249    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<EnvInput, D::Error> {
250        struct V;
251        impl<'de> Visitor<'de> for V {
252            type Value = EnvInput;
253            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
254                f.write_str("a text, a number, a boolean, or `{ input: NAME }`")
255            }
256            fn visit_str<E: de::Error>(self, v: &str) -> Result<EnvInput, E> {
257                Ok(EnvInput::Text(v.to_owned()))
258            }
259            fn visit_bool<E: de::Error>(self, v: bool) -> Result<EnvInput, E> {
260                Ok(EnvInput::Bool(v))
261            }
262            // A whole number reads as its digits, exactly (through a float it could round).
263            fn visit_u64<E: de::Error>(self, v: u64) -> Result<EnvInput, E> {
264                Ok(EnvInput::Text(v.to_string()))
265            }
266            fn visit_i64<E: de::Error>(self, v: i64) -> Result<EnvInput, E> {
267                Ok(EnvInput::Text(v.to_string()))
268            }
269            fn visit_f64<E: de::Error>(self, v: f64) -> Result<EnvInput, E> {
270                Ok(EnvInput::Number(v))
271            }
272            fn visit_map<A: MapAccess<'de>>(self, map: A) -> Result<EnvInput, A::Error> {
273                EnvReference::deserialize(de::value::MapAccessDeserializer::new(map)).map(EnvInput::Reference)
274            }
275        }
276        d.deserialize_any(V)
277    }
278}
279
280impl TryFrom<EnvInput> for EnvValue {
281    type Error = Error;
282
283    fn try_from(written: EnvInput) -> Result<EnvValue, Error> {
284        match written {
285            EnvInput::Text(text) => Ok(EnvValue::Text(text)),
286            EnvInput::Number(n) => Ok(EnvValue::Text(n.to_string())),
287            EnvInput::Bool(b) => Ok(EnvValue::Text(b.to_string())),
288            EnvInput::Reference(EnvReference { input: Some(i), secret: None }) => Ok(EnvValue::Input(i)),
289            EnvInput::Reference(EnvReference { input: None, secret: Some(s) }) => Ok(EnvValue::Secret(s)),
290            EnvInput::Reference(_) => Err(Error::EnvReference),
291        }
292    }
293}
294
295impl JsonSchema for EnvValue {
296    fn schema_name() -> Cow<'static, str> {
297        "EnvValue".into()
298    }
299
300    fn json_schema(g: &mut SchemaGenerator) -> Schema {
301        EnvInput::json_schema(g)
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    #[test]
310    fn a_command_or_an_environment_value_of_the_wrong_shape_says_what_is_expected() {
311        let command = serde_yaml_ng::from_str::<Command>("5").unwrap_err().to_string();
312        assert!(command.contains("a text, a list of arguments, or `{ run, cwd }`"), "{command}");
313        let value = serde_yaml_ng::from_str::<EnvValue>("[1]").unwrap_err().to_string();
314        assert!(value.contains("a text, a number, a boolean, or `{ input: NAME }`"), "{value}");
315        let both = serde_yaml_ng::from_str::<EnvValue>("{ input: A, secret: b }").unwrap_err().to_string();
316        assert!(both.contains("write one of `{ input: NAME }`"), "{both}");
317        assert_eq!(serde_yaml_ng::from_str::<EnvValue>("1.5").ok(), Some(EnvValue::Text("1.5".into())));
318        assert_eq!(serde_yaml_ng::from_str::<EnvValue>("-3").ok(), Some(EnvValue::Text("-3".into())));
319    }
320
321    #[test]
322    fn a_command_is_a_text_a_list_or_with_its_directory() {
323        let text: Command = serde_yaml_ng::from_str("python3 app.py").unwrap();
324        assert_eq!(text.run, CommandLine::Shell("python3 app.py".into()));
325        let list: Command = serde_yaml_ng::from_str("[nginx, -g, daemon off;]").unwrap();
326        assert!(matches!(list.run, CommandLine::Exec(ref a) if a.len() == 3));
327        let full: Command = serde_yaml_ng::from_str("{ run: [python3, app.py], cwd: /srv }").unwrap();
328        assert_eq!(full.cwd.as_deref(), Some("/srv"));
329    }
330
331    #[test]
332    fn a_healthcheck_names_its_port() {
333        let by_name: Healthcheck = serde_yaml_ng::from_str("{ http: { port: site, path: /health } }").unwrap();
334        assert!(matches!(by_name.probe, HealthProbe::Http(HealthPage { port: PortRef::Name(ref n), .. }) if n.as_str() == "site"));
335        let by_number: Healthcheck = serde_yaml_ng::from_str("{ http: { port: 8000 } }").unwrap();
336        assert!(matches!(by_number.probe, HealthProbe::Http(HealthPage { port: PortRef::Number(_), .. })));
337    }
338
339    #[test]
340    fn a_healthcheck_tries_one_thing() {
341        let test: Healthcheck = serde_yaml_ng::from_str("{ test: [pg_isready], interval: 2s }").unwrap();
342        assert_eq!(
343            (test.probe, test.interval.as_deref()),
344            (HealthProbe::Test(CommandLine::Exec(vec!["pg_isready".into()])), Some("2s"))
345        );
346        let both = serde_yaml_ng::from_str::<Healthcheck>("{ test: x, http: { port: 80 } }")
347            .unwrap_err()
348            .to_string();
349        assert!(both.contains("`test` and `http` are both given"), "{both}");
350        let neither = serde_yaml_ng::from_str::<Healthcheck>("{ interval: 5s }").unwrap_err().to_string();
351        assert!(neither.contains("a healthcheck says what to try"), "{neither}");
352    }
353
354    #[test]
355    fn environment_values_are_scalars_or_inputs() {
356        let v: indexmap::IndexMap<String, EnvValue> = serde_yaml_ng::from_str("{ A: x, B: 1024, C: true, D: { input: API_URL }, E: { secret: db } }").unwrap();
357        let texts: Vec<_> = v.values().collect();
358        assert_eq!(texts[1], &EnvValue::Text("1024".into()));
359        assert!(matches!(texts[3], EnvValue::Input(_)) && matches!(texts[4], EnvValue::Secret(_)));
360        assert!(serde_yaml_ng::from_str::<EnvValue>("{ file: x }").is_err());
361    }
362}