1use super::app::Recipe;
8use super::machine::{Kind, Machine};
9use super::problem::{Code, FieldPath, Problem};
10use super::step::Step;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum Form<'a> {
15 Supplied,
17 Recipe(&'a Recipe),
19 Steps,
21 Os,
23 Service,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct NoForm {
30 pub field: String,
32 pub reason: String,
34 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 #[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 #[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
64pub 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
114pub 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#[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}