Skip to main content

isoloom_plan/
requirements.rs

1//! What an environment asks of a backend, and what a backend offers. A backend refuses an
2//! environment when it lacks the capability one of its requirements needs: the refusal comes
3//! from the plan, without generating anything.
4
5use std::collections::BTreeSet;
6use std::fmt;
7
8use indexmap::IndexMap;
9use isoloom_spec::model::Spec;
10use isoloom_spec::windows::Tpm;
11
12use crate::checks::{self, Position, Probe};
13use crate::environment::{MachinePlan, MachineShape};
14use crate::hardware::Need;
15
16/// A capability a backend may have: the kind of a [`Requirement`].
17#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
18#[non_exhaustive]
19pub enum Feature {
20    /// Environment-level provisioning (`provision:`), run by a controller.
21    EnvironmentProvisioning,
22    /// Checks that are Ansible playbooks (run from a controller).
23    PlaybookChecks,
24    /// `exec` checks from a position with no container of its own.
25    ExecWithoutContainer,
26    /// Network appliances (`docker.appliance`), wired the way Docker attaches networks.
27    NetworkAppliances,
28    /// Networks routed by a machine of the environment (a network's `gateway`).
29    Gateways,
30    /// One shared volume mounted by containers and VMs alike.
31    VolumesAcrossShapes,
32    /// A VM that waits for a container (`depends_on`).
33    VmAfterContainer,
34    /// Extra names for a machine (`aliases`), dotted ones included.
35    MachineAliases,
36    /// Per-container process limits (`ulimits`).
37    Ulimits,
38    /// Link impairment (`tc`), applied on the path into a network.
39    TrafficControl,
40    /// Dual-stack networks (`ipv6`), with addresses derived from the octets.
41    Ipv6,
42    /// IPv6-only networks (`ipv6.only`).
43    Ipv6Only,
44    /// IPv6 addresses the target hands out (`ipv6.addresses: auto`).
45    Ipv6Auto,
46    /// Secrets delivered as files when the machines start (`machines.*.secrets`).
47    Secrets,
48    /// A secret file owned by a given user or group (`uid`, `gid`).
49    SecretOwners,
50    /// Joining an existing network by name (`external`), which the target never makes.
51    ExternalNetworks,
52    /// Bridging a network to an interface of the host (`bridged`).
53    BridgedNetworks,
54    /// A container's file from the project with its own owner or mode (see [`crate::machine_files`]).
55    OwnedProjectFiles,
56    /// Files written on a host that already exists.
57    ExistingHostFiles,
58    /// A virtual TPM 2.0 in a VM.
59    Tpm,
60    /// A process Isoloom starts on the machine: its own `command` (with its `cwd`) or
61    /// `healthcheck`.
62    MachineProcess,
63    /// A VM booting its own way (`vm.boot`): a kernel, firmware, boot order or network boot.
64    OwnBoot,
65    /// GPUs for a container (`gpus`), from the host its runtime runs on.
66    ContainerGpus,
67    /// The host's devices for a container (`devices`).
68    ContainerDevices,
69    /// VMs inside a container (`nested_virtualization`): no backend has it.
70    ContainerNestedVirtualization,
71    /// GPUs for a VM (`gpus`): passed through, or sized in.
72    VmGpus,
73    /// The host's devices for a VM (`devices`): no backend has it.
74    VmDevices,
75    /// VMs inside a VM (`nested_virtualization`).
76    NestedVirtualization,
77    /// A network's own MTU (`mtu`).
78    NetworkMtu,
79    /// A bandwidth cap on a machine's links (`rate_limit`).
80    RateLimit,
81    /// Names served under the environment's private DNS zone (`dns.private`).
82    PrivateDns,
83    /// Volumes kept past `down` (`persistent`).
84    PersistentVolumes,
85    /// Volumes that exist already (`external`), mounted by name.
86    ExternalVolumes,
87    /// Data disks beside a machine's own (`disks`).
88    DataDisks,
89    /// Data disks left unformatted, as block devices (`fs: raw`).
90    RawDisks,
91    /// A mode on a memory-backed mount (`tmpfs[].mode`).
92    TmpfsModes,
93    /// Several addresses on one network for a VM (`lan: [10, 11]`), added by its set-up.
94    VmMoreAddresses,
95    /// Several addresses on one network for a container or an existing host: no backend has it.
96    ContainerMoreAddresses,
97}
98
99/// One thing the environment needs from the backend that renders it, with what in the spec
100/// asks for it.
101#[derive(Debug, Clone, PartialEq, Eq)]
102#[non_exhaustive]
103pub enum Requirement {
104    /// The spec has `provision:` steps.
105    EnvironmentProvisioning,
106    /// A check is an Ansible playbook.
107    PlaybookChecks,
108    /// An `exec` check stands where no container of the environment runs.
109    ExecWithoutContainer {
110        /// The check's name.
111        check: String,
112    },
113    /// A machine is a network appliance.
114    NetworkAppliance {
115        /// The machine.
116        machine: String,
117    },
118    /// A network is routed by one of the environment's machines.
119    Gateway {
120        /// The network.
121        network: String,
122    },
123    /// A shared volume is mounted by a container and by a VM.
124    VolumeAcrossShapes {
125        /// The volume.
126        volume: String,
127    },
128    /// A VM waits for a container.
129    VmDependsOnContainer {
130        /// The VM.
131        vm: String,
132        /// The container it waits for.
133        container: String,
134    },
135    /// A machine has `aliases`.
136    MachineAliases,
137    /// A machine sets `ulimits`.
138    Ulimits {
139        /// The first machine that does.
140        machine: String,
141    },
142    /// A network has `tc`.
143    TrafficControl,
144    /// A network has an IPv6 block.
145    Ipv6 {
146        /// The first such network.
147        network: String,
148    },
149    /// A network is IPv6-only.
150    Ipv6Only {
151        /// The first such network.
152        network: String,
153    },
154    /// A network leaves its IPv6 addresses to the target.
155    Ipv6Auto {
156        /// The first such network.
157        network: String,
158    },
159    /// A machine gets secrets.
160    Secrets {
161        /// The first machine that does.
162        machine: String,
163    },
164    /// A machine's secret sets its owner (`uid` or `gid`).
165    SecretOwner {
166        /// The first machine whose secret does.
167        machine: String,
168    },
169    /// A network is an existing one, joined by name.
170    ExternalNetwork {
171        /// The network.
172        network: String,
173        /// Its name on the target.
174        name: String,
175    },
176    /// A network is bridged to an interface of the host.
177    BridgedNetwork {
178        /// The network.
179        network: String,
180    },
181    /// A container's file comes from the project with a `uid`, `gid` or `mode`.
182    OwnedProjectFile {
183        /// The machine.
184        machine: String,
185        /// Where the file goes in it.
186        path: String,
187    },
188    /// An existing host has `files`.
189    ExistingHostFiles {
190        /// The machine.
191        machine: String,
192    },
193    /// A VM asks for a virtual TPM (`hardware.tpm: required`).
194    Tpm {
195        /// The first machine that does.
196        machine: String,
197    },
198    /// A machine sets its own process (`command`, `healthcheck`).
199    MachineProcess {
200        /// The first machine that does.
201        machine: String,
202    },
203    /// A VM sets `vm.boot`.
204    OwnBoot {
205        /// The first VM that does.
206        machine: String,
207    },
208    /// A machine asks for hardware (`gpus`, `devices`, `nested_virtualization`).
209    Hardware {
210        /// The machine.
211        machine: String,
212        /// What it asks for, in its shape on the target.
213        need: Need,
214    },
215    /// A network sets its `mtu`.
216    NetworkMtu {
217        /// The first such network.
218        network: String,
219    },
220    /// A machine sets a `rate_limit`.
221    RateLimit {
222        /// The first such machine.
223        machine: String,
224    },
225    /// The environment's names are served under a private DNS zone.
226    PrivateDns {
227        /// The zone's domain.
228        domain: String,
229    },
230    /// A volume is `persistent`.
231    PersistentVolume {
232        /// The first such volume.
233        volume: String,
234    },
235    /// A volume is `external`.
236    ExternalVolume {
237        /// The first such volume.
238        volume: String,
239    },
240    /// A machine has `disks`.
241    DataDisks {
242        /// The first machine that does.
243        machine: String,
244    },
245    /// A machine has a disk with `fs: raw`.
246    RawDisks {
247        /// The first machine that does.
248        machine: String,
249    },
250    /// A machine's tmpfs mount has a `mode`.
251    TmpfsModes {
252        /// The first machine that does.
253        machine: String,
254    },
255    /// A machine has several addresses on one network (`lan: [10, 11]`).
256    MoreAddresses {
257        /// The first machine that does.
258        machine: String,
259        /// Its first network with several.
260        network: String,
261        /// How the target produces it.
262        shape: MachineShape,
263    },
264}
265
266impl Requirement {
267    /// The capability this requirement needs.
268    #[must_use]
269    pub fn feature(&self) -> Feature {
270        match self {
271            Requirement::EnvironmentProvisioning => Feature::EnvironmentProvisioning,
272            Requirement::PlaybookChecks => Feature::PlaybookChecks,
273            Requirement::ExecWithoutContainer { .. } => Feature::ExecWithoutContainer,
274            Requirement::NetworkAppliance { .. } => Feature::NetworkAppliances,
275            Requirement::Gateway { .. } => Feature::Gateways,
276            Requirement::VolumeAcrossShapes { .. } => Feature::VolumesAcrossShapes,
277            Requirement::VmDependsOnContainer { .. } => Feature::VmAfterContainer,
278            Requirement::MachineAliases => Feature::MachineAliases,
279            Requirement::Ulimits { .. } => Feature::Ulimits,
280            Requirement::TrafficControl => Feature::TrafficControl,
281            Requirement::Ipv6 { .. } => Feature::Ipv6,
282            Requirement::Ipv6Only { .. } => Feature::Ipv6Only,
283            Requirement::Ipv6Auto { .. } => Feature::Ipv6Auto,
284            Requirement::Secrets { .. } => Feature::Secrets,
285            Requirement::SecretOwner { .. } => Feature::SecretOwners,
286            Requirement::ExternalNetwork { .. } => Feature::ExternalNetworks,
287            Requirement::BridgedNetwork { .. } => Feature::BridgedNetworks,
288            Requirement::OwnedProjectFile { .. } => Feature::OwnedProjectFiles,
289            Requirement::ExistingHostFiles { .. } => Feature::ExistingHostFiles,
290            Requirement::Tpm { .. } => Feature::Tpm,
291            Requirement::MachineProcess { .. } => Feature::MachineProcess,
292            Requirement::OwnBoot { .. } => Feature::OwnBoot,
293            Requirement::Hardware { need, .. } => need.feature(),
294            Requirement::NetworkMtu { .. } => Feature::NetworkMtu,
295            Requirement::RateLimit { .. } => Feature::RateLimit,
296            Requirement::PrivateDns { .. } => Feature::PrivateDns,
297            Requirement::PersistentVolume { .. } => Feature::PersistentVolumes,
298            Requirement::ExternalVolume { .. } => Feature::ExternalVolumes,
299            Requirement::DataDisks { .. } => Feature::DataDisks,
300            Requirement::RawDisks { .. } => Feature::RawDisks,
301            Requirement::TmpfsModes { .. } => Feature::TmpfsModes,
302            Requirement::MoreAddresses { shape: MachineShape::Vm, .. } => Feature::VmMoreAddresses,
303            Requirement::MoreAddresses { .. } => Feature::ContainerMoreAddresses,
304        }
305    }
306}
307
308/// Why a target without [`Feature::MachineProcess`] refuses a machine's own process: only
309/// `external` lacks it, and its hosts already run what they run.
310const OWN_PROCESS: &str = "sets its own process (`command` or `healthcheck`): an existing host runs what it already runs, Isoloom starts nothing on it";
311
312/// Why an `exec` check needs a container where it stands.
313const IN_A_CONTAINER: &str = "needs `from:` a machine with its own container (it runs inside it)";
314
315impl fmt::Display for Requirement {
316    /// Why a backend without the capability refuses the environment, in the user's terms.
317    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
318        match self {
319            Requirement::EnvironmentProvisioning => f.write_str("environment-level provisioning (`provision:`) runs on VM targets for now"),
320            Requirement::PlaybookChecks => f.write_str("Ansible checks (.yml) run on VM targets for now"),
321            Requirement::ExecWithoutContainer { check } => write!(f, "`exec` check \"{check}\" {IN_A_CONTAINER}"),
322            Requirement::NetworkAppliance { machine } => write!(
323                f,
324                "machine `{machine}` is a network appliance (`app.appliance`), which runs on the Docker targets"
325            ),
326            Requirement::Gateway { .. } => f.write_str("networks with a `gateway` machine in hybrid environments come later"),
327            Requirement::VolumeAcrossShapes { volume } => write!(
328                f,
329                "volume `{volume}` is shared by containers and VMs: one storage can't be both a Docker volume and an NFS export, for now"
330            ),
331            Requirement::VmDependsOnContainer { vm, container } => write!(
332                f,
333                "VM `{vm}` depends on container `{container}`: VMs start first in a hybrid environment, for now"
334            ),
335            // Only Kubernetes lacks these today: the messages say so in its terms.
336            Requirement::MachineAliases => f.write_str("machine `aliases` (dotted DNS names) aren't Kubernetes Service names"),
337            Requirement::Ulimits { machine } => write!(f, "machine `{machine}` sets `ulimits`: Kubernetes has no per-container process limits"),
338            Requirement::TrafficControl => f.write_str("link impairment (`tc`) needs a router in the path; Kubernetes has none"),
339            Requirement::Ipv6 { network } => write!(f, "network `{network}` is IPv6 (`ipv6`), which this target's networks don't carry yet"),
340            Requirement::Ipv6Only { network } => crate::ipv6::no_ipv6_only(f, network),
341            Requirement::Ipv6Auto { network } => crate::ipv6::no_auto_addresses(f, network),
342            Requirement::Secrets { machine } => crate::secrets::no_delivery(f, machine),
343            Requirement::SecretOwner { machine } => crate::secrets::no_owner(f, machine),
344            Requirement::ExternalNetwork { network, name } => crate::outside::no_existing_network(f, network, name),
345            Requirement::BridgedNetwork { network } => crate::outside::no_bridge(f, network),
346            Requirement::OwnedProjectFile { machine, path } => crate::machine_files::no_owned_project_file(f, machine, path),
347            Requirement::ExistingHostFiles { machine } => crate::machine_files::no_existing_host_files(f, machine),
348            Requirement::Tpm { machine } => no_tpm(f, machine),
349            Requirement::MachineProcess { machine } => write!(f, "machine `{machine}` {OWN_PROCESS}"),
350            Requirement::OwnBoot { machine } => crate::boot::no_own_boot(f, machine),
351            Requirement::Hardware { machine, need } => need.reason(machine, f),
352            Requirement::NetworkMtu { network } => crate::links::no_mtu(f, network),
353            Requirement::RateLimit { machine } => crate::links::no_rate_limit(f, machine),
354            // Only Kubernetes and existing hosts lack it: a cluster names Services under its own
355            // domain, and Isoloom leaves an existing host's resolver and hosts file as they are.
356            Requirement::PrivateDns { domain } => write!(
357                f,
358                "the names are served under a private zone (`dns.private`, `{domain}`), and this target has no DNS or hosts file Isoloom sets: a cluster names Services under its own domain, an existing host keeps its own resolver"
359            ),
360            Requirement::MoreAddresses { machine, network, shape } => crate::more_addresses::refused(f, machine, network, *shape),
361            Requirement::PersistentVolume { volume } => crate::storage::no_persistent_volume(f, volume),
362            Requirement::ExternalVolume { volume } => crate::storage::no_existing_volume(f, volume),
363            Requirement::DataDisks { machine } => crate::storage::no_data_disks(f, machine),
364            Requirement::RawDisks { machine } => crate::storage::no_raw_disks(f, machine),
365            Requirement::TmpfsModes { machine } => crate::storage::no_tmpfs_modes(f, machine),
366        }
367    }
368}
369
370/// Why a target without a virtual TPM refuses a machine that requires one.
371fn no_tpm(f: &mut fmt::Formatter<'_>, machine: &str) -> fmt::Result {
372    write!(
373        f,
374        "machine `{machine}` asks for a virtual TPM (`hardware.tpm: required`), which this target doesn't give (Vagrant on VirtualBox, libvirt or Hyper-V, Proxmox and the big clouds do)"
375    )
376}
377
378/// What a backend can render.
379#[derive(Debug, Clone, Default, PartialEq, Eq)]
380pub struct Capabilities {
381    features: BTreeSet<Feature>,
382}
383
384impl Capabilities {
385    /// A backend with these capabilities.
386    pub fn of(features: impl IntoIterator<Item = Feature>) -> Capabilities {
387        Capabilities {
388            features: features.into_iter().collect(),
389        }
390    }
391
392    /// Whether the backend has `feature`.
393    #[must_use]
394    pub fn has(&self, feature: Feature) -> bool {
395        self.features.contains(&feature)
396    }
397}
398
399/// Why a backend won't render an environment: the first requirement it can't meet.
400#[derive(Debug, Clone, PartialEq, Eq)]
401pub struct Refusal {
402    /// The requirement the backend lacks the capability for.
403    pub requirement: Requirement,
404}
405
406impl fmt::Display for Refusal {
407    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
408        self.requirement.fmt(f)
409    }
410}
411
412/// The first of `requirements` that `caps` can't meet, in the order the plan lists them.
413#[must_use]
414pub fn refusal(requirements: &[Requirement], caps: &Capabilities) -> Option<Refusal> {
415    requirements.iter().find(|r| !caps.has(r.feature())).map(|r| Refusal { requirement: r.clone() })
416}
417
418/// Everything `spec` requires, most fundamental first.
419#[must_use]
420pub fn of(spec: &Spec) -> Vec<Requirement> {
421    let mut out = Vec::new();
422    if !spec.provision.is_empty() {
423        out.push(Requirement::EnvironmentProvisioning);
424    }
425    out.extend(container_checks(spec));
426    out.extend(
427        spec.machines
428            .iter()
429            .filter(|(_, m)| m.docker.as_ref().is_some_and(|d| d.appliance.is_some()))
430            .map(|(name, _)| Requirement::NetworkAppliance { machine: name.clone() }),
431    );
432    if spec.machines.values().any(|m| !m.aliases.is_empty()) {
433        out.push(Requirement::MachineAliases);
434    }
435    out.extend(
436        spec.networks
437            .iter()
438            .filter(|(_, n)| n.gateway.is_some())
439            .map(|(name, _)| Requirement::Gateway { network: name.clone() }),
440    );
441    out.extend(limits(spec));
442    out.extend(crate::ipv6::requirements(spec));
443    out.extend(crate::links::requirements(spec));
444    out.extend(crate::private_dns::requirements(spec));
445    out.extend(crate::storage::requirements(spec));
446    out
447}
448
449/// What the containers' limits, the links' impairment and the machines' own processes ask for:
450/// at most one requirement of each kind, the first machine or network that asks.
451fn limits(spec: &Spec) -> Vec<Requirement> {
452    let mut out = Vec::new();
453    if let Some((name, _)) = spec.machines.iter().find(|(_, m)| !m.ulimits.is_empty()) {
454        out.push(Requirement::Ulimits { machine: name.clone() });
455    }
456    if spec.networks.values().any(|n| n.tc.is_some()) {
457        out.push(Requirement::TrafficControl);
458    }
459    if let Some((name, _)) = spec.machines.iter().find(|(_, m)| !crate::process::Process::of(m).is_empty()) {
460        out.push(Requirement::MachineProcess { machine: name.clone() });
461    }
462    out.extend(secrets(spec));
463    out
464}
465
466/// What the machines' secrets ask for: delivering them at all, then owning their files.
467fn secrets(spec: &Spec) -> Vec<Requirement> {
468    let mut out = Vec::new();
469    if let Some((name, _)) = spec.machines.iter().find(|(_, m)| !m.secrets.is_empty()) {
470        out.push(Requirement::Secrets { machine: name.clone() });
471    }
472    let owned = |m: &isoloom_spec::Machine| m.secrets.iter().any(|s| s.mount().uid.is_some() || s.mount().gid.is_some());
473    if let Some((name, _)) = spec.machines.iter().find(|(_, m)| owned(m)) {
474        out.push(Requirement::SecretOwner { machine: name.clone() });
475    }
476    out
477}
478
479/// What mixing containers and VMs in one environment asks for: a shared volume mounted by both,
480/// a VM waiting for a container. `shape` is each machine's shape on the target; only containers
481/// and VMs count (an external or supplied machine is neither), so nothing comes out unless the
482/// target runs both.
483pub(crate) fn mixed_shapes(spec: &Spec, shape: impl Fn(&str) -> Option<MachineShape>) -> Vec<Requirement> {
484    let is = |name: &str, s: MachineShape| shape(name) == Some(s);
485    let volumes = spec.volumes.keys().filter(|v| {
486        let users = spec.volume_users(v);
487        users.iter().any(|u| is(u, MachineShape::Container)) && users.iter().any(|u| is(u, MachineShape::Vm))
488    });
489    let mut out: Vec<Requirement> = volumes.map(|v| Requirement::VolumeAcrossShapes { volume: v.clone() }).collect();
490    for (name, m) in spec.machines.iter().filter(|(n, _)| is(n, MachineShape::Vm)) {
491        if let Some(dep) = m.depends_on.iter().find(|d| is(d, MachineShape::Container)) {
492            out.push(Requirement::VmDependsOnContainer {
493                vm: name.clone(),
494                container: dep.clone(),
495            });
496        }
497    }
498    out
499}
500
501/// The first VM that requires a TPM (`vm.tpm: true`); one Windows 11 merely expects is given
502/// where the target can, so it requires nothing.
503#[must_use]
504pub fn tpm(machines: &IndexMap<String, MachinePlan>) -> Option<Requirement> {
505    machines
506        .iter()
507        .find(|(_, m)| m.shape == MachineShape::Vm && m.tpm == Some(Tpm::Required))
508        .map(|(name, _)| Requirement::Tpm { machine: name.clone() })
509}
510
511/// The checks' requirements beyond probing from a container: playbooks, and `exec` checks that
512/// would have no container to run in. At most one of them: the first, as a container output
513/// reports it.
514pub(crate) fn container_checks(spec: &Spec) -> Option<Requirement> {
515    if spec.checks.iter().any(isoloom_spec::Check::is_playbook) {
516        return Some(Requirement::PlaybookChecks);
517    }
518    checks::plan(spec)
519        .into_iter()
520        .filter(|c| matches!(c.probe, Probe::Exec { .. }))
521        .find(|c| match &c.position {
522            Position::Machine(m) => !spec.machines.get(m).is_some_and(|m| m.docker.is_some() && !m.supplied),
523            Position::Networks => true,
524        })
525        .map(|c| Requirement::ExecWithoutContainer { check: c.name })
526}
527
528mod feature;
529#[cfg(test)]
530mod tests;