Skip to main content

isoloom_spec/model/
machine.rs

1//! A machine as seen from outside: its OS, ports, architecture, resources and the settings it
2//! carries for every target.
3
4use indexmap::IndexMap;
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7
8use super::{CommandLine, Disk, Dns, DockerImpl, External, Healthcheck, MachineFile, MachineSecret, Mount, Tmpfs, Ulimit, VmImpl};
9
10/// A machine's CPU architecture. Every target has one: a container platform, a box
11/// architecture, a cloud instance family. Defaults to x86-64.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
13#[serde(rename_all = "lowercase")]
14pub enum Arch {
15    /// x86-64 (amd64), the default.
16    #[default]
17    Amd64,
18    /// 64-bit ARM (aarch64).
19    Arm64,
20}
21
22impl Arch {
23    /// The container platform value (`linux/amd64`, `linux/arm64`).
24    #[must_use]
25    pub fn docker_platform(self) -> &'static str {
26        match self {
27            Arch::Amd64 => "linux/amd64",
28            Arch::Arm64 => "linux/arm64",
29        }
30    }
31
32    /// The name Vagrant, Kubernetes and most tools use (`amd64`, `arm64`).
33    #[must_use]
34    pub fn id(self) -> &'static str {
35        match self {
36            Arch::Amd64 => "amd64",
37            Arch::Arm64 => "arm64",
38        }
39    }
40}
41
42/// How strongly a machine's offline seal holds. `internet: false` is enforced for every machine,
43/// but a privileged container can lift it from inside its own network namespace, so such a machine
44/// must declare `seal: best-effort` to acknowledge the seal is advisory for it (it is refused
45/// otherwise). Defaults to `guaranteed`.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
47#[serde(rename_all = "kebab-case")]
48#[non_exhaustive]
49pub enum Seal {
50    /// The seal holds: nothing in the machine can lift it (the default).
51    #[default]
52    Guaranteed,
53    /// The machine can lift the seal from inside (a privileged container): it is applied but not
54    /// guaranteed.
55    BestEffort,
56}
57
58impl Seal {
59    /// Whether the seal is the default, guaranteed one.
60    #[must_use]
61    pub fn is_guaranteed(&self) -> bool {
62        matches!(self, Seal::Guaranteed)
63    }
64}
65
66/// A machine of the environment: where it sits on the networks, what it serves, and how each
67/// kind of target produces it (`docker:`, `vm:`, `external:`).
68#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
69#[serde(deny_unknown_fields)]
70#[expect(
71    clippy::struct_excessive_bools,
72    reason = "the lowered form keeps one independent switch per field the backends read (spec.md §7.1); it shrinks as they read the plan instead"
73)]
74pub struct Machine {
75    /// Network name -> last octet of the machine's address on it. Every machine has one, but a
76    /// LAN's `switch`, whose ports the LAN gives it. A list (`lan: [10, 11, 12]`) gives the
77    /// machine several addresses on the network: the first is its primary one, the one its name
78    /// resolves to, and the others are on the same interface.
79    #[serde(default)]
80    #[schemars(with = "IndexMap<String, super::Octets>")]
81    pub networks: IndexMap<String, u8>,
82    /// The addresses after the first of each network written as a list, split from `networks`
83    /// by lowering: not a field a spec writes.
84    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
85    #[schemars(skip)]
86    pub more_addresses: IndexMap<String, Vec<u8>>,
87    /// How many of this machine (2 to 99): clones named `<name>-01`, `<name>-02`, ..., each one
88    /// address further along on every network. Where the spec names this machine, it means
89    /// every clone. Expanded before anything else reads the spec.
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub count: Option<u8>,
92    /// More DNS names the machine answers to besides its own (a fully qualified name an
93    /// application has baked in, like `api.example.com`), on every network it is on.
94    #[serde(default, skip_serializing_if = "Vec::is_empty")]
95    pub aliases: Vec<String>,
96    /// The machine's OS as `<family>@<version>` (`debian@12`, `windows-server@2022`), or the
97    /// family alone for its pinned default version (`debian` is `debian@12`). `vm.os` defaults
98    /// to it, and a machine with neither `docker:` nor `vm:` runs as a VM of this OS
99    /// (`vm: { os }`).
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub os: Option<String>,
102    /// The machine's CPU architecture (`amd64` or `arm64`). Defaults to `amd64`.
103    #[serde(default)]
104    pub arch: Arch,
105    /// A bandwidth cap on each of the machine's network links, in both directions, as `tc`
106    /// writes a rate (`10mbit`, `512kbit`): a machine on a slow line, while `networks.*.tc`
107    /// slows a whole network down on its router.
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub rate_limit: Option<String>,
110    /// The machine needs kernel-level access (nested containers, loading kernel modules, raw
111    /// devices). A container runs privileged; a VM already has it (its workload is root). VMs
112    /// inside a VM need `nested_virtualization`.
113    #[serde(default)]
114    pub privileged: bool,
115    /// How strongly the machine's `internet: false` seal holds. A privileged container can lift
116    /// the seal from inside its network namespace, so a privileged container on an offline network
117    /// must set `seal: best-effort` to acknowledge that (it is refused otherwise). `guaranteed`
118    /// (the default) means nothing in the machine can lift it.
119    #[serde(default, skip_serializing_if = "Seal::is_guaranteed")]
120    pub seal: Seal,
121    /// The machine runs VMs of its own: its VM gets the CPU's virtualization extensions (each
122    /// hypervisor's nested virtualization). A VM only: a container can't, so the machine needs
123    /// a `vm:`. Targets whose VMs can't nest refuse it.
124    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
125    pub nested_virtualization: bool,
126    /// GPUs for the machine: a count (`1`), `all`, or `{ count, ids, driver, capabilities,
127    /// options }`. A container gets the host's (Docker, Kubernetes); a VM gets them passed
128    /// through (Proxmox, by `ids`) or sized with them (a cloud's GPU instance type). Other
129    /// targets refuse it.
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    #[schemars(with = "Option<super::GpusInput>")]
132    pub gpus: Option<super::Gpus>,
133    /// The host's devices given to the machine: `/dev/fuse`, `host:guest[:permissions]`, or
134    /// `{ host, guest, permissions }` (`r`, `w`, `m`). A container on the user's own Docker
135    /// host only: a VM has its own devices (a GPU reaches a VM through `gpus`).
136    #[serde(default, skip_serializing_if = "Vec::is_empty")]
137    #[schemars(with = "Vec<super::DeviceInput>")]
138    pub devices: Vec<super::Device>,
139    /// The machine's root filesystem is read-only (hardening): nothing can be written outside
140    /// its declared `volumes`. Applies to containers; a VM's root stays writable.
141    #[serde(default)]
142    pub read_only: bool,
143    /// Memory-backed (tmpfs) mounts inside the machine, for scratch space that never touches
144    /// disk: a path (`/tmp`), or a path with a size and a mode (`{ path: /run, size: 64m, mode:
145    /// "1777" }`). A VM sets these up in its own provisioning (fstab).
146    #[serde(default)]
147    pub tmpfs: Vec<Tmpfs>,
148    /// The size of `/dev/shm` (shared memory), e.g. `256m` or `1g`, for workloads that need more
149    /// than the small default (browsers, some databases). A VM sizes it in its own provisioning.
150    #[serde(default)]
151    pub shm_size: Option<String>,
152    /// How the machine resolves names: which DNS servers to use, search domains, and its own
153    /// domain. Useful to point a Linux box at the lab's domain controller. A VM sets its resolver
154    /// in its own provisioning (resolv.conf).
155    #[serde(default)]
156    pub dns: Option<Dns>,
157    /// Environment variables with fixed values: a container's environment; on a VM, every
158    /// service's (systemd's default environment) and every login's (`/etc/environment`).
159    #[serde(default, skip_serializing_if = "IndexMap::is_empty", deserialize_with = "scalars")]
160    #[schemars(with = "IndexMap<String, ScalarSchema>")]
161    pub environment: IndexMap<String, String>,
162    /// Kernel parameters, e.g. `net.core.somaxconn: 1024`. A container can only set the ones
163    /// the kernel keeps per network or IPC namespace (`net.*`, `kernel.shm*`, `kernel.msg*`,
164    /// `kernel.sem`, `fs.mqueue.*`); a VM sets any (`/etc/sysctl.d`).
165    #[serde(default, skip_serializing_if = "IndexMap::is_empty", deserialize_with = "scalars")]
166    #[schemars(with = "IndexMap<String, ScalarSchema>")]
167    pub sysctls: IndexMap<String, String>,
168    /// Process limits, by name (`nofile`, `nproc`, `memlock`...): one value for both, or
169    /// `{ soft, hard }`. A container's ulimits; on a VM, every service's (systemd) and every
170    /// login's (`limits.d`). Kubernetes has none per container.
171    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
172    pub ulimits: IndexMap<String, Ulimit>,
173    /// The ports the machine answers on, each with its protocol, an HTTP probe and whether it
174    /// is published or dormant: what is published, opened by `reach`, waited for and checked.
175    #[serde(default)]
176    pub ports: Vec<Port>,
177    /// The spec's `inputs` this machine receives, by name: only these reach it.
178    #[serde(default)]
179    pub inputs: Vec<String>,
180    /// The secrets this machine gets, as files (never environment variables): a name from the
181    /// top-level `secrets` (`/run/secrets/<name>`), or `{ source, target, uid, gid, mode }`.
182    /// Delivered by `isoloom run` when the machine starts.
183    #[serde(default, skip_serializing_if = "Vec::is_empty")]
184    pub secrets: Vec<MachineSecret>,
185    /// CPUs, memory and disk for the machine (a VM's size, a container's limits). Default: 1
186    /// CPU, 1024 MB, 20 GB.
187    #[serde(default)]
188    pub resources: Option<Resources>,
189    /// The machine's own command, instead of its image's (`CMD`; the image's entrypoint still
190    /// runs it). A text runs in the shell, a list as it is. On a VM it runs as a service
191    /// (systemd, OpenRC on Alpine; a startup task on Windows), restarted when it fails.
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub command: Option<CommandLine>,
194    /// The directory the command and the healthcheck run in (an absolute
195    /// path). Default: the image's `WORKDIR`; on a VM, the project folder (`/opt/isoloom`).
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub working_dir: Option<String>,
198    /// When the machine counts as healthy beyond its ports answering: a command or a
199    /// page, with its pace. Checked beside the port probe, never instead of it.
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub healthcheck: Option<Healthcheck>,
202    /// Machines that must be ready (their ports answering) before this one starts.
203    #[serde(default)]
204    pub depends_on: Vec<String>,
205    /// Data that survives restarts and re-creation of the machine, until the environment is
206    /// destroyed: a name -> an absolute path in the machine. On the container targets a volume
207    /// starts with the image's files at that path (Docker's named volumes; Kubernetes seeds
208    /// its claim from the image while it is empty), so init jobs can add to them. A shared
209    /// volume's user may take one folder of it: `{ path: /srv/www, subpath: www }`.
210    #[serde(default)]
211    pub volumes: IndexMap<String, Mount>,
212    /// Files placed at a path on the machine (a configuration the image doesn't carry), from
213    /// the project or written inline, with their owner and mode: on every target.
214    #[serde(default, skip_serializing_if = "Vec::is_empty")]
215    pub files: Vec<MachineFile>,
216    /// Data disks beside the machine's own, by name (kebab-case, at most 12 characters: the
217    /// filesystem's label): `{ size_gb: 50, fs: xfs, mount: /data }`. A VM gets an empty disk
218    /// of that size, formatted and mounted at boot; a container gets a volume at the mount. The
219    /// disks go with the machine.
220    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
221    pub disks: IndexMap<String, Disk>,
222    /// The machine the user lands on. It may have no implementation: the runner supplies it.
223    #[serde(default)]
224    pub access: bool,
225    /// How a container produces this machine: an image or a build, and what only a container
226    /// has (init jobs, appliances). A machine with `docker:` can run on the container targets.
227    #[serde(default)]
228    pub docker: Option<DockerImpl>,
229    /// How a VM produces this machine (services installed natively, no Docker inside).
230    #[serde(default)]
231    pub vm: Option<VmImpl>,
232    /// Where this machine already exists (the `external` target): Isoloom creates nothing and
233    /// reaches it over SSH to provision, check and connect.
234    #[serde(default, skip_serializing_if = "Option::is_none")]
235    pub external: Option<External>,
236    /// Filled by the runner's image table (an access machine the spec leaves to the runner):
237    /// a stock image, kept running idle for the user to work from. Never in a spec file.
238    #[serde(skip)]
239    #[schemars(skip)]
240    pub supplied: bool,
241    /// What configures it (an app's steps), in the configuration model, for every form: the
242    /// plan resolves it into the recipe a container's build and a VM's set-up render. Set by
243    /// lowering, never written in a spec.
244    #[serde(skip)]
245    #[schemars(skip)]
246    pub configuration: Option<super::Configuration>,
247}
248
249/// A port a machine answers on.
250#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
251#[serde(deny_unknown_fields)]
252#[expect(
253    clippy::struct_excessive_bools,
254    reason = "the lowered form keeps the backends' port switches one to one (spec.md §7.3 item 2 turns them into one `PortKind`)"
255)]
256pub struct Port {
257    /// The port the machine listens on (what the other machines and the
258    /// checks connect to).
259    pub port: u16,
260    /// A name for the port (`web`, `api`): how outputs and messages refer to it. Default:
261    /// its port.
262    #[serde(default)]
263    pub name: Option<String>,
264    /// It speaks HTTP: derived checks request a page instead of only connecting.
265    #[serde(default)]
266    pub http: bool,
267    /// It speaks TLS (HTTPS for an `http` port): probes and checks use TLS, certificates
268    /// unchecked (lab services sign their own).
269    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
270    pub tls: bool,
271    /// Reachable from the user's machine on this port (loopback only), besides the
272    /// environment's own networks.
273    #[serde(default)]
274    pub publish: Option<u16>,
275    /// Keep `publish` as the host port on local Docker too (normally a free one is picked, so
276    /// labs never collide): for apps whose pages call `localhost:<that port>` themselves. The
277    /// environment can't start while another holds that port.
278    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
279    pub fixed: bool,
280    /// Only answers later, once something wakes it (the bind shell an exploit opens): declared,
281    /// published and opened by `reach` like any port, but nothing waits for it (healthcheck,
282    /// readiness probe, `depends_on`, `up --wait`) and no derived check expects it to answer.
283    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
284    pub dormant: bool,
285    /// `tcp` (default) or `udp` (DNS, SNMP, TFTP, syslog): published, forwarded and opened as
286    /// UDP. UDP has no handshake, so nothing waits for it (healthcheck, readiness probe,
287    /// `depends_on`) and no derived check probes it: a check of your own says how (`exec: dig`).
288    /// A port served over both is two entries, one per protocol.
289    #[serde(default, skip_serializing_if = "Protocol::is_tcp")]
290    pub protocol: Protocol,
291}
292
293/// A port's transport protocol.
294#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
295#[serde(rename_all = "lowercase")]
296pub enum Protocol {
297    /// TCP, the default.
298    #[default]
299    Tcp,
300    /// UDP.
301    Udp,
302}
303
304impl Protocol {
305    /// `tcp` or `udp`, as Compose, Vagrant, nftables and Terraform providers write it.
306    #[must_use]
307    pub fn id(self) -> &'static str {
308        match self {
309            Protocol::Tcp => "tcp",
310            Protocol::Udp => "udp",
311        }
312    }
313
314    /// Whether this is TCP, the default (a port without `protocol`).
315    #[must_use]
316    pub fn is_tcp(&self) -> bool {
317        *self == Protocol::Tcp
318    }
319}
320
321impl Machine {
322    /// How outputs name one of its ports (published outputs, Compose labels): its `name`,
323    /// else its port. A UDP port is `<port>-udp`, and `<name>-udp` when a TCP port of the
324    /// machine has the same name (DNS's `dns` over both protocols), so the two never share a key.
325    #[must_use]
326    pub fn port_label(&self, s: &Port) -> String {
327        match &s.name {
328            None if s.protocol.is_tcp() => s.port.to_string(),
329            None => format!("{}-udp", s.port),
330            Some(n) if !s.protocol.is_tcp() && self.ports.iter().any(|o| o.protocol.is_tcp() && o.name.as_ref() == Some(n)) => {
331                format!("{n}-udp")
332            }
333            Some(n) => n.clone(),
334        }
335    }
336
337    /// The ports Isoloom waits for before the machine counts as ready: its TCP ports but the
338    /// dormant ones (UDP has no handshake to probe). Empty: nothing to probe (no healthcheck, no
339    /// readiness probe).
340    #[must_use]
341    pub fn ready_ports(&self) -> Vec<u16> {
342        self.ports.iter().filter(|s| !s.dormant && s.protocol.is_tcp()).map(|s| s.port).collect()
343    }
344
345    /// Whether anything says when the machine is ready: a port to probe or a healthcheck of its
346    /// own. Without either, the machine is ready once started.
347    #[must_use]
348    pub fn has_readiness(&self) -> bool {
349        self.healthcheck.is_some() || !self.ready_ports().is_empty()
350    }
351}
352
353/// What a machine is given to run on; each left out takes its default.
354#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
355#[serde(deny_unknown_fields)]
356pub struct Resources {
357    /// CPUs (default 1).
358    #[serde(default)]
359    pub cpus: Option<u32>,
360    /// Memory in MB (default 1024).
361    #[serde(default)]
362    pub memory_mb: Option<u32>,
363    /// Disk in GB, for a VM (default 20).
364    #[serde(default)]
365    pub disk_gb: Option<u32>,
366}
367
368/// A machine's address on a network, as written: one last octet, or several (the first the
369/// primary one).
370#[derive(JsonSchema)]
371#[serde(untagged)]
372pub enum Octets {
373    /// The last octet of its address.
374    One(u8),
375    /// Several addresses on the network, the first its primary one.
376    Several(Vec<u8>),
377}
378
379/// A text, a number or a boolean, read as text (environment values, sysctl values).
380#[derive(JsonSchema)]
381#[serde(untagged)]
382pub enum ScalarSchema {
383    /// A text.
384    Text(String),
385    /// A number, read as its text.
386    Number(f64),
387    /// A boolean, read as `true` or `false`.
388    Bool(bool),
389}
390
391/// A mapping of scalars, read as texts (`1024`, `true` and `"x"` alike).
392pub(crate) fn scalars<'de, D: serde::Deserializer<'de>>(d: D) -> Result<IndexMap<String, String>, D::Error> {
393    let raw: IndexMap<String, serde_json::Value> = serde::Deserialize::deserialize(d)?;
394    raw.into_iter()
395        .map(|(k, v)| match v {
396            serde_json::Value::String(s) => Ok((k, s)),
397            serde_json::Value::Number(n) => Ok((k, n.to_string())),
398            serde_json::Value::Bool(b) => Ok((k, b.to_string())),
399            _ => Err(serde::de::Error::custom(format!("`{k}`: a text, a number or a boolean"))),
400        })
401        .collect()
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407
408    /// A machine read from its YAML.
409    fn machine(yaml: &str) -> Result<Machine, serde_yaml_ng::Error> {
410        serde_yaml_ng::from_str(yaml)
411    }
412
413    #[test]
414    fn architectures_and_protocols_go_by_the_names_a_spec_writes() -> Result<(), serde_yaml_ng::Error> {
415        for arch in [Arch::Amd64, Arch::Arm64] {
416            assert_eq!(serde_yaml_ng::from_str::<Arch>(arch.id())?, arch);
417            assert_eq!(arch.docker_platform(), format!("linux/{}", arch.id()));
418        }
419        for protocol in [Protocol::Tcp, Protocol::Udp] {
420            assert_eq!(serde_yaml_ng::from_str::<Protocol>(protocol.id())?, protocol);
421        }
422        assert_eq!([Protocol::Tcp.is_tcp(), Protocol::Udp.is_tcp()], [true, false]);
423        Ok(())
424    }
425
426    #[test]
427    fn a_port_is_labelled_by_its_name_or_number_and_udp_where_tcp_shares_it() -> Result<(), serde_yaml_ng::Error> {
428        let m = machine(
429            "ports:
430  - { port: 80 }
431  - { port: 69, protocol: udp }
432  - { port: 53, name: dns }
433  - { port: 53, name: dns, protocol: udp }
434  - { port: 161, name: snmp, protocol: udp }
435",
436        )?;
437        let labels: Vec<String> = m.ports.iter().map(|p| m.port_label(p)).collect();
438        assert_eq!(labels, ["80", "69-udp", "dns", "dns-udp", "snmp"]);
439        Ok(())
440    }
441
442    #[test]
443    fn readiness_waits_on_tcp_ports_that_answer_or_a_healthcheck() -> Result<(), serde_yaml_ng::Error> {
444        let m = machine("ports: [{ port: 80 }, { port: 53, protocol: udp }, { port: 4444, dormant: true }]\n")?;
445        assert_eq!(m.ready_ports(), [80]);
446        assert!(m.has_readiness());
447        let udp = machine("ports: [{ port: 53, protocol: udp }]\n")?;
448        assert!(!udp.has_readiness(), "nothing to probe");
449        assert!(machine("ports: [{ port: 53, protocol: udp }]\nhealthcheck: { test: dig }\n")?.has_readiness());
450        Ok(())
451    }
452
453    #[test]
454    fn environment_values_are_texts_numbers_or_booleans_read_as_text() -> Result<(), serde_yaml_ng::Error> {
455        let m = machine("environment: { MODE: prod, WORKERS: 4, DEBUG: false }\n")?;
456        let read: Vec<(&str, &str)> = m.environment.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
457        assert_eq!(read, [("MODE", "prod"), ("WORKERS", "4"), ("DEBUG", "false")]);
458        let list = machine("environment: { HOSTS: [a, b] }\n").map(|_| ()).map_err(|e| e.to_string());
459        assert!(list.is_err_and(|e| e.contains("`HOSTS`: a text, a number or a boolean")));
460        Ok(())
461    }
462}