Skip to main content

isoloom_plan/
lifecycle.rs

1//! A VM's set-up, as an ordered list of steps every VM backend runs the same way. The plan says
2//! what each step does; `isoloom-render`'s `steps` module writes each one as Linux shell (and
3//! PowerShell where Windows has it); a backend only picks the transport (a Vagrant provisioner,
4//! cloud-init, `remote-exec`, WinRM).
5
6use std::net::{Ipv4Addr, Ipv6Addr};
7
8use isoloom_spec::model::{Machine, MachineFile, Spec};
9
10use crate::addressing::address_for;
11use crate::configure::{Recipe, Resource, Shell};
12use crate::environment::Route;
13use crate::ipv6::{self, Route6};
14use crate::routing;
15use crate::storage::DataDisk;
16
17/// A machine's set-up steps, in order.
18pub type MachineLifecycle = Vec<Step>;
19
20/// One set-up step of a VM.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum Step {
23    /// The spec's constraints: proxy, package mirrors, NTP (before anything downloads).
24    Prelude,
25    /// Grows the root file system to the disk the machine was given.
26    DiskGrow,
27    /// Formats its data disks (once, when blank) and mounts them, at every boot.
28    Disks {
29        /// The disks, in spec order (a raw one is left as it is).
30        disks: Vec<DataDisk>,
31    },
32    /// The machine's environment, sysctls and ulimits.
33    Settings,
34    /// The other machines by name, at the address this one reaches each at.
35    Hosts {
36        /// One `(address, names)` per other machine, its own name first.
37        entries: Vec<(Ipv4Addr, Vec<String>)>,
38    },
39    /// Routes to the networks it isn't on, then its default route through a gateway.
40    Routes {
41        /// The routes.
42        routes: Vec<Route>,
43        /// The default route's next hop.
44        default: Option<Ipv4Addr>,
45    },
46    /// Its IPv6 addresses, each on the interface holding its IPv4 address on that network,
47    /// then its IPv6 routes through the router.
48    Ipv6 {
49        /// One `(IPv4 address, IPv6 address)` per IPv6 network: the IPv4 one finds the interface.
50        addresses: Vec<(Ipv4Addr, Ipv6Addr)>,
51        /// The routes.
52        routes: Vec<Route6>,
53    },
54    /// 802.1Q interfaces for the VLANs it reaches through a trunk.
55    Trunks,
56    /// Its further addresses on a network (`lan: [10, 11]`), each on the interface holding its
57    /// primary address there.
58    MoreAddresses {
59        /// One entry per network where it has several.
60        addresses: Vec<crate::more_addresses::MoreAddresses>,
61    },
62    /// The directories of its own volumes.
63    Volumes {
64        /// The mount paths.
65        paths: Vec<String>,
66    },
67    /// A shared volume: served over NFS by its owner, mounted by the others.
68    SharedVolume {
69        /// The volume's name.
70        volume: String,
71        /// Where this machine has it.
72        path: String,
73        /// This machine's part.
74        role: SharedRole,
75    },
76    /// The project copied to `/opt/isoloom`.
77    Project,
78    /// The machine's `files`, each placed at its path with its owner and mode (a `source` read
79    /// from the unpacked project).
80    Files {
81        /// The files, in spec order.
82        files: Vec<MachineFile>,
83    },
84    /// The inputs made available to the steps.
85    Inputs {
86        /// Kept on disk (a reboot comes and `/tmp` is emptied at boot).
87        persist: bool,
88    },
89    /// Waits for `isoloom run` to deliver its secrets, then puts each file in place, now and at
90    /// every boot (see [`crate::SecretFile`]). Before anything that may read one: the
91    /// app's steps, its command and its `setup` scripts.
92    Secrets,
93    /// Waits until a machine it depends on answers.
94    WaitFor {
95        /// The machine.
96        machine: String,
97        /// The ports it must answer on.
98        ports: Vec<u16>,
99    },
100    /// One of its app's scripts or playbooks.
101    Run {
102        /// The script or playbook, from the project folder.
103        path: String,
104        /// How it runs.
105        kind: RunKind,
106        /// A path whose existence means it already ran (a script's `creates`).
107        creates: Option<String>,
108    },
109    /// A run of its recipe's other steps (packages, files, users, services...), between its
110    /// scripts, playbooks and reboots, rendered by the configuration library of its OS.
111    Configure {
112        /// The steps, in recipe order.
113        resources: Vec<Resource>,
114        /// Whether no script, playbook or reboot ran before it in this set-up, so nothing has
115        /// changed yet (the services after one restart: the rule errs on restarting).
116        fresh: bool,
117    },
118    /// Restarts the machine; the steps after it run once it is back.
119    Reboot,
120    /// Its own command, run as a service from now on (see [`crate::Process`]).
121    Command,
122    /// Its `setup` scripts, run once with its service up, as root from the project folder.
123    Setup {
124        /// The scripts, from the project folder.
125        paths: Vec<String>,
126    },
127    /// Offline: no new connection leaves the environment.
128    Egress,
129    /// Link impairment on its own interfaces.
130    Tc,
131    /// Marks the machine ready, once its healthcheck passes when it has one.
132    Ready,
133}
134
135/// A machine's part in a shared volume.
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub enum SharedRole {
138    /// It holds the data and serves it to these addresses.
139    Serve {
140        /// The addresses of the machines mounting it.
141        clients: Vec<Ipv4Addr>,
142        /// The folders other machines take of it (`subpath`), created when missing.
143        folders: Vec<String>,
144    },
145    /// It mounts the owner's export.
146    Mount {
147        /// `<owner address>:<owner path>`, then `/<subpath>` when it takes one folder.
148        source: String,
149        /// The owner, named in errors.
150        owner: String,
151    },
152    /// It mounts a folder of the server the target shares into the VM (virtiofs on Proxmox),
153    /// as every other machine using the volume does: no owner serves it. The plan never makes
154    /// this role; a target that shares the volume so puts it in place of the other two.
155    HostFolder {
156        /// A file the target writes in the machine, holding the folder's mount tag (the tag is
157        /// only known once the target's own names are, a slot in them).
158        tag_file: String,
159        /// The folder of the volume it takes (`subpath`), if not all of it.
160        folder: Option<String>,
161    },
162}
163
164/// How a `provision` step runs.
165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166pub enum RunKind {
167    /// A shell script.
168    Shell,
169    /// An Ansible playbook, run locally.
170    Playbook,
171    /// A PowerShell script (a Windows machine's).
172    PowerShell,
173}
174
175/// The set-up steps of VM `name`, its configuration resolved as `recipe`.
176#[must_use]
177pub fn of(spec: &Spec, name: &str, m: &Machine, recipe: Option<&Recipe>) -> MachineLifecycle {
178    let mut steps = vec![Step::Prelude];
179    if m.resources.and_then(|r| r.disk_gb).is_some() {
180        steps.push(Step::DiskGrow);
181    }
182    if !m.disks.is_empty() {
183        steps.push(Step::Disks {
184            disks: crate::storage::disks(name, m),
185        });
186    }
187    steps.push(Step::Project);
188    steps.push(hosts(spec, name));
189    steps.push(Step::Settings);
190    steps.extend(shared_volumes(spec, name, m));
191    steps.push(Step::Trunks);
192    steps.extend(crate::more_addresses::step(spec, m));
193    steps.push(Step::Routes {
194        routes: routing::routes(spec, name, m).into_iter().map(|(cidr, via)| Route { cidr, via }).collect(),
195        default: routing::default_gateway(spec, name, m),
196    });
197    steps.extend(ipv6_step(spec, m));
198    let own: Vec<String> = m
199        .volumes
200        .iter()
201        .filter(|(v, _)| !spec.volumes.contains_key(*v))
202        .map(|(_, p)| p.path().to_owned())
203        .collect();
204    if !own.is_empty() {
205        steps.push(Step::Volumes { paths: own });
206    }
207    steps.extend(crate::machine_files::step(m));
208    if !m.secrets.is_empty() {
209        steps.push(Step::Secrets);
210    }
211    steps.extend(m.depends_on.iter().filter_map(|dep| {
212        let ports = spec.machines.get(dep)?.ready_ports();
213        (!ports.is_empty()).then(|| Step::WaitFor { machine: dep.clone(), ports })
214    }));
215    steps.extend(runs(m, recipe));
216    if m.command.is_some() {
217        steps.push(Step::Command);
218    }
219    if let Some(vm) = m.vm.as_ref().filter(|vm| !vm.setup.is_empty()) {
220        steps.push(Step::Setup { paths: vm.setup.clone() });
221    }
222    if routing::offline(spec, name, m) {
223        steps.push(Step::Egress);
224    }
225    if m.networks.keys().any(|n| spec.networks.get(n).is_some_and(|net| net.tc.is_some())) {
226        steps.push(Step::Tc);
227    }
228    steps.push(Step::Ready);
229    steps
230}
231
232/// Its IPv6 addresses and routes, when it has any.
233fn ipv6_step(spec: &Spec, m: &Machine) -> Option<Step> {
234    let addresses: Vec<(Ipv4Addr, Ipv6Addr)> = m
235        .networks
236        .iter()
237        .filter_map(|(n, o)| Some((crate::addressing::address(spec, n, *o), ipv6::address(spec, n, *o)?)))
238        .collect();
239    let routes = ipv6::routes(spec, m);
240    (!addresses.is_empty() || !routes.is_empty()).then_some(Step::Ipv6 { addresses, routes })
241}
242
243/// This machine and the others by name (and under the private zone's domain, with `dns.private`),
244/// so a machine resolves its own names in the zone too, as the clouds' private DNS does. A Linux
245/// machine leaves the Windows ones out: in a domain they are reached through AD DNS, and a short
246/// name in /etc/hosts shadows it (a realm join then fails the Kerberos SPN lookup, "Server not
247/// found").
248fn hosts(spec: &Spec, name: &str) -> Step {
249    let linux = !spec.machines.get(name).is_some_and(windows);
250    let entries = spec
251        .machines
252        .iter()
253        .filter(|(_, om)| !(om.networks.is_empty() || linux && windows(om)))
254        .map(|(o, om)| {
255            let names = std::iter::once(o.clone()).chain(om.aliases.iter().cloned()).collect();
256            (address_for(spec, name, o), crate::private_dns::with_domain(spec, names))
257        })
258        .collect();
259    Step::Hosts {
260        entries: [entries, more_addresses(spec, name, linux)].concat(),
261    }
262}
263
264/// The other machines' further addresses (`lan: [10, 11]`), after every primary one, under
265/// their machine's own name: a reverse lookup names the machine, and a lookup by name still
266/// finds its primary address first.
267fn more_addresses(spec: &Spec, name: &str, linux: bool) -> Vec<(Ipv4Addr, Vec<String>)> {
268    spec.machines
269        .iter()
270        .filter(|(o, om)| o.as_str() != name && !(linux && windows(om)))
271        .flat_map(|(o, om)| {
272            let names = crate::private_dns::with_domain(spec, vec![o.clone()]);
273            crate::more_addresses::of(spec, om).into_values().flatten().map(move |a| (a, names.clone()))
274        })
275        .collect()
276}
277
278/// The machine's part in each shared volume it mounts: its owner serves it, the others mount
279/// the owner's export (validated: the owner mounts the volume too).
280fn shared_volumes(spec: &Spec, name: &str, m: &Machine) -> Vec<Step> {
281    let mount_of = |machine: &str, v: &str| spec.machines.get(machine).and_then(|om| om.volumes.get(v));
282    m.volumes
283        .iter()
284        .filter(|(v, _)| spec.volumes.contains_key(*v))
285        .filter_map(|(v, mount)| {
286            let owner = spec.volume_owner(v)?;
287            let role = if owner == name {
288                let others: Vec<&str> = spec.volume_users(v).into_iter().filter(|u| *u != name).collect();
289                let mut folders: Vec<String> = others.iter().filter_map(|u| mount_of(u, v)?.subpath().map(String::from)).collect();
290                folders.dedup();
291                SharedRole::Serve {
292                    clients: others.iter().map(|u| address_for(spec, name, u)).collect(),
293                    folders,
294                }
295            } else {
296                let at = mount_of(owner, v)?.path().trim_end_matches('/');
297                let folder = mount.subpath().map(|f| format!("/{f}")).unwrap_or_default();
298                SharedRole::Mount {
299                    source: format!("{}:{at}{folder}", address_for(spec, name, owner)),
300                    owner: owner.to_owned(),
301                }
302            };
303            Some(Step::SharedVolume {
304                volume: v.clone(),
305                path: mount.path().to_owned(),
306                role,
307            })
308        })
309        .collect()
310}
311
312/// Its inputs made available, then its recipe in order: each script, playbook and reboot a step
313/// of its own, the steps between them runs of the configuration library.
314fn runs(m: &Machine, recipe: Option<&Recipe>) -> Vec<Step> {
315    if m.vm.is_none() {
316        return Vec::new();
317    }
318    let mut steps = Vec::new();
319    if !m.inputs.is_empty() {
320        // Kept on disk when a reboot comes, and for the command, which reads them again at
321        // every boot.
322        let reboots = recipe.is_some_and(|r| r.resources().any(|x| matches!(x, Resource::Reboot)));
323        steps.push(Step::Inputs {
324            persist: reboots || m.command.is_some(),
325        });
326    }
327    let Some(recipe) = recipe else { return steps };
328    let mut run: Vec<Resource> = Vec::new();
329    let mut fresh = true;
330    for r in recipe.resources() {
331        let own = match r {
332            Resource::Run(script) => Step::Run {
333                path: script.script.to_string(),
334                kind: if script.shell == Shell::PowerShell {
335                    RunKind::PowerShell
336                } else {
337                    RunKind::Shell
338                },
339                creates: script.creates.as_ref().map(ToString::to_string),
340            },
341            Resource::Playbook(p) => Step::Run {
342                path: p.to_string(),
343                kind: RunKind::Playbook,
344                creates: None,
345            },
346            Resource::Reboot => Step::Reboot,
347            other => {
348                run.push(other.clone());
349                continue;
350            }
351        };
352        if !run.is_empty() {
353            steps.push(Step::Configure {
354                resources: std::mem::take(&mut run),
355                fresh,
356            });
357        }
358        steps.push(own);
359        fresh = false;
360    }
361    if !run.is_empty() {
362        steps.push(Step::Configure { resources: run, fresh });
363    }
364    steps
365}
366
367/// Whether a provisioning step's file has this extension, in any case (`setup.SH` is a shell
368/// script): how the plan and the backends tell a step's kind.
369#[must_use]
370pub fn has_extension(path: &str, extension: &str) -> bool {
371    std::path::Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case(extension))
372}
373
374/// Whether a machine is a Windows VM.
375fn windows(m: &Machine) -> bool {
376    m.vm.as_ref().is_some_and(|vm| isoloom_spec::images::is_windows(&vm.os))
377}
378
379#[cfg(test)]
380mod tests;