Skip to main content

isoloom_spec/syntax/
forms.rs

1//! The forms a machine takes, derived from what it is: a container (its image, its build, a
2//! Dockerfile generated from its steps, its OS's image) and a VM (a fresh VM of its OS, set up by
3//! its steps). A machine without one of them says why, naming the field that rules it out, so a
4//! target that needs that form refuses the machine with the reason. What a container built from
5//! steps runs (its services, its command) is the configuration model's rule, in the plan.
6
7use super::app::Recipe;
8use super::machine::{Kind, Machine};
9use super::problem::{Code, FieldPath, Problem};
10use super::step::Step;
11
12/// How a machine is produced in one form.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum Form<'a> {
15    /// The runner supplies it (an access machine with no `os`).
16    Supplied,
17    /// A container from the app's own recipe.
18    Recipe(&'a Recipe),
19    /// A container Isoloom builds from the OS's image and the steps.
20    Steps,
21    /// The OS alone: its image kept running idle, or a fresh VM.
22    Os,
23    /// A catalogue service.
24    Service,
25}
26
27/// Why a machine has no form of a kind: the field that rules it out, why, and what to write.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct NoForm {
30    /// The field, under the machine (`app.build`, `os`, `app.steps[2]`).
31    pub field: String,
32    /// Why that field rules the form out.
33    pub reason: String,
34    /// What would give the machine the form.
35    pub fix: String,
36}
37
38impl NoForm {
39    fn new(field: impl Into<String>, reason: impl Into<String>, fix: impl Into<String>) -> NoForm {
40        NoForm {
41            field: field.into(),
42            reason: reason.into(),
43            fix: fix.into(),
44        }
45    }
46
47    /// The `refused` problem of a target that needs the form, at the field for machine `name`.
48    #[must_use]
49    pub fn refusal(&self, name: &str) -> Problem {
50        Problem::new(Code::Refused, self.path(name), self.reason.clone()).fix(self.fix.clone())
51    }
52
53    /// The field's full path for machine `name`.
54    #[must_use]
55    pub fn path(&self, name: &str) -> FieldPath {
56        let machine = FieldPath::new("machines").key(name);
57        match self.field.split_once('[') {
58            Some((key, rest)) => FieldPath::new(format!("{}[{rest}", machine.key(key))),
59            None => machine.key(&self.field),
60        }
61    }
62}
63
64/// The machine's container form, or why it has none.
65///
66/// # Errors
67///
68/// [`NoForm`]: the field that rules the form out, why, and the fix.
69pub fn container(m: &Machine) -> Result<Form<'_>, NoForm> {
70    if m.access && m.os.is_none() && matches!(m.kind, Kind::Bare) {
71        return Ok(Form::Supplied);
72    }
73    if let Some(os) = m.os
74        && os.is_windows()
75        && !matches!(&m.kind, Kind::App(a) if a.container.is_some())
76    {
77        return Err(NoForm::new(
78            "os",
79            "Windows has no container form in 1.0",
80            "run it on a VM target (`vagrant`, `proxmox`, `cloud-vm`)",
81        ));
82    }
83    if let Some(field) = m.hardware.vm_only() {
84        return Err(NoForm::new(
85            format!("hardware.{field}"),
86            "only a VM gives it: a container has no kernel or firmware of its own",
87            "use a VM target, or leave it out for the container form",
88        ));
89    }
90    match &m.kind {
91        Kind::Service(s) if s.services.len() > 1 => Err(NoForm::new(
92            "service",
93            "several services share a VM, but a container runs one",
94            "one machine per service, or a VM target",
95        )),
96        Kind::Service(_) => Ok(Form::Service),
97        Kind::App(a) => match &a.container {
98            Some(recipe) => Ok(Form::Recipe(recipe)),
99            None => match a.steps.iter().enumerate().find_map(|(i, s)| s.container_refusal().map(|why| (i, why))) {
100                Some((i, why)) => Err(NoForm::new(
101                    format!("app.steps[{i}]"),
102                    why,
103                    "give the container form its own recipe (`app.image` or `app.build`), or use a VM target",
104                )),
105                None if m.os.is_some() => Ok(Form::Steps),
106                None => Err(NoForm::new("app.steps", "steps install onto an OS", "`os: debian@12`")),
107            },
108        },
109        Kind::Bare if m.os.is_some() => Ok(Form::Os),
110        Kind::Bare => Err(NoForm::new("os", "a machine says what it is", "`os: debian@12`, plus `app:` or `service:`")),
111    }
112}
113
114/// The machine's VM form, or why it has none.
115///
116/// # Errors
117///
118/// [`NoForm`]: the field that rules the form out, why, and the fix.
119pub fn vm(m: &Machine) -> Result<Form<'_>, NoForm> {
120    if m.access && m.os.is_none() && matches!(m.kind, Kind::Bare) {
121        return Ok(Form::Supplied);
122    }
123    if !m.hardware.devices.is_empty() {
124        return Err(NoForm::new(
125            "hardware.devices",
126            "the host's devices go to a container on the host's own Docker; a VM has its own",
127            "use a container target, or leave them out for the VM form",
128        ));
129    }
130    match &m.kind {
131        Kind::App(a) if a.steps.is_empty() => match &a.container {
132            Some(Recipe::Image(_)) => Err(NoForm::new(
133                "app.image",
134                "an image's filesystem can't be reproduced on a fresh VM",
135                "add `os` and `app.steps` for a VM form, or use `docker`, `docker-vm` or `cloud-docker`",
136            )),
137            Some(Recipe::Build(_)) => Err(NoForm::new(
138                "app.build",
139                "a Dockerfile builds a container only",
140                "add `os` and `app.steps`, or use `docker`, `docker-vm` or `cloud-docker`",
141            )),
142            Some(Recipe::Appliance(_)) => Err(NoForm::new(
143                "app.appliance",
144                "an appliance's packaging exists only as a container",
145                "use `docker`, `docker-vm` or `cloud-docker`",
146            )),
147            None => Err(NoForm::new("app", "an app needs a recipe", "`app: { steps: [...] }` with `os`")),
148        },
149        _ if m.os.is_none() => Err(NoForm::new("os", "a VM is a fresh install of an OS", "`os: debian@12`")),
150        Kind::Service(_) => Ok(Form::Service),
151        Kind::App(_) => Ok(Form::Steps),
152        Kind::Bare => Ok(Form::Os),
153    }
154}
155
156/// Whether one of the machine's steps restarts it (its inputs must outlive the boot).
157#[must_use]
158pub fn reboots(m: &Machine) -> bool {
159    m.app().is_some_and(|a| a.steps.contains(&Step::Reboot))
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    fn machine(yaml: &str) -> Machine {
167        serde_yaml_ng::from_str(yaml).unwrap()
168    }
169
170    #[test]
171    fn services_and_bare_machines_take_the_forms_their_os_allows() {
172        let one = machine("{ os: debian@12, service: postgres@16 }");
173        assert_eq!((container(&one), vm(&one)), (Ok(Form::Service), Ok(Form::Service)));
174        let two = machine("{ os: debian@12, service: [nginx, php-fpm] }");
175        assert_eq!(container(&two).unwrap_err().field, "service");
176        assert_eq!(vm(&two), Ok(Form::Service));
177        let no_os = machine("{ networks: { lab: 10 } }");
178        assert_eq!(
179            (container(&no_os).unwrap_err().field, vm(&no_os).unwrap_err().field),
180            ("os".to_owned(), "os".to_owned())
181        );
182        let steps_no_os = machine("{ app: { steps: [{ run: a.sh }] } }");
183        assert_eq!(container(&steps_no_os).unwrap_err().field, "app.steps");
184        assert_eq!(vm(&steps_no_os).unwrap_err().field, "os");
185        assert_eq!(container(&machine("{ os: debian@12, app: { steps: [{ run: a.sh }] } }")), Ok(Form::Steps));
186    }
187
188    #[test]
189    fn only_a_reboot_step_reboots() {
190        assert!(!reboots(&machine("{ os: debian@12, app: { steps: [{ run: a.sh }] } }")));
191        assert!(!reboots(&machine("{ os: debian@12 }")));
192    }
193
194    #[test]
195    fn a_refusal_is_placed_at_its_field() {
196        let built = machine("{ app: { build: build/web } }");
197        let refusal = vm(&built).unwrap_err().refusal("web");
198        assert_eq!((refusal.code, refusal.path.as_str()), (Code::Refused, "machines.web.app.build"));
199        assert_eq!(
200            refusal.fix.as_deref(),
201            Some("add `os` and `app.steps`, or use `docker`, `docker-vm` or `cloud-docker`")
202        );
203    }
204
205    #[test]
206    fn each_recipe_gives_its_forms() {
207        let os_only = machine("{ os: debian@12 }");
208        assert_eq!((container(&os_only), vm(&os_only)), (Ok(Form::Os), Ok(Form::Os)));
209        let built = machine("{ app: { build: build/web } }");
210        assert!(matches!(container(&built), Ok(Form::Recipe(_))));
211        assert_eq!(vm(&built).unwrap_err().path("web").as_str(), "machines.web.app.build");
212        let rebooting = machine("{ os: debian@12, app: { steps: [{ run: a.sh }, reboot] } }");
213        assert_eq!(container(&rebooting).unwrap_err().path("web").as_str(), "machines.web.app.steps[1]");
214        assert_eq!(vm(&rebooting), Ok(Form::Steps));
215        assert!(reboots(&rebooting));
216        let both = machine("{ os: debian@12, app: { image: redis:7, steps: [reboot] } }");
217        assert!(matches!(container(&both), Ok(Form::Recipe(_))) && vm(&both).is_ok());
218    }
219
220    #[test]
221    fn hardware_only_one_form_has_rules_out_the_other() {
222        let tpm = machine("{ os: debian@12, hardware: { tpm: required } }");
223        assert_eq!(container(&tpm).unwrap_err().field, "hardware.tpm");
224        assert_eq!(vm(&tpm), Ok(Form::Os));
225        let fuse = machine("{ os: debian@12, hardware: { devices: [/dev/fuse] } }");
226        assert_eq!(vm(&fuse).unwrap_err().field, "hardware.devices");
227        assert_eq!(container(&fuse), Ok(Form::Os));
228    }
229
230    #[test]
231    fn windows_and_access_machines() {
232        let windows = machine("{ os: windows-server@2019, app: { steps: [{ run: iis.ps1 }] } }");
233        assert_eq!(container(&windows).unwrap_err().field, "os");
234        let supplied = machine("{ access: true }");
235        assert_eq!((container(&supplied), vm(&supplied)), (Ok(Form::Supplied), Ok(Form::Supplied)));
236        let kali = machine("{ access: true, os: kali }");
237        assert_eq!(container(&kali), Ok(Form::Os));
238    }
239}