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/// The other machines by name (and under the private zone's domain, with `dns.private`). A Linux machine leaves the Windows ones out: in a domain they are
244/// reached through AD DNS, and a short name in /etc/hosts shadows it (a realm join then fails
245/// the Kerberos SPN lookup, "Server not found").
246fn hosts(spec: &Spec, name: &str) -> Step {
247 let linux = !spec.machines.get(name).is_some_and(windows);
248 let entries = spec
249 .machines
250 .iter()
251 .filter(|(o, om)| o.as_str() != name && !om.networks.is_empty() && !(linux && windows(om)))
252 .map(|(o, om)| {
253 let names = std::iter::once(o.clone()).chain(om.aliases.iter().cloned()).collect();
254 (address_for(spec, name, o), crate::private_dns::with_domain(spec, names))
255 })
256 .collect();
257 Step::Hosts {
258 entries: [entries, more_addresses(spec, name, linux)].concat(),
259 }
260}
261
262/// The other machines' further addresses (`lan: [10, 11]`), after every primary one, under
263/// their machine's own name: a reverse lookup names the machine, and a lookup by name still
264/// finds its primary address first.
265fn more_addresses(spec: &Spec, name: &str, linux: bool) -> Vec<(Ipv4Addr, Vec<String>)> {
266 spec.machines
267 .iter()
268 .filter(|(o, om)| o.as_str() != name && !(linux && windows(om)))
269 .flat_map(|(o, om)| {
270 let names = crate::private_dns::with_domain(spec, vec![o.clone()]);
271 crate::more_addresses::of(spec, om).into_values().flatten().map(move |a| (a, names.clone()))
272 })
273 .collect()
274}
275
276/// The machine's part in each shared volume it mounts: its owner serves it, the others mount
277/// the owner's export (validated: the owner mounts the volume too).
278fn shared_volumes(spec: &Spec, name: &str, m: &Machine) -> Vec<Step> {
279 let mount_of = |machine: &str, v: &str| spec.machines.get(machine).and_then(|om| om.volumes.get(v));
280 m.volumes
281 .iter()
282 .filter(|(v, _)| spec.volumes.contains_key(*v))
283 .filter_map(|(v, mount)| {
284 let owner = spec.volume_owner(v)?;
285 let role = if owner == name {
286 let others: Vec<&str> = spec.volume_users(v).into_iter().filter(|u| *u != name).collect();
287 let mut folders: Vec<String> = others.iter().filter_map(|u| mount_of(u, v)?.subpath().map(String::from)).collect();
288 folders.dedup();
289 SharedRole::Serve {
290 clients: others.iter().map(|u| address_for(spec, name, u)).collect(),
291 folders,
292 }
293 } else {
294 let at = mount_of(owner, v)?.path().trim_end_matches('/');
295 let folder = mount.subpath().map(|f| format!("/{f}")).unwrap_or_default();
296 SharedRole::Mount {
297 source: format!("{}:{at}{folder}", address_for(spec, name, owner)),
298 owner: owner.to_owned(),
299 }
300 };
301 Some(Step::SharedVolume {
302 volume: v.clone(),
303 path: mount.path().to_owned(),
304 role,
305 })
306 })
307 .collect()
308}
309
310/// Its inputs made available, then its recipe in order: each script, playbook and reboot a step
311/// of its own, the steps between them runs of the configuration library.
312fn runs(m: &Machine, recipe: Option<&Recipe>) -> Vec<Step> {
313 if m.vm.is_none() {
314 return Vec::new();
315 }
316 let mut steps = Vec::new();
317 if !m.inputs.is_empty() {
318 // Kept on disk when a reboot comes, and for the command, which reads them again at
319 // every boot.
320 let reboots = recipe.is_some_and(|r| r.resources().any(|x| matches!(x, Resource::Reboot)));
321 steps.push(Step::Inputs {
322 persist: reboots || m.command.is_some(),
323 });
324 }
325 let Some(recipe) = recipe else { return steps };
326 let mut run: Vec<Resource> = Vec::new();
327 let mut fresh = true;
328 for r in recipe.resources() {
329 let own = match r {
330 Resource::Run(script) => Step::Run {
331 path: script.script.to_string(),
332 kind: if script.shell == Shell::PowerShell {
333 RunKind::PowerShell
334 } else {
335 RunKind::Shell
336 },
337 creates: script.creates.as_ref().map(ToString::to_string),
338 },
339 Resource::Playbook(p) => Step::Run {
340 path: p.to_string(),
341 kind: RunKind::Playbook,
342 creates: None,
343 },
344 Resource::Reboot => Step::Reboot,
345 other => {
346 run.push(other.clone());
347 continue;
348 }
349 };
350 if !run.is_empty() {
351 steps.push(Step::Configure {
352 resources: std::mem::take(&mut run),
353 fresh,
354 });
355 }
356 steps.push(own);
357 fresh = false;
358 }
359 if !run.is_empty() {
360 steps.push(Step::Configure { resources: run, fresh });
361 }
362 steps
363}
364
365/// Whether a provisioning step's file has this extension, in any case (`setup.SH` is a shell
366/// script): how the plan and the backends tell a step's kind.
367#[must_use]
368pub fn has_extension(path: &str, extension: &str) -> bool {
369 std::path::Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case(extension))
370}
371
372/// Whether a machine is a Windows VM.
373fn windows(m: &Machine) -> bool {
374 m.vm.as_ref().is_some_and(|vm| isoloom_spec::images::is_windows(&vm.os))
375}
376
377#[cfg(test)]
378mod tests;