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;