isoloom_spec/syntax/machine.rs
1//! A machine: what it is (`os` plus a catalogue `service`, a custom `app`, or nothing more), where
2//! it sits on the networks, and the settings every form shares. It never says how a platform
3//! runs it; each target picks the form it uses.
4
5use std::fmt;
6
7use indexmap::IndexMap;
8use schemars::JsonSchema;
9use serde::Deserialize;
10use serde::de::{self, SeqAccess, Visitor};
11
12use super::app::App;
13use super::files::File;
14use super::hardware::Hardware;
15use super::names::{PortName, ServiceRef};
16use super::os::OsRef;
17use super::ports::Port;
18use super::process::{Command, EnvValue, Healthcheck};
19use crate::Error;
20use crate::model::{Disk, Dns, Mount, Resources, ScalarSchema, Tmpfs, Ulimit};
21
22/// What a machine is, besides its OS.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum Kind {
25 /// A catalogue service (or several), with its settings.
26 Service(Services),
27 /// A custom app: an image, a build, an appliance or portable steps.
28 App(App),
29 /// The OS alone: its official image kept running idle, or a fresh VM.
30 Bare,
31}
32
33/// Catalogue services on a machine and their settings.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct Services {
36 /// The services, each `name[@version]`.
37 pub services: Vec<ServiceRef>,
38 /// Their settings (`with:`), as written.
39 pub with: IndexMap<String, serde_json::Value>,
40}
41
42/// A machine's address on a network: one last octet, or several (the first the primary one).
43#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)]
44#[serde(untagged)]
45pub enum Octets {
46 /// The last octet of its address (0: an address from the LAN's DHCP server, on a bridged
47 /// network that hands them out).
48 One(u8),
49 /// Several addresses on the network, by last octet: the first is its primary address.
50 Several(Vec<u8>),
51}
52
53/// A machine of the environment.
54#[derive(Debug, Clone, PartialEq, Deserialize)]
55#[serde(try_from = "MachineInput")]
56pub struct Machine {
57 /// Its OS (`None`: a container-only app, or an access machine the runner supplies).
58 pub os: Option<OsRef>,
59 /// What it is.
60 pub kind: Kind,
61 /// Network name → its address on it.
62 pub networks: IndexMap<String, Octets>,
63 /// More DNS names it answers to.
64 pub aliases: Vec<String>,
65 /// The machine the user lands on.
66 pub access: bool,
67 /// Its ports, by name.
68 pub ports: IndexMap<PortName, Port>,
69 /// Its own command.
70 pub command: Option<Command>,
71 /// Project scripts run once it is up.
72 pub setup: Vec<String>,
73 /// When it counts as healthy beyond its ports.
74 pub healthcheck: Option<Healthcheck>,
75 /// Machines that must be ready first.
76 pub depends_on: Vec<String>,
77 /// Its environment variables.
78 pub environment: IndexMap<String, EnvValue>,
79 /// Files placed on it.
80 pub files: Vec<File>,
81 /// Its volumes: name → where it mounts it.
82 pub volumes: IndexMap<String, Mount>,
83 /// Its data disks.
84 pub disks: IndexMap<String, Disk>,
85 /// Its memory-backed mounts.
86 pub tmpfs: Vec<Tmpfs>,
87 /// Its CPUs, memory and disk.
88 pub resources: Option<Resources>,
89 /// The hardware it asks for.
90 pub hardware: Hardware,
91 /// How it resolves names.
92 pub dns: Option<Dns>,
93 /// Kernel-level access.
94 pub privileged: bool,
95 /// A read-only root filesystem.
96 pub read_only: bool,
97 /// The size of `/dev/shm`.
98 pub shm_size: Option<String>,
99 /// Kernel parameters.
100 pub sysctls: IndexMap<String, String>,
101 /// Process limits.
102 pub ulimits: IndexMap<String, Ulimit>,
103 /// A bandwidth cap on each of its links.
104 pub rate_limit: Option<String>,
105}
106
107/// A machine as written.
108#[derive(Deserialize, JsonSchema)]
109#[serde(deny_unknown_fields)]
110pub(crate) struct MachineInput {
111 /// The OS, as `<family>@<version>` (`debian@12`, `windows-server@2022`), or the family alone
112 /// for its pinned default. Required, except for a machine whose only recipe is `app.image`,
113 /// `app.build` or `app.appliance`, and an `access` machine the runner supplies. Windows has
114 /// no container form in 1.0.
115 #[serde(default)]
116 os: Option<OsRef>,
117 /// A catalogue service, `name[@version]` (`postgres@16`), or a list of them. Not with `app`.
118 #[serde(default, deserialize_with = "services")]
119 #[schemars(with = "Option<ServicesInput>")]
120 service: Option<Vec<ServiceRef>>,
121 /// The service's settings, from its entry's settings schema. Only with `service`.
122 #[serde(default)]
123 with: IndexMap<String, serde_json::Value>,
124 /// A custom app: its own image, its own Dockerfile, a network appliance, or portable steps.
125 /// Not with `service`.
126 #[serde(default)]
127 app: Option<App>,
128 /// Network name → the last octet of the machine's address on it, or a list of them (the
129 /// first is its primary address). Every machine has one, but a LAN's `switch`.
130 #[serde(default)]
131 networks: IndexMap<String, Octets>,
132 /// More DNS names it answers to besides its own (`api.example.com`), on every network.
133 #[serde(default)]
134 aliases: Vec<String>,
135 /// How many of this machine (2 to 99): clones `<name>-01`, `<name>-02`, ..., each one address
136 /// further along on every network. Where the spec names this machine, it means every clone.
137 #[serde(default, rename = "count")]
138 _count: Option<u8>,
139 /// The machine the user lands on (at most one). Without `os`, the runner supplies it.
140 #[serde(default)]
141 access: bool,
142 /// Its ports, by name (`site`, `dns-udp`): a number (TCP), or `{ port, protocol, publish,
143 /// dormant }`. Messages and checks reach them as `machines.<m>.ports.<name>.published`.
144 #[serde(default)]
145 ports: IndexMap<PortName, Port>,
146 /// Its own command: a text (run by the shell), a list (run as is), or `{ run, cwd }`. Default:
147 /// the image's own; an `os`-only or steps-only machine stays up idle. Not with `service`.
148 #[serde(default)]
149 command: Option<Command>,
150 /// Project scripts run once, in order, once the machine is up (as root, with its network,
151 /// volumes, files and privileges): `.sh`, or `.ps1` on Windows. A machine is ready once they
152 /// have run.
153 #[serde(default)]
154 setup: Vec<String>,
155 /// When it counts as healthy beyond its ports answering: `{ test }` or `{ http: { port,
156 /// path } }` (a port's name or number), with `interval`, `timeout`, `retries` and
157 /// `start_period`.
158 #[serde(default)]
159 healthcheck: Option<Healthcheck>,
160 /// Machines that must be ready (ports answering, healthcheck passed, setup run) first.
161 #[serde(default)]
162 depends_on: Vec<String>,
163 /// Environment variables: a text, a number or a boolean, or `{ input: NAME }` for a
164 /// non-sensitive input. Secrets and sensitive inputs reach a machine as `files`.
165 #[serde(default)]
166 environment: IndexMap<String, EnvValue>,
167 /// Files placed on the machine, each from a project `source`, inline `content`, an `input` or a
168 /// `secret`, with its owner and mode.
169 #[serde(default)]
170 files: Vec<File>,
171 /// Data kept until the environment is destroyed: a name → an absolute path, or `{ path,
172 /// subpath }` for one folder of a shared volume.
173 #[serde(default)]
174 volumes: IndexMap<String, Mount>,
175 /// Data disks beside its own, by name: `{ size_gb, fs, mount }`.
176 #[serde(default)]
177 disks: IndexMap<String, Disk>,
178 /// Memory-backed mounts: a path, or `{ path, size, mode }`.
179 #[serde(default)]
180 tmpfs: Vec<Tmpfs>,
181 /// CPUs, memory and disk (default 1, 1024 MB, 20 GB).
182 #[serde(default)]
183 resources: Option<Resources>,
184 /// What it asks of the hardware: `arch`, `tpm`, `gpus`, `devices`, `nested_virtualization`,
185 /// `boot`.
186 #[serde(default)]
187 hardware: Hardware,
188 /// How it resolves names: `servers` (addresses, or machines sharing a network), `search`,
189 /// `domain`, `options`.
190 #[serde(default)]
191 dns: Option<Dns>,
192 /// Kernel-level access (nested containers, kernel modules, raw devices).
193 #[serde(default)]
194 privileged: bool,
195 /// A read-only root filesystem (containers).
196 #[serde(default)]
197 read_only: bool,
198 /// The size of `/dev/shm` (`256m`, `1g`).
199 #[serde(default)]
200 shm_size: Option<String>,
201 /// Kernel parameters (`net.core.somaxconn: 4096`).
202 #[serde(default, deserialize_with = "crate::model::scalars")]
203 #[schemars(with = "IndexMap<String, ScalarSchema>")]
204 sysctls: IndexMap<String, String>,
205 /// Process limits by name: one value, or `{ soft, hard }`.
206 #[serde(default)]
207 ulimits: IndexMap<String, Ulimit>,
208 /// A bandwidth cap on each of its links, as `tc` writes a rate (`10mbit`).
209 #[serde(default)]
210 rate_limit: Option<String>,
211}
212
213// What the schema shows and the reader returns.
214/// `service:` as written, for the schema.
215#[derive(JsonSchema)]
216#[serde(untagged)]
217enum ServicesInput {
218 /// One service.
219 One(ServiceRef),
220 /// Several services on one machine (no container form: one process per container).
221 Several(Vec<ServiceRef>),
222}
223
224impl<'de> Deserialize<'de> for ServicesInput {
225 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<ServicesInput, D::Error> {
226 struct V;
227 impl<'de> Visitor<'de> for V {
228 type Value = ServicesInput;
229 fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
230 f.write_str("a catalogue service, `name[@version]`, or a list of them")
231 }
232 fn visit_str<E: de::Error>(self, v: &str) -> Result<ServicesInput, E> {
233 v.parse().map(ServicesInput::One).map_err(E::custom)
234 }
235 fn visit_seq<A: SeqAccess<'de>>(self, seq: A) -> Result<ServicesInput, A::Error> {
236 Vec::<ServiceRef>::deserialize(de::value::SeqAccessDeserializer::new(seq)).map(ServicesInput::Several)
237 }
238 }
239 d.deserialize_any(V)
240 }
241}
242
243fn services<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<Vec<ServiceRef>>, D::Error> {
244 Ok(Some(match ServicesInput::deserialize(d)? {
245 ServicesInput::One(s) => vec![s],
246 ServicesInput::Several(s) => s,
247 }))
248}
249
250impl TryFrom<MachineInput> for Machine {
251 type Error = Error;
252
253 fn try_from(m: MachineInput) -> Result<Machine, Error> {
254 let kind = match (m.service, m.app) {
255 (Some(_), Some(_)) => return Err(Error::ServiceAndApp),
256 (Some(services), None) => Kind::Service(Services { services, with: m.with }),
257 (None, _) if !m.with.is_empty() => return Err(Error::WithWithoutService),
258 (None, Some(app)) => Kind::App(app),
259 (None, None) => Kind::Bare,
260 };
261 Ok(Machine {
262 os: m.os,
263 kind,
264 networks: m.networks,
265 aliases: m.aliases,
266 access: m.access,
267 ports: m.ports,
268 command: m.command,
269 setup: m.setup,
270 healthcheck: m.healthcheck,
271 depends_on: m.depends_on,
272 environment: m.environment,
273 files: m.files,
274 volumes: m.volumes,
275 disks: m.disks,
276 tmpfs: m.tmpfs,
277 resources: m.resources,
278 hardware: m.hardware,
279 dns: m.dns,
280 privileged: m.privileged,
281 read_only: m.read_only,
282 shm_size: m.shm_size,
283 sysctls: m.sysctls,
284 ulimits: m.ulimits,
285 rate_limit: m.rate_limit,
286 })
287 }
288}
289
290impl Machine {
291 /// Its custom app, when it is one.
292 #[must_use]
293 pub fn app(&self) -> Option<&App> {
294 match &self.kind {
295 Kind::App(a) => Some(a),
296 _ => None,
297 }
298 }
299}
300
301impl JsonSchema for Machine {
302 fn schema_name() -> std::borrow::Cow<'static, str> {
303 "Machine".into()
304 }
305
306 fn json_schema(g: &mut schemars::SchemaGenerator) -> schemars::Schema {
307 MachineInput::json_schema(g)
308 }
309}
310
311#[cfg(test)]
312mod tests;