Skip to main content

isoloom_spec/
model.rs

1//! The isoloom format, version 1: what an environment looks like from outside (machines,
2//! networks, who reaches whom, the ports that answer) and, per machine, how each kind of
3//! target produces it (`docker:` and/or `vm:`).
4
5mod boot;
6mod bridged;
7mod checks;
8mod cloud;
9mod configuration;
10mod constraints;
11mod hardware;
12mod implementation;
13mod ipv6;
14mod machine;
15mod machine_file;
16mod network;
17mod private_dns;
18mod process;
19mod secrets;
20mod shared;
21mod storage;
22mod target;
23mod virtiofs;
24
25pub use boot::{Boot, BootDevice, Firmware, FirmwareFormat, NetworkBoot};
26pub(crate) use checks::is_yaml_file;
27pub use checks::{Check, Declared, Expect};
28pub use cloud::{CloudProvider, CloudServices};
29pub use configuration::Configuration;
30pub use constraints::{Constraints, Mirrors, Proxy};
31pub use hardware::{DEFAULT_GPU_DRIVER, Device, DeviceFull, DeviceInput, GpuCount, Gpus, GpusFull, GpusInput};
32pub use implementation::{
33    Appliance, BaseImage, Controller, Dns, DockerImpl, Generated, KNOWN_OS, OwnProcess, Provision, REBOOT_STEP, VmImage, VmImpl, Winrm, is_reboot,
34};
35pub use ipv6::{Ipv6, Ipv6Addresses};
36pub(crate) use machine::scalars;
37pub use machine::{Arch, Machine, Octets, Port, Protocol, Resources, ScalarSchema, Seal};
38pub use machine_file::{DEFAULT_FILE_MODE, MachineFile};
39pub use network::{Bridged, Network, NetworkDocker, Reach, Tc, Vlan};
40pub use private_dns::EnvironmentDns;
41pub use process::{CommandLine, Healthcheck, HttpCheck, seconds};
42pub use secrets::{MachineSecret, SECRETS_DIR, SecretMount, SecretSource, after_drive, is_drive_path, secret_variable};
43pub use shared::{External, Group, Shared, SharedDocker, SharedVm, SharedVolume, TOOL_RECIPES, Tool, ULIMITS, Ulimit};
44pub use storage::{Disk, Filesystem, Mount, MountPart, Tmpfs, TmpfsMount};
45pub use target::{Shape, Target};
46pub use virtiofs::{MAX_VIRTIOFS, Virtiofs, VirtiofsCache, mapping_id};
47
48use indexmap::IndexMap;
49use schemars::JsonSchema;
50use serde::{Deserialize, Serialize};
51
52/// An environment spec. Field order in the file is kept (machines start in the order written).
53#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
54#[serde(deny_unknown_fields)]
55pub struct Spec {
56    /// The version of the `isoloom.yml` format the file is written in: `1`, the only one so far.
57    pub version: u32,
58    /// The environment's id, in kebab case (`supplier-portal`): it names its containers, VMs,
59    /// networks and cloud resources, and the environment in `isoloom status`.
60    pub name: String,
61    /// The environment's networks, by name: each an address block the machines on it share.
62    #[serde(default)]
63    pub networks: IndexMap<String, Network>,
64    /// Allowed traffic between networks; everything else between networks is blocked.
65    #[serde(default)]
66    pub reach: Vec<Reach>,
67    /// The environment's names in a private DNS zone: off unless `private: true`. See
68    /// [`EnvironmentDns`].
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub dns: Option<EnvironmentDns>,
71    /// Names of the values a runner provides at launch (a token, a licence key): from the
72    /// environment of `isoloom run`, or a profile's `inputs`. A machine receives the ones its own
73    /// `inputs` lists. Never baked into images.
74    #[serde(default)]
75    pub inputs: Vec<String>,
76    /// The inputs the authored spec marks `sensitive: false` (an URL, a mode): shown in a runner's
77    /// events and logs. Every other input is masked. Set by lowering only.
78    #[serde(default, skip_serializing_if = "Vec::is_empty")]
79    #[schemars(skip)]
80    pub plain_inputs: Vec<String>,
81    /// Secrets, by kebab-case name: where `isoloom run` reads each value on the runner (`env`,
82    /// `file`, `vault`, `sops`). A machine lists the ones it gets in its own `secrets`, as
83    /// files. Values never reach a generated file, the resolved snapshot or a log.
84    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
85    pub secrets: IndexMap<String, SecretSource>,
86    /// The environment's machines, by name, started in the order written (after their
87    /// `depends_on`).
88    #[serde(default)]
89    pub machines: IndexMap<String, Machine>,
90    /// An environment of cloud services rather than machines (IAM users and roles, functions,
91    /// buckets, queues...): a Terraform module in the project, applied into the user's own cloud
92    /// account. A spec has either `cloud:` or machines.
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub cloud: Option<CloudServices>,
95    /// Volumes shared between machines: a machine volume with one of these names is this
96    /// volume, wherever it mounts it. One declared here can also outlive `isoloom down`
97    /// (`persistent: true`) or be one that exists already (`external: <name>`).
98    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
99    pub volumes: IndexMap<String, SharedVolume>,
100    /// What the place it runs in imposes: an HTTP proxy, registry and package mirrors, time
101    /// servers. Usually set by a profile, one customer's network at a time.
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub constraints: Option<Constraints>,
104    /// Machine fields shared by every machine (a machine's own value wins). `docker:` and `vm:`
105    /// here only complete an implementation a machine declares itself.
106    #[serde(default)]
107    pub common: Option<Shared>,
108    /// Named sets of machines sharing fields (over `common:`, under the machine's own; the most
109    /// specific group wins). They also become Ansible inventory groups, and `isoloom exec`
110    /// targets.
111    #[serde(default)]
112    pub groups: IndexMap<String, Group>,
113    /// Provisioning across machines, once every machine is up: Ansible playbooks run from a
114    /// controller on the environment's networks, with an inventory Isoloom writes.
115    #[serde(default)]
116    pub provision: Vec<Provision>,
117    /// The controller Isoloom adds for `provision:` (and checks run from it): a small VM, sized
118    /// and boxed like a machine. By default 1 CPU, 512 MB and a minimal Alpine box on Vagrant.
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub controller: Option<Controller>,
121    /// Black-box checks that prove the behavior on every target: scripts, or declared probes
122    /// (`http`, `tcp`, `exec`, `script`) run from a machine of the environment. Isoloom adds
123    /// derived checks of its own from `ports` and `reach`.
124    #[serde(default)]
125    pub checks: Vec<Check>,
126    /// Narrows the targets derived from the implementations (e.g. not tested on Proxmox yet).
127    #[serde(default)]
128    pub targets: Option<Vec<Target>>,
129    /// Shown once the environment is up (`isoloom run`, `isoloom message`): how to start, in
130    /// Markdown. `{{ machines.web.addresses.front }}`-style placeholders take any value of the
131    /// resolved snapshot (`isoloom inspect` shows the paths).
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub message: Option<String>,
134    /// Observers beside the environment, on every network, that aren't machines of it (not
135    /// in `reach`, `resources` or the derived checks): a toolbox to look from (`shell`), or any
136    /// container image.
137    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
138    pub tools: IndexMap<String, Tool>,
139    /// Base machine name -> its clones, for machines that had a `count`. Filled when parsing.
140    #[serde(skip)]
141    #[schemars(skip)]
142    pub clones: IndexMap<String, Vec<String>>,
143}
144
145impl Spec {
146    /// The private DNS zone's domain, when `dns.private` is on.
147    #[must_use]
148    pub fn private_domain(&self) -> Option<String> {
149        self.dns.as_ref().and_then(|d| d.private_domain(&self.name))
150    }
151
152    /// The storage behind machine `machine`'s volume `volume`: the shared volume's own name,
153    /// else one of its own (`<machine>-<volume>`).
154    #[must_use]
155    pub fn volume_key(&self, machine: &str, volume: &str) -> String {
156        if self.volumes.contains_key(volume) {
157            volume.to_owned()
158        } else {
159            format!("{machine}-{volume}")
160        }
161    }
162
163    /// The machines that mount shared volume `volume`, in spec order.
164    #[must_use]
165    pub fn volume_users(&self, volume: &str) -> Vec<&str> {
166        self.machines
167            .iter()
168            .filter(|(_, m)| m.volumes.contains_key(volume))
169            .map(|(n, _)| n.as_str())
170            .collect()
171    }
172
173    /// The machine holding shared volume `volume`: its `owner`, else its first user.
174    #[must_use]
175    pub fn volume_owner(&self, volume: &str) -> Option<&str> {
176        match self.volumes.get(volume)?.owner.as_deref() {
177            Some(o) => Some(o),
178            None => self.volume_users(volume).first().copied(),
179        }
180    }
181
182    /// The spec with `{{ cloud.outputs.<name> }}` filled in (checks and message), from a
183    /// deployed cloud-services environment's outputs.
184    #[must_use]
185    pub fn with_cloud_outputs(&self, outputs: &IndexMap<String, String>) -> Spec {
186        let fill = |t: &str| {
187            outputs
188                .iter()
189                .fold(t.to_owned(), |acc, (k, v)| acc.replace(&format!("{{{{ cloud.outputs.{k} }}}}"), v))
190        };
191        let mut s = self.clone();
192        for c in &mut s.checks {
193            match c {
194                Check::Script(p) => *p = fill(p),
195                Check::Declared(d) => {
196                    for f in [&mut d.http, &mut d.tcp, &mut d.exec, &mut d.body, &mut d.contains].into_iter().flatten() {
197                        *f = fill(f);
198                    }
199                    for v in d.headers.values_mut() {
200                        *v = fill(v);
201                    }
202                }
203            }
204        }
205        s.message = s.message.as_deref().map(fill);
206        s
207    }
208
209    /// The message of a cloud-services environment, its outputs filled in.
210    #[must_use]
211    pub fn cloud_message(&self, outputs: &IndexMap<String, String>) -> Option<String> {
212        self.with_cloud_outputs(outputs).message
213    }
214}
215
216/// Whether `value` is its type's default: a field left out when it holds it.
217pub(crate) fn is_default<T: Default + PartialEq>(value: &T) -> bool {
218    *value == T::default()
219}
220
221#[cfg(test)]
222mod tests;