Skip to main content

isoloom_plan/
environment.rs

1//! The environment as one target builds it: every fact a backend needs, resolved once from a
2//! validated spec (addresses, names, routes, published ports, volumes, settings, images, start
3//! order, checks, requirements), so backends render the same decisions instead of each
4//! re-deriving them from the spec.
5//!
6//! The plan is typed and target-aware (on the Docker targets, networks sit on their Docker
7//! blocks); a backend reads it and writes its own format. What the plan doesn't model yet
8//! (appliance wiring, trunk scripts) a backend still reads from [`Environment::spec`], the spec
9//! as laid out for the target.
10
11use std::net::{Ipv4Addr, Ipv6Addr};
12
13use indexmap::IndexMap;
14use isoloom_spec::images;
15use isoloom_spec::model::{Arch, Boot, Constraints, External, Machine, MachineFile, Network, Protocol, Spec, Target, Tc, Ulimit, VmImage, Winrm};
16use isoloom_spec::validate::docker_cidrs;
17use isoloom_spec::windows::{self, Tpm};
18
19mod image;
20
21pub use image::{Build, Image};
22
23use crate::addressing::{address, address_for, host_address, start_order};
24use crate::ansible;
25use crate::checks::{self, Resolved};
26use crate::configure::{DaemonTable, Recipe};
27use crate::hardware::{self, Hardware};
28use crate::images::Images;
29use crate::ipv6::{self, Ipv6Plan, Route6};
30use crate::lifecycle::{self, MachineLifecycle};
31use crate::outside::{self, OutsideAddress, OutsideNetwork};
32use crate::process::Process;
33use crate::requirements::{self, Requirement};
34use crate::routing;
35use crate::secrets::SecretFile;
36use crate::storage::{self, DataDisk, Lifetime};
37
38/// One environment, planned for one target.
39#[derive(Debug, Clone, PartialEq)]
40pub struct Environment {
41    /// The environment's name (Compose project, resource prefix).
42    pub name: String,
43    /// The target it is planned for.
44    pub target: Target,
45    /// The instance number, when several copies of the environment run side by side.
46    pub instance: Option<u8>,
47    /// Its networks, in spec order.
48    pub networks: IndexMap<String, NetworkPlan>,
49    /// The networks outside the environment (`external`, `bridged`), in spec order: never in
50    /// [`Environment::networks`] nor in the spec the backends read.
51    pub outside: IndexMap<String, OutsideNetwork>,
52    /// Isoloom's router, when `reach` rules need one: its address on each network it routes.
53    pub router: Option<IndexMap<String, Ipv4Addr>>,
54    /// The router's IPv6 address on each IPv6 network it routes (empty without a router).
55    pub router6: IndexMap<String, Ipv6Addr>,
56    /// Its machines, in spec order.
57    pub machines: IndexMap<String, MachinePlan>,
58    /// Machine names, each after the machines it waits for.
59    pub start_order: Vec<String>,
60    /// The checks, resolved to positions and probes.
61    pub checks: Vec<Resolved>,
62    /// The controller VM running playbooks, when the environment needs one.
63    pub controller: Option<Controller>,
64    /// The spec's constraints (proxy, mirrors, registries, NTP), when it has any.
65    pub constraints: Option<Constraints>,
66    /// What the environment asks of the backend rendering it.
67    pub requirements: Vec<Requirement>,
68    /// The image references to pull, through the registry mirrors.
69    pub images: Images,
70    /// The Ansible inventory groups and their members (see [`ansible::groups`]).
71    pub inventory_groups: IndexMap<String, Vec<String>>,
72    /// The private DNS zone, when `dns.private` is on: its domain and records.
73    pub private_dns: Option<crate::private_dns::PrivateDns>,
74    spec: Spec,
75}
76
77/// A network, as the target lays it out.
78#[derive(Debug, Clone, PartialEq)]
79pub struct NetworkPlan {
80    /// Its block on this target (the Docker block on the Docker targets).
81    pub cidr: String,
82    /// Its block as the author wrote it.
83    pub declared_cidr: String,
84    /// The host's own address on it (Docker's bridge, the hypervisor).
85    pub host: Ipv4Addr,
86    /// A machine of the environment that routes it, if any.
87    pub gateway: Option<String>,
88    /// Whether its machines may reach the internet.
89    pub internet: bool,
90    /// Link impairment on traffic entering it.
91    pub tc: Option<Tc>,
92    /// The LAN and VLAN id it is, when it is a LAN's VLAN.
93    pub vlan: Option<(String, u16)>,
94    /// Its IPv6 side, when it is dual-stack or IPv6-only.
95    pub ipv6: Option<Ipv6Plan>,
96    /// The largest packet it carries, when the spec sets one (`mtu`).
97    pub mtu: Option<u16>,
98}
99
100/// How a machine is produced on the target.
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum MachineShape {
103    /// A container.
104    Container,
105    /// A virtual machine.
106    Vm,
107    /// An access machine the runner supplies (nothing to build).
108    Supplied,
109    /// An existing machine, reached as it is (every machine on the `external` target).
110    External,
111}
112
113/// One machine of the environment.
114#[derive(Debug, Clone, PartialEq)]
115pub struct MachinePlan {
116    /// How the target produces it.
117    pub shape: MachineShape,
118    /// Its address on each of its networks, in its order (the first is its primary network).
119    pub addresses: IndexMap<String, Ipv4Addr>,
120    /// Its addresses after the primary one on each network where it has several (`lan: [10,
121    /// 11]`), in its networks' order.
122    pub more_addresses: IndexMap<String, Vec<Ipv4Addr>>,
123    /// Its IPv6 address on each IPv6 network that derives them (not on `addresses: auto` ones).
124    pub addresses6: IndexMap<String, Ipv6Addr>,
125    /// Its names: its own, then its aliases, then (with `dns.private`) each of them under the
126    /// private zone's domain.
127    pub names: Vec<String>,
128    /// Routes to the networks it isn't on.
129    pub routes: Vec<Route>,
130    /// IPv6 routes to the IPv6 blocks it isn't on, through the router.
131    pub routes6: Vec<Route6>,
132    /// Its default route through a gateway machine, if one routes one of its networks.
133    pub default_gateway: Option<Ipv4Addr>,
134    /// Whether it is the gateway of one of its networks (it forwards).
135    pub is_gateway: bool,
136    /// Whether nothing it is on reaches the internet and no gateway decides for it.
137    pub offline: bool,
138    /// The ports it publishes on the host.
139    pub ports: Vec<PublishedPort>,
140    /// Its volumes, in spec order.
141    pub volumes: Vec<Volume>,
142    /// The files placed on it, in spec order.
143    pub files: Vec<MachineFile>,
144    /// Its data disks, in spec order.
145    pub disks: Vec<DataDisk>,
146    /// The settings a container gets from its runtime and a VM from its set-up.
147    pub settings: Settings,
148    /// The inputs it reads (environment variables given at run time).
149    pub inputs: Vec<String>,
150    /// The secrets it gets as files, delivered at run time (never their values).
151    pub secrets: Vec<SecretFile>,
152    /// Its size.
153    pub size: Size,
154    /// Its CPU architecture.
155    pub arch: Arch,
156    /// The bandwidth cap on each of its links (`rate_limit`, a `tc` rate such as `10mbit`).
157    pub rate_limit: Option<String>,
158    /// What it runs from, on this target.
159    pub image: Option<Image>,
160    /// The VM's own image per target (`vm.image`: a box URL, a Proxmox or cloud image), when
161    /// the spec gives one.
162    pub vm_image: Option<VmImage>,
163    /// How the VM boots, when not the way its image does (`vm.boot`).
164    pub boot: Option<Boot>,
165    /// The machines it waits for, as the spec lists them.
166    pub depends_on: Vec<String>,
167    /// The set-up steps of a VM, in order (empty for the other shapes).
168    pub lifecycle: MachineLifecycle,
169    /// The OS of its VM implementation (`debian-12`, `windows-server-2022`...), whatever the
170    /// target: an existing host keeps the OS its `vm:` names.
171    pub os: Option<String>,
172    /// How its Windows VM answers WinRM (`None` off Windows, and on a box reached over OpenSSH).
173    pub winrm: Option<Winrm>,
174    /// Whether its Windows VM's box is reached over OpenSSH instead (`vm.image.winssh`).
175    pub winssh: bool,
176    /// Its virtual TPM 2.0, when it gets one (`vm.tpm`, or Windows 11's own).
177    pub tpm: Option<Tpm>,
178    /// Its SSH endpoint as an existing host (`external:`), when the spec gives one.
179    pub external: Option<External>,
180    /// Its variables from the `provision:` steps' `host_vars`, merged in step order.
181    pub host_vars: IndexMap<String, serde_json::Value>,
182    /// Its addresses on the outside networks it joins, after its own networks.
183    pub outside: IndexMap<String, OutsideAddress>,
184    /// Its own command, working directory, `before` steps and healthcheck.
185    pub process: Process,
186    /// The GPUs, host devices and nested virtualization it asks for.
187    pub hardware: Hardware,
188    /// Its configuration resolved (an app's steps), which its container's build or its VM's
189    /// set-up renders: the one source every CM renderer reads.
190    pub recipe: Option<Recipe>,
191}
192
193impl MachinePlan {
194    /// Whether it runs Windows.
195    pub fn is_windows(&self) -> bool {
196        self.os.as_deref().is_some_and(images::is_windows)
197    }
198}
199
200/// A route to a network a machine isn't on.
201#[derive(Debug, Clone, PartialEq, Eq)]
202pub struct Route {
203    /// The network's block.
204    pub cidr: String,
205    /// The next hop.
206    pub via: Ipv4Addr,
207}
208
209/// A port published on the host.
210#[derive(Debug, Clone, PartialEq, Eq)]
211pub struct PublishedPort {
212    /// The port's name: how labels, outputs and the message refer to it.
213    pub name: String,
214    /// The machine's port.
215    pub port: u16,
216    /// The host port asked for.
217    pub publish: u16,
218    /// TCP or UDP.
219    pub protocol: Protocol,
220    /// Whether the host port is always `publish` (else one the host picks, unless pinned).
221    pub fixed: bool,
222}
223
224/// A machine's volume.
225#[derive(Debug, Clone, PartialEq, Eq)]
226pub struct Volume {
227    /// The volume's name in the spec.
228    pub name: String,
229    /// Where the machine mounts it.
230    pub path: String,
231    /// The folder of a shared volume it takes, if not all of it.
232    pub subpath: Option<String>,
233    /// The name the runtime knows it by (a Compose named volume).
234    pub key: String,
235    /// How long its storage lives.
236    pub lifetime: Lifetime,
237    /// Whether the machine keeps it to itself or shares it.
238    pub kind: VolumeKind,
239}
240
241/// Who a volume belongs to.
242#[derive(Debug, Clone, PartialEq, Eq)]
243pub enum VolumeKind {
244    /// The machine's own.
245    Own,
246    /// One of the spec's shared `volumes:`: `owner` serves it to the other `users`.
247    Shared {
248        /// The machine that holds the data.
249        owner: String,
250        /// Every machine that mounts it, the owner included.
251        users: Vec<String>,
252    },
253}
254
255/// The settings a container gets from its runtime: environment, sysctls, ulimits.
256#[derive(Debug, Clone, Default, PartialEq, Eq)]
257pub struct Settings {
258    /// Fixed environment variables.
259    pub environment: IndexMap<String, String>,
260    /// Kernel parameters.
261    pub sysctls: IndexMap<String, String>,
262    /// Resource limits.
263    pub ulimits: IndexMap<String, Ulimit>,
264}
265
266impl Settings {
267    /// Whether there is nothing to set.
268    #[must_use]
269    pub fn is_empty(&self) -> bool {
270        self.environment.is_empty() && self.sysctls.is_empty() && self.ulimits.is_empty()
271    }
272}
273
274/// A machine's or the controller's size; `None` leaves the target's default.
275#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
276pub struct Size {
277    /// CPUs.
278    pub cpus: Option<u32>,
279    /// Memory, in MB.
280    pub memory_mb: Option<u32>,
281    /// Disk, in GB.
282    pub disk_gb: Option<u32>,
283}
284
285/// The controller VM, which runs the environment's playbooks and Ansible checks: its size.
286#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
287#[non_exhaustive]
288pub struct Controller {
289    /// CPUs: [`Controller::CPUS`] unless the spec says.
290    pub cpus: u32,
291    /// Memory in MB: [`Controller::MEMORY_MB`] unless the spec says.
292    pub memory_mb: u32,
293    /// Disk in GB, when the spec sets one (else its image's own).
294    pub disk_gb: Option<u32>,
295}
296
297impl Controller {
298    /// Its CPUs when the spec leaves them out.
299    pub const CPUS: u32 = 1;
300    /// Its memory in MB when the spec leaves it out: Python, ansible-core and pywinrm fit.
301    pub const MEMORY_MB: u32 = 512;
302
303    /// The controller of `spec` (`controller.resources`, else the defaults), whether or not
304    /// the environment needs one.
305    #[must_use]
306    pub fn of(spec: &Spec) -> Controller {
307        let r = spec.controller.as_ref().and_then(|c| c.resources.as_ref());
308        Controller {
309            cpus: r.and_then(|r| r.cpus).unwrap_or(Controller::CPUS),
310            memory_mb: r.and_then(|r| r.memory_mb).unwrap_or(Controller::MEMORY_MB),
311            disk_gb: r.and_then(|r| r.disk_gb),
312        }
313    }
314
315    /// Whether `spec` needs a controller: it has `provision:` steps or Ansible checks.
316    #[must_use]
317    pub fn needed(spec: &Spec) -> bool {
318        !spec.provision.is_empty() || spec.checks.iter().any(isoloom_spec::Check::is_playbook)
319    }
320}
321
322impl Environment {
323    /// Plans a validated `spec` for `target` (`instance`: the copy it is, when several run).
324    #[must_use]
325    pub fn build(spec: &Spec, target: Target, instance: Option<u8>) -> Environment {
326        // Laid out with the outside networks (so a moved block avoids them), then without them.
327        let laid_out = outside::without(&lay_out(spec, target));
328        let machines = machines(spec, &laid_out, target);
329        let requirements = all_requirements(spec, &laid_out, &machines);
330        let networks = laid_out
331            .networks
332            .iter()
333            .map(|(name, n)| {
334                let declared = spec.networks.get(name).map_or(&n.cidr, |o| &o.cidr);
335                (name.clone(), network(&laid_out, name, n, declared))
336            })
337            .collect();
338        Environment {
339            name: laid_out.name.clone(),
340            target,
341            instance,
342            networks,
343            outside: outside::networks(spec),
344            router: routing::needed(&laid_out).then(|| routing::networks(&laid_out).map(|n| (n.clone(), routing::address(&laid_out, n))).collect()),
345            router6: ipv6::router(&laid_out),
346            machines,
347            start_order: start_order(&laid_out).into_iter().map(String::from).collect(),
348            checks: checks::plan(&laid_out),
349            controller: Controller::needed(&laid_out).then(|| Controller::of(&laid_out)),
350            constraints: laid_out.constraints.clone(),
351            requirements,
352            images: Images::of(&laid_out),
353            inventory_groups: ansible::groups(&laid_out),
354            private_dns: crate::private_dns::plan(&laid_out),
355            spec: laid_out,
356        }
357    }
358
359    /// The spec as laid out for the target, for what the plan doesn't model yet.
360    #[must_use]
361    pub fn spec(&self) -> &Spec {
362        &self.spec
363    }
364
365    /// The address `from` reaches `to` at: on a network they share, else `to`'s first.
366    #[must_use]
367    pub fn address_for(&self, from: &str, to: &str) -> Ipv4Addr {
368        address_for(&self.spec, from, to)
369    }
370
371    /// Where a probe reaches machine `name` on `network`: its IPv4 address, or on an IPv6-only
372    /// network its IPv6 one. Its name, which the target's DNS resolves, when it has no address
373    /// there: on an IPv6-only network that leaves addresses to the target (or a machine or a
374    /// network the plan doesn't hold, which no check of the plan names).
375    #[must_use]
376    pub fn probe_address(&self, name: &str, network: &str) -> String {
377        let m = self.machines.get(name);
378        let address = if ipv6::only(&self.spec, network) {
379            m.and_then(|m| m.addresses6.get(network)).map(ToString::to_string)
380        } else {
381            m.and_then(|m| m.addresses.get(network)).map(ToString::to_string)
382        };
383        address.unwrap_or_else(|| name.to_owned())
384    }
385}
386
387/// Whether the target runs the environment on Docker's networks (their Docker blocks).
388#[must_use]
389pub fn on_docker(target: Target) -> bool {
390    matches!(target, Target::Docker | Target::Hosted)
391}
392
393/// The spec as `target` lays it out: on the Docker targets, every network on its Docker block.
394#[must_use]
395pub fn lay_out(spec: &Spec, target: Target) -> Spec {
396    let mut s = spec.clone();
397    if on_docker(target) {
398        for (name, c) in docker_cidrs(spec) {
399            if let Some(n) = s.networks.get_mut(&name) {
400                n.cidr = c.to_string();
401            }
402        }
403    }
404    s
405}
406
407/// Network `name` (`n`) as laid out in `spec`, its block as the author wrote it `declared`.
408fn network(spec: &Spec, name: &str, n: &Network, declared: &str) -> NetworkPlan {
409    NetworkPlan {
410        cidr: n.cidr.clone(),
411        declared_cidr: declared.to_owned(),
412        host: host_address(spec, name),
413        gateway: n.gateway.clone(),
414        internet: n.internet,
415        tc: n.tc.clone(),
416        vlan: n.vlan.clone(),
417        ipv6: ipv6::network(spec, name),
418        mtu: n.mtu,
419    }
420}
421
422/// How the target produces machine `m`. Lowering keeps only the forms the target runs and
423/// refuses a machine without one, so a machine with neither form here is the access machine the
424/// runner supplies.
425fn shape(target: Target, m: &Machine) -> MachineShape {
426    if target == Target::External {
427        return MachineShape::External;
428    }
429    let container = match target {
430        Target::Docker | Target::Hosted | Target::DockerVm | Target::CloudDocker | Target::Kubernetes => true,
431        Target::Hybrid => m.docker.is_some(),
432        // Proxmox runs a machine with no `vm:` as an LXC container when it opts in.
433        Target::Proxmox => m.vm.is_none() && m.docker.as_ref().is_some_and(|d| d.lxc),
434        _ => false,
435    };
436    match (container, m.docker.is_some(), m.vm.is_some()) {
437        (true, true, _) => MachineShape::Container,
438        (false, _, true) => MachineShape::Vm,
439        _ => MachineShape::Supplied,
440    }
441}
442
443/// The machines of `laid_out`, each with its place on the outside networks of `spec`.
444fn machines(spec: &Spec, laid_out: &Spec, target: Target) -> IndexMap<String, MachinePlan> {
445    laid_out
446        .machines
447        .iter()
448        .map(|(name, m)| {
449            let mut mp = machine(laid_out, target, name, m);
450            mp.outside = spec.machines.get(name).map(|om| outside::addresses(spec, om)).unwrap_or_default();
451            (name.clone(), mp)
452        })
453        .collect()
454}
455
456/// Everything the environment requires, most fundamental first: the spec's own requirements,
457/// the outside networks', then those that depend on each machine's shape.
458fn all_requirements(spec: &Spec, laid_out: &Spec, machines: &IndexMap<String, MachinePlan>) -> Vec<Requirement> {
459    let shape = |n: &str| machines.get(n).map(|m| m.shape);
460    let mut out = requirements::of(laid_out);
461    out.extend(outside::requirements(spec));
462    out.extend(requirements::mixed_shapes(laid_out, shape));
463    out.extend(crate::machine_files::requirements(laid_out, shape));
464    out.extend(requirements::tpm(machines));
465    out.extend(crate::boot::requirements(machines));
466    out.extend(hardware::requirements(laid_out, shape));
467    out.extend(crate::more_addresses::requirements(laid_out, shape));
468    out
469}
470
471/// Machine `name` (`m`), planned for `target`.
472fn machine(spec: &Spec, target: Target, name: &str, m: &Machine) -> MachinePlan {
473    let shape = shape(target, m);
474    // Lowering refuses a step the machine's family can't carry, so its configuration resolves.
475    let recipe = m.configuration.as_ref().and_then(|c| Recipe::of(c, &m.ports, &DaemonTable::common()).ok());
476    MachinePlan {
477        shape,
478        addresses: m.networks.iter().map(|(n, o)| (n.clone(), address(spec, n, *o))).collect(),
479        more_addresses: crate::more_addresses::of(spec, m),
480        addresses6: ipv6::addresses(spec, m),
481        names: crate::private_dns::with_domain(
482            spec,
483            std::iter::once(name).chain(m.aliases.iter().map(String::as_str)).map(String::from).collect(),
484        ),
485        routes: routing::routes(spec, name, m).into_iter().map(|(cidr, via)| Route { cidr, via }).collect(),
486        routes6: if target == Target::CloudVm { Vec::new() } else { ipv6::routes(spec, m) },
487        default_gateway: routing::default_gateway(spec, name, m),
488        is_gateway: routing::is_gateway(spec, name),
489        offline: routing::offline(spec, name, m),
490        ports: published(m),
491        volumes: volumes(spec, name, m),
492        files: m.files.clone(),
493        disks: storage::disks(name, m),
494        settings: Settings {
495            environment: m.environment.clone(),
496            sysctls: m.sysctls.clone(),
497            ulimits: m.ulimits.clone(),
498        },
499        inputs: m.inputs.clone(),
500        secrets: m.secrets.iter().map(|s| SecretFile::of(spec, s)).collect(),
501        size: m
502            .resources
503            .map(|r| Size {
504                cpus: r.cpus,
505                memory_mb: r.memory_mb,
506                disk_gb: r.disk_gb,
507            })
508            .unwrap_or_default(),
509        arch: m.arch,
510        rate_limit: m.rate_limit.clone(),
511        image: image::of(spec, shape, name, m, recipe.as_ref()),
512        vm_image: m.vm.as_ref().and_then(|v| v.image.clone()),
513        boot: m.vm.as_ref().and_then(|v| v.boot.clone()),
514        depends_on: m.depends_on.clone(),
515        lifecycle: if shape == MachineShape::Vm {
516            vm_lifecycle(spec, target, name, m, recipe.as_ref())
517        } else {
518            Vec::new()
519        },
520        os: m.vm.as_ref().map(|v| v.os.clone()),
521        winrm: m.vm.as_ref().filter(|v| images::is_windows(&v.os) && !windows::winssh(v)).map(images::winrm),
522        winssh: m.vm.as_ref().is_some_and(windows::winssh),
523        tpm: m.vm.as_ref().and_then(windows::tpm),
524        external: m.external.clone(),
525        host_vars: ansible::host_vars(spec, name),
526        outside: IndexMap::new(),
527        process: Process::of(m),
528        hardware: Hardware::of(m),
529        recipe,
530    }
531}
532
533/// VM `name`'s set-up steps on `target`, its configuration resolved as `recipe`.
534fn vm_lifecycle(spec: &Spec, target: Target, name: &str, m: &Machine, recipe: Option<&Recipe>) -> MachineLifecycle {
535    let mut steps = lifecycle::of(spec, name, m, recipe);
536    if target == Target::CloudVm {
537        ipv6::cloud_routes(&mut steps);
538    }
539    steps
540}
541
542fn published(m: &Machine) -> Vec<PublishedPort> {
543    m.ports
544        .iter()
545        .filter_map(|s| {
546            s.publish.map(|publish| PublishedPort {
547                name: m.port_label(s),
548                port: s.port,
549                publish,
550                protocol: s.protocol,
551                fixed: s.fixed,
552            })
553        })
554        .collect()
555}
556
557fn volumes(spec: &Spec, name: &str, m: &Machine) -> Vec<Volume> {
558    m.volumes
559        .iter()
560        .map(|(v, mount)| Volume {
561            name: v.clone(),
562            path: mount.path().to_owned(),
563            subpath: mount.subpath().map(String::from),
564            key: spec.volume_key(name, v),
565            lifetime: storage::lifetime(spec, v),
566            // Only a shared volume of the spec's `volumes:` has an owner.
567            kind: match spec.volume_owner(v) {
568                Some(owner) => VolumeKind::Shared {
569                    owner: owner.to_owned(),
570                    users: spec.volume_users(v).into_iter().map(String::from).collect(),
571                },
572                None => VolumeKind::Own,
573            },
574        })
575        .collect()
576}
577
578#[cfg(test)]
579mod tests {
580    use isoloom_spec::model::Target;
581
582    use super::{Controller, Environment};
583    use crate::testing;
584
585    const NEEDS_ONE: &str = "version: 1\nname: t\nnetworks: { lan: { cidr: 10.9.0.0/24 } }\nmachines:\n  web: { networks: { lan: 10 }, os: debian@12 }\nprovision: [{ ansible: site.yml }]\n";
586
587    fn controller(extra: &str) -> Option<Controller> {
588        testing::environment(&format!("{NEEDS_ONE}{extra}"), Target::Vagrant).controller
589    }
590
591    #[test]
592    fn the_controller_has_one_cpu_and_512_mb_unless_the_spec_sizes_it() {
593        assert_eq!(
594            controller(""),
595            Some(Controller {
596                cpus: 1,
597                memory_mb: 512,
598                disk_gb: None
599            })
600        );
601        assert_eq!(
602            controller("controller: { resources: { cpus: 2, memory_mb: 768, disk_gb: 10 } }\n"),
603            Some(Controller {
604                cpus: 2,
605                memory_mb: 768,
606                disk_gb: Some(10)
607            })
608        );
609    }
610
611    #[test]
612    fn settings_are_empty_until_one_is_set() {
613        assert!(super::Settings::default().is_empty());
614        let mut s = super::Settings::default();
615        s.sysctls.insert("net.ipv4.ip_forward".into(), "1".into());
616        assert!(!s.is_empty());
617        let mut u = super::Settings::default();
618        u.environment.insert("A".into(), "1".into());
619        assert!(!u.is_empty());
620    }
621
622    #[test]
623    fn a_probe_reaches_a_machine_at_its_address_else_by_name() {
624        let env = testing::environment(
625            "version: 1\nname: t\nnetworks:\n  v4: { cidr: 10.9.0.0/24 }\n  v6: { cidr: 10.8.0.0/24, ipv6: { cidr: \"fd00:8::/64\", only: true } }\n  auto: { cidr: 10.7.0.0/24, ipv6: { cidr: \"fd00:7::/64\", only: true, addresses: auto } }\nmachines:\n  web: { networks: { v4: 10, v6: 10, auto: 10 }, app: { image: nginx } }\n",
626            Target::Kubernetes,
627        );
628        assert_eq!(env.probe_address("web", "v4"), "10.9.0.10");
629        assert_eq!(env.probe_address("web", "v6"), "fd00:8::10");
630        assert_eq!(env.probe_address("web", "auto"), "web");
631        assert_eq!(env.probe_address("ghost", "v4"), "ghost");
632    }
633
634    /// `web` has both forms, `img` only an image, `vm` only a VM (a reboot), `ct` is an LXC
635    /// container on Proxmox, `user` is supplied; `data` is shared by `vm` and `both`.
636    const SHAPES: &str = "version: 1
637name: t
638networks: { a: { cidr: 10.1.0.0/24, ipv6: { cidr: \"fd00:1::/64\" } }, b: { cidr: 10.2.0.0/24, ipv6: { cidr: \"fd00:2::/64\" } } }
639reach: [{ from: a, to: b }]
640volumes: { data: { owner: vm } }
641machines:
642  both: { networks: { a: 10 }, os: debian@12, app: { image: nginx, steps: [{ run: a.sh }] }, volumes: { data: /d, own: /o } }
643  img: { networks: { a: 11 }, app: { image: nginx } }
644  vm: { networks: { b: 12 }, os: debian@12, app: { steps: [{ run: a.sh }, reboot] }, volumes: { data: /d } }
645  ct: { networks: { a: 13 }, app: { image: alpine } }
646  win: { networks: { a: 14 }, os: windows-server@2022 }
647  user: { access: true, networks: { a: 5 } }
648overrides:
649  proxmox: { machines: { ct: { lxc: true } } }
650  vagrant: { machines: { win: { winrm: ssl } } }
651";
652
653    fn shapes(target: Target) -> Vec<(String, super::MachineShape)> {
654        testing::environment(SHAPES, target)
655            .machines
656            .iter()
657            .map(|(n, m)| (n.clone(), m.shape))
658            .collect()
659    }
660
661    fn shape_of(target: Target, machine: &str) -> Option<super::MachineShape> {
662        shapes(target).into_iter().find(|(n, _)| n == machine).map(|(_, s)| s)
663    }
664
665    #[test]
666    fn each_target_produces_a_machine_in_the_form_it_runs() {
667        use super::MachineShape::{Container, External, Supplied, Vm};
668        for t in [Target::Docker, Target::Hosted, Target::DockerVm, Target::CloudDocker, Target::Kubernetes] {
669            assert_eq!(shape_of(t, "both"), Some(Container), "{t:?}");
670            assert_eq!(shape_of(t, "user"), Some(Supplied), "{t:?}");
671        }
672        assert_eq!(shape_of(Target::Hybrid, "both"), Some(Container), "a container where it has one");
673        assert_eq!(shape_of(Target::Hybrid, "vm"), Some(Vm));
674        assert_eq!(shape_of(Target::Proxmox, "ct"), Some(Container), "an LXC container");
675        assert_eq!(shape_of(Target::Proxmox, "both"), Some(Vm));
676        assert_eq!(
677            shape_of(Target::Proxmox, "img"),
678            Some(Supplied),
679            "no VM and no LXC: Proxmox refuses it, nothing is built"
680        );
681        assert_eq!(shape_of(Target::Vagrant, "both"), Some(Vm));
682        assert_eq!(shape_of(Target::External, "both"), Some(External));
683    }
684
685    #[test]
686    fn a_vm_has_set_up_steps_and_a_container_none() {
687        let env = testing::environment(SHAPES, Target::Hybrid);
688        let steps = |m: &str| env.machines.get(m).map_or(0, |p| p.lifecycle.len());
689        assert!(steps("vm") > 0);
690        assert_eq!(steps("both"), 0);
691    }
692
693    #[test]
694    fn a_cloud_routes_ipv6_itself_and_the_other_vm_targets_through_the_router() {
695        let routes6 = |t: Target| {
696            testing::environment(SHAPES, t)
697                .machines
698                .get("both")
699                .map(|m| m.routes6.len())
700                .unwrap_or_default()
701        };
702        assert_eq!(routes6(Target::Vagrant), 1);
703        assert_eq!(routes6(Target::CloudVm), 0);
704        let cloud = testing::environment(SHAPES, Target::CloudVm);
705        let ipv6_routes = cloud.machines.get("both").and_then(|m| {
706            m.lifecycle.iter().find_map(|s| match s {
707                crate::Step::Ipv6 { routes, .. } => Some(routes.len()),
708                _ => None,
709            })
710        });
711        assert_eq!(ipv6_routes, Some(0));
712    }
713
714    #[test]
715    fn a_shared_volume_names_its_owner_and_users_and_an_own_one_is_the_machines() {
716        let env = testing::environment(SHAPES, Target::Vagrant);
717        let both = env.machines.get("both").map(|m| m.volumes.clone()).unwrap_or_default();
718        let kinds: Vec<(&str, &super::VolumeKind)> = both.iter().map(|v| (v.name.as_str(), &v.kind)).collect();
719        assert_eq!(
720            kinds,
721            [
722                (
723                    "data",
724                    &super::VolumeKind::Shared {
725                        owner: "vm".into(),
726                        users: vec!["both".into(), "vm".into()]
727                    }
728                ),
729                ("own", &super::VolumeKind::Own)
730            ]
731        );
732    }
733
734    #[test]
735    fn a_windows_vm_answers_winrm_its_way() {
736        let env = testing::environment(SHAPES, Target::Vagrant);
737        let win = env.machines.get("win").expect("planned");
738        assert!(win.is_windows() && !win.winssh);
739        assert_eq!(win.winrm, Some(isoloom_spec::model::Winrm::Ssl));
740        let vm = env.machines.get("vm").expect("planned");
741        assert!(!vm.is_windows() && vm.winrm.is_none());
742        assert!(super::on_docker(Target::Docker) && super::on_docker(Target::Hosted) && !super::on_docker(Target::DockerVm));
743    }
744
745    #[test]
746    fn only_playbooks_need_a_controller() {
747        let lab = testing::lab(
748            "version: 1\nname: t\nnetworks: { lan: { cidr: 10.9.0.0/24 } }\nmachines:\n  web: { networks: { lan: 10 }, os: debian@12 }\nchecks: [checks/a.sh]\n",
749        );
750        let spec = lab.for_target(Target::Vagrant);
751        assert!(!Controller::needed(&spec) && Environment::build(&spec, Target::Vagrant, None).controller.is_none());
752        let playbook = testing::lab(
753            "version: 1\nname: t\nnetworks: { lan: { cidr: 10.9.0.0/24 } }\nmachines:\n  web: { networks: { lan: 10 }, os: debian@12 }\nchecks: [checks/a.yml]\n",
754        );
755        assert!(Controller::needed(&playbook.for_target(Target::Vagrant)));
756        assert_eq!(Controller::of(&spec).memory_mb, Controller::MEMORY_MB);
757    }
758}