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