Skip to main content

isoloom_plan/configure/
process.rs

1//! What a container runs (design ยง3): one daemon as its foreground process, several under the
2//! engine's init and Isoloom's runner, the machine's own command, or nothing (idle). The one rule
3//! for a container built from steps: a daemon runs only where a `service` step or `command` says
4//! so, and a container that would answer nothing on its ports is refused, as is a daemon with no
5//! known foreground form.
6
7use isoloom_spec::model::CommandLine;
8use isoloom_spec::syntax::{self, UnitName};
9
10use super::daemons::Daemon;
11use super::recipe::Recipe;
12use super::resources::Resource;
13
14/// The process a container runs.
15#[derive(Debug, Clone, PartialEq, Eq)]
16#[non_exhaustive]
17pub enum ContainerProcess {
18    /// The official image's own entrypoint and command (a catalogue entry's image form).
19    Image,
20    /// A catalogue entry's official image whose `start` steps Isoloom's runner runs at each
21    /// start (`image.start: run`), then `exec`s the image's own entrypoint with its command.
22    Started {
23        /// The image's entrypoint (empty: its command alone).
24        entrypoint: Vec<String>,
25        /// The command it runs (the image's `CMD`, or the entry's).
26        command: Vec<String>,
27    },
28    /// Nothing of its own: the container is kept running idle, a machine to work from.
29    Idle,
30    /// The machine's `command`, as the container's command.
31    Command(CommandLine),
32    /// One daemon: the runner runs `start`, then `exec`s the daemon's foreground form.
33    One(Daemon),
34    /// Several daemons (or one and the command) under the engine's init (tini): the runner runs
35    /// `start`, starts each in order, and exits when one does.
36    Supervised {
37        /// The daemons, in recipe order.
38        daemons: Vec<Daemon>,
39        /// The machine's command, started after them.
40        command: Option<CommandLine>,
41    },
42}
43
44/// Why a container built from a recipe can't run it.
45#[derive(Debug, Clone, PartialEq, Eq)]
46#[non_exhaustive]
47pub enum NoProcess {
48    /// An enabled service with no foreground form: neither its own `run` nor the daemon table's.
49    NoForeground {
50        /// The service, as its step names it.
51        unit: UnitName,
52    },
53    /// The machine has ports and steps, but nothing that serves them runs: the container would
54    /// build and answer nothing.
55    NothingServes,
56}
57
58impl ContainerProcess {
59    /// What machine `m`'s container runs, its recipe resolved as `recipe`.
60    ///
61    /// # Errors
62    ///
63    /// [`NoProcess`]: a service the container can't run, or ports nothing serves.
64    pub fn of(recipe: &Recipe, m: &syntax::Machine) -> Result<ContainerProcess, NoProcess> {
65        ContainerProcess::resolve(recipe, m.command.as_ref().map(|c| &c.run), !m.ports.is_empty())
66    }
67
68    /// What a container runs, its recipe resolved as `recipe`, with `command` (the machine's own)
69    /// and ports (`ports`: whether it answers on any).
70    ///
71    /// # Errors
72    ///
73    /// [`NoProcess`]: a service the container can't run, or ports nothing serves.
74    pub fn resolve(recipe: &Recipe, command: Option<&CommandLine>, ports: bool) -> Result<ContainerProcess, NoProcess> {
75        let mut daemons = Vec::new();
76        for r in recipe.resources() {
77            let Resource::Service(s) = r else { continue };
78            if !s.enabled {
79                continue;
80            }
81            match recipe.daemons.iter().find(|d| d.unit == s.unit) {
82                Some(d) => daemons.push(d.clone()),
83                None => return Err(NoProcess::NoForeground { unit: s.unit.clone() }),
84            }
85        }
86        match (daemons.len(), command.cloned()) {
87            (0, None) if ports && recipe.resources().next().is_some() => Err(NoProcess::NothingServes),
88            (0, None) => Ok(ContainerProcess::Idle),
89            (0, Some(c)) => Ok(ContainerProcess::Command(c)),
90            (1, None) => Ok(daemons.pop().map_or(ContainerProcess::Idle, ContainerProcess::One)),
91            (_, command) => Ok(ContainerProcess::Supervised { daemons, command }),
92        }
93    }
94
95    /// Whether Isoloom's runner is the container's process (one daemon, or several): it needs
96    /// the engine's init (Compose's `init: true`, a pod sharing its process namespace).
97    #[must_use]
98    pub fn is_runner(&self) -> bool {
99        matches!(
100            self,
101            ContainerProcess::One(_) | ContainerProcess::Supervised { .. } | ContainerProcess::Started { .. }
102        )
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::super::{DaemonTable, recipe};
109    use super::*;
110
111    /// What machine `m` (YAML) of a lab of its own runs in a container.
112    fn process(yaml: &str) -> Result<ContainerProcess, NoProcess> {
113        let m: syntax::Machine = serde_yaml_ng::from_str(yaml).unwrap_or_else(|e| panic!("{yaml}: {e}"));
114        let os = m.os.expect("the test's machine has an os");
115        let r = recipe(os, &m, &DaemonTable::common()).expect("a portable recipe");
116        ContainerProcess::of(&r, &m)
117    }
118
119    #[test]
120    fn no_service_and_no_command_is_idle() {
121        assert_eq!(process("{ os: debian@12, app: { steps: [{ packages: [curl] }] } }"), Ok(ContainerProcess::Idle));
122    }
123
124    #[test]
125    fn a_command_alone_is_the_containers_command() {
126        let p = process("{ os: debian@12, ports: { web: 80 }, command: [nginx, -g, 'daemon off;'], app: { steps: [{ packages: [nginx] }] } }");
127        assert!(matches!(p, Ok(ContainerProcess::Command(_))), "{p:?}");
128    }
129
130    #[test]
131    fn one_enabled_service_is_the_foreground_process() {
132        let Ok(ContainerProcess::One(d)) = process("{ os: debian@12, ports: { web: 80 }, app: { steps: [{ packages: [nginx] }, { service: nginx }] } }") else {
133            panic!()
134        };
135        assert_eq!(d.foreground.args(), ["nginx", "-g", "daemon off;"]);
136    }
137
138    #[test]
139    fn two_services_or_one_and_a_command_are_supervised() {
140        let two = process(
141            "{ os: debian@12, app: { steps: [{ service: { name: api, run: [/srv/api] } }, { service: { name: worker, run: [/srv/worker], user: api } }] } }",
142        );
143        let Ok(ContainerProcess::Supervised { daemons, command: None }) = two else {
144            panic!("{two:?}")
145        };
146        assert_eq!(daemons.iter().map(|d| d.unit.as_str()).collect::<Vec<_>>(), ["api", "worker"]);
147        let with_command = process("{ os: debian@12, command: [tail, -f, /dev/null], app: { steps: [{ service: { name: api, run: [/srv/api] } }] } }");
148        assert!(matches!(with_command, Ok(ContainerProcess::Supervised { command: Some(_), .. })));
149    }
150
151    #[test]
152    fn a_disabled_service_is_not_started() {
153        let p = process("{ os: debian@12, app: { steps: [{ packages: [nginx] }, { service: { name: nginx, enabled: false } }] } }");
154        assert_eq!(p, Ok(ContainerProcess::Idle));
155    }
156
157    #[test]
158    fn a_service_with_no_foreground_form_is_refused() {
159        let p = process("{ os: debian@12, app: { steps: [{ service: myd }] } }");
160        assert_eq!(p, Err(NoProcess::NoForeground { unit: "myd".parse().unwrap() }));
161        // Alpine's cron is BusyBox's: the table has no form of it there.
162        assert!(process("{ os: alpine, app: { steps: [{ service: cron }] } }").is_err());
163    }
164
165    /// The rule of `4bfd8d5f`, now the model's: steps that install a service and ports, with
166    /// nothing that runs it, answer nothing in a container.
167    #[test]
168    fn ports_nothing_serves_are_refused() {
169        assert_eq!(
170            process("{ os: debian@12, ports: { web: 80 }, app: { steps: [{ run: web.sh }] } }"),
171            Err(NoProcess::NothingServes)
172        );
173        assert_eq!(
174            process("{ os: debian@12, ports: { web: 80 } }"),
175            Ok(ContainerProcess::Idle),
176            "the OS alone stays idle, as before"
177        );
178    }
179}