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