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}