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}