1use isoloom_spec::syntax::{self, FieldPath, Step};
7
8#[derive(Debug, Clone, Default, PartialEq, Eq)]
10pub struct Trail {
11 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 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 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 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 #[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
74fn 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
85fn 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
103fn 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 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}