Skip to main content

isoloom_plan/configure/
recipe.rs

1//! A machine's recipe resolved: its steps for its OS's family, its firewall's ports by number, its
2//! services' units and foreground forms. Steps a family can't carry at all (a repository on
3//! Windows, a file's owner or mode there) are refused here, so the rule is one, whichever
4//! renderer asks. Lowering resolves the authored machine ([`recipe`]); the environment resolves
5//! the configuration lowering hands it ([`Recipe::of`]), once per machine, for every renderer.
6
7use std::collections::BTreeMap;
8
9use indexmap::IndexMap;
10use isoloom_spec::model::{self, Configuration, Protocol};
11use isoloom_spec::syntax::{self, FileState, FirewallPort, OsRef, PortName, PortProtocol, ProjectPath, Service, Step};
12
13use super::daemons::{Daemon, DaemonTable};
14use super::family::{Family, codename};
15use super::resources::{PortSpec, RepositoryKind, RepositoryPlan, Resource, RunPlan, ServicePlan, ServiceSource, Shell};
16
17/// A recipe resolved for one machine: its phases in order (`install`, `configure`, `start`), the
18/// daemons its container form runs, and the texts of its templates.
19#[derive(Debug, Clone, PartialEq, Eq)]
20#[non_exhaustive]
21pub struct Recipe {
22    /// The OS it is resolved for.
23    pub os: OsRef,
24    /// The OS's family.
25    pub family: Family,
26    /// Install: an app's steps; a catalogue entry's `install` (which its official image skips).
27    pub install: Vec<Resource>,
28    /// Configure: a catalogue entry's `configure`, run in every form.
29    pub configure: Vec<Resource>,
30    /// Start: a catalogue entry's per-start preparation (an app has none in 1.0).
31    pub start: Vec<Resource>,
32    /// The foreground forms of its enabled services, in recipe order: the container's daemons.
33    pub daemons: Vec<Daemon>,
34    /// The text of each of its templates, by the template's `from`, its lab placeholders not yet
35    /// filled in ([`super::fill`] does, per machine and target). A template missing here wasn't
36    /// read: the renderers refuse it rather than install something else.
37    pub files: BTreeMap<ProjectPath, String>,
38}
39
40impl Recipe {
41    /// Its resources, phase after phase.
42    pub fn resources(&self) -> impl Iterator<Item = &Resource> {
43        self.install.iter().chain(&self.configure).chain(&self.start)
44    }
45
46    /// The recipe of configuration `c`, its firewall's port names read in `ports` (the lowered
47    /// machine's), its services in `daemons`.
48    ///
49    /// # Errors
50    ///
51    /// Every step `c`'s family can't carry ([`Unportable`]), in step order. Lowering refuses
52    /// those first, so a lowered machine's configuration always resolves.
53    pub fn of(c: &Configuration, ports: &[model::Port], daemons: &DaemonTable) -> Result<Recipe, Vec<Unportable>> {
54        let port = |name: &PortName| {
55            let found = ports.iter().find(|p| p.name.as_deref() == Some(name.as_str()))?;
56            Some(PortSpec {
57                port: std::num::NonZeroU16::new(found.port)?,
58                protocol: found.protocol,
59            })
60        };
61        let mut r = resolve(c.os, [&c.install, &c.configure, &c.start], &port, daemons)?;
62        r.files.clone_from(&c.files);
63        Ok(r)
64    }
65}
66
67/// A step its machine's family can't carry, in any form.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct Unportable {
70    /// The step, by its index in `app.steps`.
71    pub step: usize,
72    /// The field under the step's kind that rules it out (`owner`), when one does.
73    pub field: Option<&'static str>,
74    /// Why.
75    pub reason: String,
76    /// What to write instead.
77    pub fix: String,
78}
79
80/// The recipe of machine `m`'s steps on `os`, its services read in `daemons`.
81///
82/// # Errors
83///
84/// Every step `os`'s family can't carry ([`Unportable`]), in step order.
85pub fn recipe(os: OsRef, m: &syntax::Machine, daemons: &DaemonTable) -> Result<Recipe, Vec<Unportable>> {
86    let steps = m.app().map(|a| a.steps.as_slice()).unwrap_or_default();
87    resolve(os, [steps, &[], &[]], &|name| named_port(name, &m.ports), daemons)
88}
89
90/// Where a recipe's firewall reads a port by its name.
91type PortOf<'a> = dyn Fn(&PortName) -> Option<PortSpec> + 'a;
92
93/// The recipe of `phases` (`install`, `configure`, `start`) on `os`. A step is numbered by its
94/// place in the phases one after the other (an app's: its index in `app.steps`).
95fn resolve(os: OsRef, phases: [&[Step]; 3], port: &PortOf<'_>, daemons: &DaemonTable) -> Result<Recipe, Vec<Unportable>> {
96    let family = Family::from(os);
97    let all = || phases.iter().flat_map(|p| p.iter());
98    let unportable: Vec<Unportable> = all().enumerate().filter_map(|(i, s)| unportable(i, s, family)).collect();
99    if !unportable.is_empty() {
100        return Err(unportable);
101    }
102    let mut unknown = Vec::new();
103    let mut resolved: [Vec<Resource>; 3] = Default::default();
104    let mut i = 0;
105    for (phase, steps) in resolved.iter_mut().zip(phases) {
106        for step in steps {
107            match resource(step, os, port, daemons) {
108                Some(r) => phase.push(r),
109                None => unknown.push(Unportable {
110                    step: i,
111                    field: None,
112                    reason: format!("this Isoloom can't resolve a `{}` step", step.kind()),
113                    fix: "upgrade Isoloom's plan with its spec".into(),
114                }),
115            }
116            i += 1;
117        }
118    }
119    if !unknown.is_empty() {
120        return Err(unknown);
121    }
122    let daemons = all()
123        .filter_map(|s| match s {
124            Step::Service(s) if s.enabled() => foreground(s, family, daemons),
125            _ => None,
126        })
127        .collect();
128    let [install, configure, start] = resolved;
129    Ok(Recipe {
130        os,
131        family,
132        install,
133        configure,
134        start,
135        daemons,
136        files: BTreeMap::new(),
137    })
138}
139
140/// Why `family` can't carry step `i` at all, when it can't.
141fn unportable(i: usize, step: &Step, family: Family) -> Option<Unportable> {
142    if family != Family::Windows {
143        return None;
144    }
145    let acl = |field| Unportable {
146        step: i,
147        field: Some(field),
148        reason: format!("a Windows file's `{field}` is an ACL, which the model sets in 1.x"),
149        fix: format!("leave `{field}` out, or set the ACL from a `.ps1` step"),
150    };
151    match step {
152        Step::Repository(_) => Some(Unportable {
153            step: i,
154            field: None,
155            reason: "Windows has no package repository Isoloom drives".into(),
156            fix: "`{ run: provision/install.ps1 }`".into(),
157        }),
158        Step::Packages(p) if p.iter().any(|p| p.version.is_some()) => Some(Unportable {
159            step: i,
160            field: None,
161            reason: "a Windows feature has no version".into(),
162            fix: "the feature's name alone (`Web-Server`)".into(),
163        }),
164        Step::Copy(c) => first_set(&[("owner", c.owner.is_some()), ("mode", c.mode.is_some())]).map(acl),
165        Step::Template(t) => first_set(&[("owner", t.owner.is_some()), ("group", t.group.is_some()), ("mode", t.mode.is_some())]).map(acl),
166        Step::File(f) if matches!(f.state, FileState::Link(_)) => Some(Unportable {
167            step: i,
168            field: Some("link"),
169            reason: "Windows links need privileges a set-up step doesn't have".into(),
170            fix: "copy the file instead, or make the link from a `.ps1` step".into(),
171        }),
172        Step::File(f) => first_set(&[("owner", f.owner.is_some()), ("group", f.group.is_some()), ("mode", f.mode.is_some())]).map(acl),
173        Step::User(u) => first_set(&[
174            ("uid", u.uid.is_some()),
175            ("home", u.home.is_some()),
176            ("shell", u.shell.is_some()),
177            ("system", u.system),
178        ])
179        .map(|field| Unportable {
180            step: i,
181            field: Some(field),
182            reason: format!("a Windows account has no `{field}`"),
183            fix: format!("leave `{field}` out on Windows"),
184        }),
185        Step::Group(g) => first_set(&[("gid", g.gid.is_some()), ("system", g.system)]).map(|field| Unportable {
186            step: i,
187            field: Some(field),
188            reason: format!("a Windows group has no `{field}`"),
189            fix: format!("leave `{field}` out on Windows"),
190        }),
191        Step::Service(Service::Defined { user: Some(_), .. }) => Some(Unportable {
192            step: i,
193            field: Some("user"),
194            reason: "a Windows service Isoloom defines runs as SYSTEM in 1.0".into(),
195            fix: "leave `user` out on Windows".into(),
196        }),
197        _ => None,
198    }
199}
200
201/// The first of these fields that is set.
202fn first_set(fields: &[(&'static str, bool)]) -> Option<&'static str> {
203    fields.iter().find_map(|(field, set)| set.then_some(*field))
204}
205
206/// Step `step` resolved on `os` (`port`: its machine's ports, for the firewall's names); `None`
207/// for a kind the spec has and this plan doesn't (the two crates ship together, so never).
208fn resource(step: &Step, os: OsRef, port: &PortOf<'_>, daemons: &DaemonTable) -> Option<Resource> {
209    let family = Family::from(os);
210    let r = match step {
211        Step::Run { script, creates } => Resource::Run(RunPlan {
212            script: script.clone(),
213            creates: creates.clone(),
214            shell: if family == Family::Windows { Shell::PowerShell } else { Shell::Posix },
215        }),
216        Step::Copy(c) => Resource::Copy(c.clone()),
217        Step::Packages(p) => Resource::Packages(p.clone()),
218        Step::Reboot => Resource::Reboot,
219        Step::Playbook(p) => Resource::Playbook(p.clone()),
220        Step::Repository(r) => Resource::Repository(repository(r, os)),
221        Step::File(f) => Resource::File(f.clone()),
222        Step::Template(t) => Resource::Template(t.clone()),
223        Step::User(u) => Resource::User(u.clone()),
224        Step::Group(g) => Resource::Group(g.clone()),
225        Step::Service(s) => Resource::Service(service(s, family, daemons)),
226        Step::Command(c) => Resource::Command(c.clone()),
227        Step::Firewall(f) => Resource::Firewall(f.allow.iter().filter_map(|p| firewall_port(p, port)).collect()),
228        _ => return None,
229    };
230    Some(r)
231}
232
233/// A repository on `os`: apt's suite (the OS's codename) and components (`main`) filled in.
234fn repository(r: &syntax::Repository, os: OsRef) -> RepositoryPlan {
235    let kind = match Family::from(os) {
236        Family::RedHat => RepositoryKind::Rpm,
237        Family::Alpine => RepositoryKind::Apk,
238        Family::Debian | Family::Windows => RepositoryKind::Apt,
239    };
240    let apt = kind == RepositoryKind::Apt;
241    let suite = r.suite.clone().or_else(|| codename(os.os()).and_then(|c| c.parse().ok())).filter(|_| apt);
242    let components = match &r.components {
243        Some(c) => c.clone(),
244        None if apt => "main".parse().ok().into_iter().collect(),
245        None => Vec::new(),
246    };
247    RepositoryPlan {
248        name: r.name.clone(),
249        kind,
250        url: r.url.clone(),
251        key: r.key.clone(),
252        suite,
253        components,
254        priority: r.priority,
255    }
256}
257
258/// A service on `family`: a packaged unit's name as the family has it.
259fn service(s: &Service, family: Family, daemons: &DaemonTable) -> ServicePlan {
260    match s {
261        Service::Defined { name, run, user, cwd, enabled } => ServicePlan {
262            unit: name.clone(),
263            source: ServiceSource::Defined {
264                run: run.clone(),
265                user: user.clone(),
266                cwd: cwd.clone(),
267            },
268            enabled: *enabled,
269        },
270        _ => ServicePlan {
271            unit: daemons.find(s.name(), family).map_or_else(|| s.name().clone(), |(_, f)| f.daemon.unit.clone()),
272            source: ServiceSource::Package,
273            enabled: s.enabled(),
274        },
275    }
276}
277
278/// The foreground form of service `s` on `family`: its own `run`, else the daemon table's.
279fn foreground(s: &Service, family: Family, daemons: &DaemonTable) -> Option<Daemon> {
280    match s {
281        Service::Defined { name, run, user, .. } => Some(Daemon {
282            unit: name.clone(),
283            foreground: run.clone(),
284            user: user.clone(),
285            logs: Vec::new(),
286        }),
287        _ => daemons.find(s.name(), family).map(|(_, f)| f.daemon.clone()),
288    }
289}
290
291/// A firewall port by number: a named one is the machine's port (the spec's checks report a
292/// name the machine doesn't have).
293fn firewall_port(p: &FirewallPort, named: &PortOf<'_>) -> Option<PortSpec> {
294    match p {
295        FirewallPort::Named(name) => named(name),
296        FirewallPort::Number { port, protocol } => Some(PortSpec {
297            port: *port,
298            protocol: *protocol,
299        }),
300        _ => None,
301    }
302}
303
304/// The authored machine's port `name`, by number and transport.
305fn named_port(name: &PortName, ports: &IndexMap<PortName, syntax::Port>) -> Option<PortSpec> {
306    let found = ports.get(name)?;
307    let protocol = if found.protocol == PortProtocol::Udp { Protocol::Udp } else { Protocol::Tcp };
308    Some(PortSpec { port: found.port?, protocol })
309}
310
311#[cfg(test)]
312mod tests;