1use 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#[derive(Debug, Clone, PartialEq, Eq)]
16#[non_exhaustive]
17pub enum ContainerProcess {
18 Image,
20 Started {
23 entrypoint: Vec<String>,
25 command: Vec<String>,
27 },
28 Idle,
30 Command(CommandLine),
32 One(Daemon),
34 Supervised {
37 daemons: Vec<Daemon>,
39 command: Option<CommandLine>,
41 },
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
46#[non_exhaustive]
47pub enum NoProcess {
48 NoForeground {
50 unit: UnitName,
52 },
53 NothingServes,
56}
57
58impl ContainerProcess {
59 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 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 #[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 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 assert!(process("{ os: alpine, app: { steps: [{ service: cron }] } }").is_err());
163 }
164
165 #[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}