Skip to main content

isoloom_plan/lower/
trail.rs

1//! Where each lowered field came from: a problem found on the lowered spec (its networks,
2//! addresses, volumes, checks) is reported at the field the author wrote. Indexed fields
3//! (a machine's ports, files, steps) are recorded as lowering writes them; renamed ones
4//! (`docker.image` from `app.image`, `vm.os` from `os`) follow fixed rules.
5
6use isoloom_spec::syntax::{self, FieldPath, Step};
7
8/// Lowered paths back to written ones.
9#[derive(Debug, Clone, Default, PartialEq, Eq)]
10pub struct Trail {
11    /// (lowered prefix, written path), the longest prefix wins.
12    entries: Vec<(String, String)>,
13}
14
15impl Trail {
16    fn record(&mut self, lowered: String, written: String) {
17        self.entries.push((lowered, written));
18    }
19
20    /// A machine's `kind` (`files` or `secrets`) item `lowered` came from its `files[written]`.
21    pub(super) fn file(&mut self, machine: &str, kind: &str, lowered: usize, written: usize) {
22        self.record(format!("machines.{machine}.{kind}[{lowered}]"), format!("machines.{machine}.files[{written}]"));
23    }
24
25    /// A machine's ports, steps, setup scripts and environment inputs.
26    pub(super) fn machine(&mut self, name: &str, m: &syntax::Machine) {
27        let at = format!("machines.{name}");
28        for (i, p) in m.ports.keys().enumerate() {
29            self.record(format!("{at}.ports[{i}]"), format!("{at}.ports.{p}"));
30        }
31        if let Some(app) = m.app() {
32            let vm_steps = app
33                .steps
34                .iter()
35                .enumerate()
36                .filter(|(_, s)| matches!(s, Step::Run { .. } | Step::Reboot | Step::Playbook(_)));
37            for (lowered, (written, _)) in vm_steps.enumerate() {
38                self.record(format!("{at}.vm.provision[{lowered}]"), format!("{at}.app.steps[{written}]"));
39            }
40        }
41        for i in 0..m.setup.len() {
42            self.record(format!("{at}.docker.init[{i}]"), format!("{at}.setup[{i}]"));
43        }
44        let inputs = m.environment.iter().filter(|(_, v)| matches!(v, syntax::EnvValue::Input(_)));
45        for (i, (var, _)) in inputs.enumerate() {
46            self.record(format!("{at}.inputs[{i}]"), format!("{at}.environment.{var}"));
47        }
48    }
49
50    /// The spec's inputs, and the secrets lowering made of the files they fill.
51    pub(super) fn inputs(&mut self, source: &syntax::Spec) {
52        for (i, name) in source.inputs.0.keys().enumerate() {
53            self.record(format!("inputs[{i}]"), format!("inputs.{name}"));
54            self.record(format!("secrets.{}", super::machine::input_secret(name.as_str())), format!("inputs.{name}"));
55        }
56    }
57
58    /// The written path of lowered path `path`.
59    #[must_use]
60    pub fn path(&self, path: &str) -> FieldPath {
61        let within = |prefix: &str| path == prefix || path.strip_prefix(prefix).is_some_and(|r| r.starts_with(['.', '[']));
62        let best = self
63            .entries
64            .iter()
65            .filter(|(lowered, _)| within(lowered))
66            .max_by_key(|(lowered, _)| lowered.len());
67        match best {
68            Some((lowered, written)) => FieldPath::new(format!("{written}{}", rename_inside(path.get(lowered.len()..).unwrap_or_default()))),
69            None => FieldPath::new(rename(path)),
70        }
71    }
72}
73
74/// The written name of a field inside a recorded one (a port's `publish`, a file's `target`).
75fn rename_inside(rest: &str) -> String {
76    match rest {
77        ".http" | ".tls" => ".protocol".into(),
78        ".fixed" => ".publish".into(),
79        ".target" => ".path".into(),
80        ".source" => String::new(),
81        other => other.to_owned(),
82    }
83}
84
85/// The written path of a lowered one with no recorded index: the fixed renames.
86fn rename(path: &str) -> String {
87    let parts: Vec<&str> = path.splitn(3, '.').collect();
88    match parts.as_slice() {
89        ["machines", m, rest] => machine_field(m, rest),
90        ["networks", n, rest] => match rest.split_once('.').map_or((*rest, ""), |(a, b)| (a, b)) {
91            ("docker", "") => format!("overrides.docker.networks.{n}"),
92            ("docker", inner) => format!("overrides.docker.networks.{n}.{inner}"),
93            ("external", _) => format!("overrides.docker.networks.{n}.external"),
94            _ => path.to_owned(),
95        },
96        ["volumes", v, "external"] => format!("overrides.docker.volumes.{v}.external"),
97        ["volumes", v, rest] if rest.starts_with("virtiofs") => format!("overrides.proxmox.volumes.{v}.{rest}"),
98        ["controller", "image", ..] => "overrides.vagrant.controller".into(),
99        _ => path.to_owned(),
100    }
101}
102
103/// A machine field's written path.
104fn machine_field(m: &str, rest: &str) -> String {
105    let at = format!("machines.{m}");
106    let (head, tail) = rest
107        .split_once(['.', '['])
108        .map_or((rest, ""), |(h, _)| (h, rest.get(h.len()..).unwrap_or_default()));
109    let image = |key: &str| -> String {
110        match key {
111            "vagrant" => format!("overrides.vagrant.machines.{m}.box"),
112            "vagrant_version" => format!("overrides.vagrant.machines.{m}.box_version"),
113            "qemu" | "hyperv" => format!("overrides.vagrant.machines.{m}.boxes.{key}"),
114            "box_url" | "box_server_url" | "winrm" | "winssh" => format!("overrides.vagrant.machines.{m}.{key}"),
115            "proxmox" => format!("overrides.proxmox.machines.{m}.image"),
116            "" => format!("overrides.vagrant.machines.{m}"),
117            cloud => format!("overrides.{cloud}.machines.{m}.image"),
118        }
119    };
120    match (head, tail) {
121        ("docker", t) => match t.trim_start_matches('.').split(['.', '[']).next().unwrap_or_default() {
122            "image" | "appliance" | "config" | "firmware" => format!("{at}.app{t}"),
123            "build" | "dockerfile" | "target" | "args" => format!("{at}.app.build"),
124            "init" => format!("{at}.setup{}", t.trim_start_matches(".init")),
125            "lxc" => format!("overrides.proxmox.machines.{m}.lxc"),
126            "idle" => format!("{at}.os"),
127            _ => format!("{at}.app"),
128        },
129        ("vm", t) => match t
130            .trim_start_matches('.')
131            .split_once('.')
132            .map_or((t.trim_start_matches('.'), ""), |(a, b)| (a, b))
133        {
134            ("os" | "", _) => format!("{at}.os"),
135            ("image", key) => image(key),
136            ("tpm", _) => format!("{at}.hardware.tpm"),
137            ("boot", "") => format!("{at}.hardware.boot"),
138            ("boot", inner) => format!("{at}.hardware.boot.{inner}"),
139            (field, _) if field.starts_with("provision") => format!("{at}.app.steps{}", field.trim_start_matches("provision")),
140            _ => format!("{at}.app"),
141        },
142        ("arch" | "gpus" | "devices" | "nested_virtualization", t) => format!("{at}.hardware.{head}{t}"),
143        ("working_dir", _) => format!("{at}.command.cwd"),
144        ("external", _) => format!("overrides.external.machines.{m}.ssh"),
145        ("more_addresses", t) => format!("{at}.networks{t}"),
146        _ => format!("{at}.{rest}"),
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use proptest::prelude::*;
153
154    use super::*;
155
156    /// A machine's name, as the spec allows them.
157    fn machine() -> impl Strategy<Value = String> {
158        "[a-z][a-z0-9-]{0,10}"
159    }
160
161    proptest! {
162        #[test]
163        fn a_recorded_file_points_at_its_written_index_and_field(
164            m in machine(),
165            (lowered, written) in (0usize..20, 0usize..20),
166            (inside, renamed) in prop::sample::select(vec![("", ""), (".uid", ".uid"), (".mode", ".mode"), (".target", ".path"), (".source", "")]),
167        ) {
168            let mut t = Trail::default();
169            t.file(&m, "secrets", lowered, written);
170            let path = t.path(&format!("machines.{m}.secrets[{lowered}]{inside}"));
171            prop_assert_eq!(path.to_string(), format!("machines.{m}.files[{written}]{renamed}"));
172        }
173
174        #[test]
175        fn the_longest_recorded_prefix_wins(m in machine(), i in 1usize..9) {
176            let mut t = Trail::default();
177            t.record(format!("machines.{m}.ports[{i}]"), format!("machines.{m}.ports.short"));
178            t.record(format!("machines.{m}.ports[{i}0]"), format!("machines.{m}.ports.long"));
179            prop_assert_eq!(t.path(&format!("machines.{m}.ports[{i}0].fixed")).to_string(), format!("machines.{m}.ports.long.publish"));
180            prop_assert_eq!(t.path(&format!("machines.{m}.ports[{i}].fixed")).to_string(), format!("machines.{m}.ports.short.publish"));
181        }
182
183        #[test]
184        fn a_path_nothing_renames_is_kept(head in prop::sample::select(vec!["checks", "reach", "message", "targets", "groups"]), rest in "(\\[[0-9]{1,2}\\])?(\\.[a-z_]{1,8}){0,3}") {
185            let path = format!("{head}{rest}");
186            prop_assert_eq!(Trail::default().path(&path).to_string(), path);
187        }
188    }
189
190    #[test]
191    fn renamed_fields_point_at_what_the_author_wrote() {
192        let t = Trail::default();
193        for (lowered, written) in [
194            ("machines.web.docker.image", "machines.web.app.image"),
195            ("machines.web.docker.dockerfile", "machines.web.app.build"),
196            ("machines.web.docker.init[1]", "machines.web.setup[1]"),
197            ("machines.web.docker.lxc", "overrides.proxmox.machines.web.lxc"),
198            ("machines.web.vm.os", "machines.web.os"),
199            ("machines.web.vm.tpm", "machines.web.hardware.tpm"),
200            ("machines.web.vm.image.vagrant", "overrides.vagrant.machines.web.box"),
201            ("machines.web.vm.image.aws", "overrides.aws.machines.web.image"),
202            ("machines.web.arch", "machines.web.hardware.arch"),
203            ("machines.web.working_dir", "machines.web.command.cwd"),
204            ("machines.web.networks.lab", "machines.web.networks.lab"),
205            ("networks.lab.docker.cidr", "overrides.docker.networks.lab.cidr"),
206            ("volumes.data.virtiofs.cache", "overrides.proxmox.volumes.data.virtiofs.cache"),
207            ("controller.image.vagrant", "overrides.vagrant.controller"),
208            ("networks.lab.docker", "overrides.docker.networks.lab"),
209            ("networks.lab.external", "overrides.docker.networks.lab.external"),
210            ("networks.lab.cidr", "networks.lab.cidr"),
211            ("volumes.data.external", "overrides.docker.volumes.data.external"),
212            ("volumes.data.persistent", "volumes.data.persistent"),
213            ("machines.web.docker.idle", "machines.web.os"),
214            ("machines.web.docker.args.X", "machines.web.app.build"),
215            ("machines.web.docker.generated", "machines.web.app"),
216            ("machines.web.vm", "machines.web.os"),
217            ("machines.web.vm.boot", "machines.web.hardware.boot"),
218            ("machines.web.vm.boot.network", "machines.web.hardware.boot.network"),
219            ("machines.web.vm.provision[2]", "machines.web.app.steps[2]"),
220            ("machines.web.vm.setup", "machines.web.app"),
221            ("machines.web.vm.image", "overrides.vagrant.machines.web"),
222            ("machines.web.vm.image.vagrant_version", "overrides.vagrant.machines.web.box_version"),
223            ("machines.web.vm.image.qemu", "overrides.vagrant.machines.web.boxes.qemu"),
224            ("machines.web.vm.image.winrm", "overrides.vagrant.machines.web.winrm"),
225            ("machines.web.vm.image.proxmox", "overrides.proxmox.machines.web.image"),
226            ("machines.web.gpus", "machines.web.hardware.gpus"),
227            ("machines.web.external.address", "overrides.external.machines.web.ssh"),
228            ("machines.web.more_addresses.lan", "machines.web.networks.lan"),
229            ("machines.web.environment.A", "machines.web.environment.A"),
230            ("checks[2].from", "checks[2].from"),
231        ] {
232            assert_eq!(t.path(lowered).as_str(), written, "{lowered}");
233        }
234    }
235
236    #[test]
237    fn lowering_records_a_machines_ports_vm_steps_setup_and_inputs() {
238        let lab = crate::testing::lab(
239            "version: 1\nname: t\ninputs: { A: { sensitive: false }, B: {} }\nmachines:\n  web:\n    os: debian@12\n    ports: { site: 80, admin: 8080 }\n    environment: { X: x, A: { input: A } }\n    setup: [s.sh, t.sh]\n    app: { steps: [{ packages: [curl] }, { run: a.sh }, { copy: { from: f, to: /f } }, reboot] }\n",
240        );
241        let mut t = Trail::default();
242        if let Some(m) = lab.source().machines.get("web") {
243            t.machine("web", m);
244        }
245        t.inputs(lab.source());
246        for (lowered, written) in [
247            ("machines.web.ports[1].publish", "machines.web.ports.admin.publish"),
248            ("machines.web.vm.provision[0]", "machines.web.app.steps[1]"),
249            ("machines.web.vm.provision[1]", "machines.web.app.steps[3]"),
250            ("machines.web.docker.init[1]", "machines.web.setup[1]"),
251            ("machines.web.inputs[0]", "machines.web.environment.A"),
252            ("inputs[1]", "inputs.B"),
253            ("secrets.input-b", "inputs.B"),
254        ] {
255            assert_eq!(t.path(lowered).as_str(), written, "{lowered}");
256        }
257    }
258
259    #[test]
260    fn recorded_items_point_at_their_written_index() {
261        let mut t = Trail::default();
262        t.file("web", "secrets", 0, 2);
263        t.record("machines.web.ports[0]".into(), "machines.web.ports.site".into());
264        assert_eq!(t.path("machines.web.secrets[0].target").as_str(), "machines.web.files[2].path");
265        assert_eq!(t.path("machines.web.ports[0].fixed").as_str(), "machines.web.ports.site.publish");
266        assert_eq!(t.path("machines.web.ports[0]").as_str(), "machines.web.ports.site");
267    }
268}