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;