Skip to main content

isoloom_plan/
lab.rs

1//! A lab: a spec as its author wrote it, ready to be planned for any target. It lowers the spec
2//! once per target (with the runner's image table, when one is given), says which targets can
3//! run it, and validates it: the authored spec's own checks, then those of its lowered form, each
4//! problem reported at the field the author wrote.
5
6use std::borrow::Cow;
7use std::collections::BTreeMap;
8use std::path::Path;
9use std::sync::OnceLock;
10
11use isoloom_catalogue::Catalogue;
12use isoloom_spec::images::Table;
13use isoloom_spec::model::{self, Target};
14use isoloom_spec::syntax::{self, Code, FieldPath, Problem, Problems, ProjectPath};
15
16use crate::lower::{Lowered, Scope, lower};
17
18mod steps;
19mod templates;
20
21/// An authored spec, ready to plan.
22#[derive(Debug, Clone, PartialEq)]
23pub struct Lab {
24    source: syntax::Spec,
25    images: Table,
26    templates: templates::Texts,
27    catalogue: &'static Catalogue,
28    memo: Memo,
29}
30
31/// The catalogue built into Isoloom, or an empty one when it doesn't load (its own tests rule
32/// that out; [`Lab::problems`] says so at each service it would have resolved).
33fn built_in() -> &'static Catalogue {
34    static EMPTY: OnceLock<Catalogue> = OnceLock::new();
35    Catalogue::embedded().unwrap_or_else(|_| EMPTY.get_or_init(Catalogue::default))
36}
37
38/// [`Scope::Lab`], then one scope per target of [`Target::ALL`].
39const SCOPES: usize = Target::ALL.len() + 1;
40
41/// What a lab works out from its spec, once each: generating reads them many times (the
42/// resolved snapshot asks every target's refusal, for each target generated). Neither depends on
43/// the image table, so [`Lab::with_images`] keeps them.
44#[derive(Debug, Clone, Default)]
45struct Memo {
46    /// The spec lowered for each scope, before the image table: the lab's, then each target's.
47    lowered: [OnceLock<Lowered>; SCOPES],
48    /// [`Lab::problems`].
49    problems: OnceLock<Problems>,
50}
51
52impl PartialEq for Memo {
53    /// A memo is no part of a lab's value: two labs of the same spec are equal, whatever each
54    /// has worked out so far.
55    fn eq(&self, _: &Memo) -> bool {
56        true
57    }
58}
59
60/// The memo's slot of `scope`.
61fn slot(scope: Scope) -> Option<usize> {
62    match scope {
63        Scope::Lab => Some(0),
64        Scope::Target(t) => Target::ALL.iter().position(|x| *x == t).map(|i| i + 1),
65    }
66}
67
68impl Lab {
69    /// The lab of an authored spec.
70    #[must_use]
71    pub fn new(source: syntax::Spec) -> Lab {
72        Lab {
73            source,
74            images: Table::default(),
75            templates: templates::Texts::new(),
76            catalogue: built_in(),
77            memo: Memo::default(),
78        }
79    }
80
81    /// The lab of `yaml`, written in the authored format.
82    ///
83    /// # Errors
84    ///
85    /// The problems that stop the parser, coded and placed.
86    pub fn parse(yaml: &str) -> Result<Lab, Problems> {
87        syntax::parse(yaml).map(Lab::new)
88    }
89
90    /// The lab with the runner's image table: each VM's image from it unless the spec's
91    /// overrides give one, and the access machine the spec leaves to the runner.
92    #[must_use]
93    pub fn with_images(mut self, images: Table) -> Lab {
94        self.images = images;
95        self
96    }
97
98    /// The lab with the texts of its project's templates (read by the caller that generates,
99    /// from [`Lab::template_paths`]): the renderers install them filled in. A template whose text
100    /// isn't given fails the machine's configuration where it is applied, saying so.
101    #[must_use]
102    pub fn with_templates(mut self, texts: BTreeMap<ProjectPath, String>) -> Lab {
103        self.templates = texts;
104        self
105    }
106
107    /// The project files the lab's steps fill in as templates, each once.
108    #[must_use]
109    pub fn template_paths(&self) -> Vec<ProjectPath> {
110        templates::paths(&self.source)
111    }
112
113    /// `spec` with the image table and the templates' texts applied.
114    fn finish(&self, spec: &model::Spec) -> model::Spec {
115        templates::add(self.images.apply(spec), &self.templates)
116    }
117
118    /// The spec as written.
119    #[must_use]
120    pub fn source(&self) -> &syntax::Spec {
121        &self.source
122    }
123
124    /// The catalogue its services resolve in: Isoloom's own.
125    #[must_use]
126    pub fn catalogue(&self) -> &'static Catalogue {
127        self.catalogue
128    }
129
130    /// The spec lowered for `scope`, the image table applied.
131    #[must_use]
132    pub fn lowered(&self, scope: Scope) -> Lowered {
133        let mut l = self.raw(scope).into_owned();
134        l.spec = self.finish(&l.spec);
135        l
136    }
137
138    /// The spec lowered for `scope` before the image table, lowered once.
139    fn raw(&self, scope: Scope) -> Cow<'_, Lowered> {
140        match slot(scope).and_then(|i| self.memo.lowered.get(i)) {
141            Some(cell) => Cow::Borrowed(cell.get_or_init(|| lower(&self.source, self.catalogue, scope))),
142            None => Cow::Owned(lower(&self.source, self.catalogue, scope)),
143        }
144    }
145
146    /// The spec as `target` runs it.
147    #[must_use]
148    pub fn for_target(&self, target: Target) -> model::Spec {
149        self.finish(&self.raw(Scope::Target(target)).spec)
150    }
151
152    /// The spec as the lab itself: every form, no target's own facts.
153    #[must_use]
154    pub fn spec(&self) -> model::Spec {
155        self.finish(&self.raw(Scope::Lab).spec)
156    }
157
158    /// The targets every machine has the form for, narrowed by the spec's `targets:`.
159    #[must_use]
160    pub fn possible(&self) -> Vec<Target> {
161        if self.source.cloud.is_some() {
162            return vec![Target::CloudServices];
163        }
164        if self.source.machines.is_empty() {
165            return Vec::new();
166        }
167        Target::ALL
168            .into_iter()
169            .filter(|t| *t != Target::CloudServices)
170            .filter(|t| crate::lower::missing_form(&self.source, self.catalogue, *t).is_none())
171            .filter(|t| *t != Target::Hybrid || isoloom_spec::targets::mixed(&self.spec()))
172            .filter(|t| self.source.targets.as_ref().is_none_or(|wanted| wanted.contains(t)))
173            .collect()
174    }
175
176    /// Why `target` can't run the lab, found while lowering: the first machine it has no form
177    /// for, or a VM step it doesn't render yet, as a `refused` problem at the field.
178    #[must_use]
179    pub fn refusal(&self, target: Target) -> Option<Problem> {
180        let first = self.raw(Scope::Target(target)).refusals.first().cloned();
181        first.map(|p| self.place(p))
182    }
183
184    /// The spec's problems: its own, then its lowered form's at the fields the author wrote, in
185    /// file order.
186    #[must_use]
187    pub fn problems(&self) -> Problems {
188        self.memo.problems.get_or_init(|| self.find_problems()).clone()
189    }
190
191    /// [`Lab::problems`], worked out.
192    fn find_problems(&self) -> Problems {
193        let mut out = syntax::check(&self.source);
194        if let Err(broken) = Catalogue::embedded() {
195            add(
196                &mut out,
197                self.services()
198                    .map(|at| Problem::new(Code::Refused, at, format!("this Isoloom's catalogue doesn't load: {broken}"))),
199            );
200        }
201        let lowered = self.raw(Scope::Lab);
202        // The catalogue's problems (and the entries' ports and data a machine conflicts with)
203        // say what is wrong with a service machine better than its lowered form would.
204        add(&mut out, lowered.problems.iter().cloned());
205        let model: Vec<Problem> = isoloom_spec::validate(&lowered.spec)
206            .iter()
207            .map(|p| translated(&lowered, p))
208            .filter(|p| !follows_from(&out, p, &self.source))
209            .collect();
210        add(&mut out, model);
211        // What only a target's overrides set (an existing network or volume) is checked once
212        // lowered for that target.
213        for (key, target) in override_targets(&self.source.overrides) {
214            let on_target = self.raw(Scope::Target(target));
215            let found: Vec<Problem> = isoloom_spec::validate(&on_target.spec)
216                .iter()
217                .map(|p| under_overrides_of(key, translated(&on_target, p)))
218                .filter(|p| !follows_from(&out, p, &self.source))
219                .collect();
220            add(&mut out, found);
221        }
222        self.sorted(out)
223    }
224
225    /// [`Lab::problems`], and the project paths the spec names that don't exist in `dir`.
226    #[must_use]
227    pub fn problems_with_files(&self, dir: &Path) -> Problems {
228        let mut out = self.problems();
229        let lowered = self.raw(Scope::Lab);
230        add(
231            &mut out,
232            isoloom_spec::validate_files(&lowered.spec, dir).iter().map(|p| translated(&lowered, p)),
233        );
234        add(&mut out, steps::step_files(&self.source, dir));
235        add(&mut out, steps::setting_files(&self.source, self.catalogue, dir));
236        self.sorted(out)
237    }
238
239    /// Problems placed and in file order: a clone's at its counted machine.
240    fn sorted(&self, problems: Problems) -> Problems {
241        let mut placed: Problems = problems.into_iter().map(|p| self.place(p)).collect();
242        placed.sort_by_key(|p| p.span.map(|s| (s.line, s.column)));
243        placed
244    }
245}
246
247impl Lab {
248    /// The `service` field of each machine that runs catalogue services.
249    fn services(&self) -> impl Iterator<Item = FieldPath> + '_ {
250        self.source
251            .machines
252            .iter()
253            .filter(|(_, m)| matches!(m.kind, syntax::Kind::Service(_)))
254            .map(|(name, _)| FieldPath::new("machines").key(name).key("service"))
255    }
256
257    /// `p` placed, a clone's field (`machines.ws-01.os`) at its counted machine's (`machines.ws.os`).
258    fn place(&self, mut p: Problem) -> Problem {
259        if p.span.is_none()
260            && let Some((base, clone)) = self
261                .source
262                .clones
263                .iter()
264                .find_map(|(b, c)| c.iter().find(|n| p.path.within(&FieldPath::new("machines").key(n))).map(|n| (b, n)))
265        {
266            let rest = p.path.as_str().strip_prefix(&format!("machines.{clone}")).unwrap_or_default();
267            p.span = self.source.spans.find(&FieldPath::new(format!("machines.{base}{rest}")));
268        }
269        self.source.spans.place(p)
270    }
271}
272
273/// Every problem an author sees in `yaml`: the parser's when it can't read it, else the lab's
274/// (what `isoloom validate` shows, files aside).
275#[must_use]
276pub fn problems_in(yaml: &str) -> Problems {
277    Lab::parse(yaml).map_or_else(|refused| refused, |lab| lab.problems())
278}
279
280/// `found` added to `out`, unless a problem at the same field is already there (the authored
281/// spec's own check says it better).
282fn add(out: &mut Problems, found: impl IntoIterator<Item = Problem>) {
283    for p in found {
284        if !out.iter().any(|o| o.path == p.path) {
285            out.push(p);
286        }
287    }
288}
289
290/// The targets whose `overrides` block the spec writes and validation must see: each with the
291/// block's key.
292fn override_targets(o: &syntax::Overrides) -> Vec<(&'static str, Target)> {
293    [
294        ("docker", o.docker.is_some(), Target::Docker),
295        ("kubernetes", o.kubernetes.is_some(), Target::Kubernetes),
296        ("proxmox", o.proxmox.is_some(), Target::Proxmox),
297        ("esxi", o.esxi.is_some(), Target::Esxi),
298    ]
299    .into_iter()
300    .filter(|(_, written, _)| *written)
301    .map(|(key, _, target)| (key, target))
302    .collect()
303}
304
305/// `p` at the override block it came from: the trail names Docker's for an existing network or
306/// volume, which every other target writes under its own key.
307fn under_overrides_of(key: &str, mut p: Problem) -> Problem {
308    if let Some(rest) = p.path.as_str().strip_prefix("overrides.docker.")
309        && key != "docker"
310    {
311        p.path = FieldPath::new(format!("overrides.{key}.{rest}"));
312    }
313    p
314}
315
316/// The fields that say what a machine is: a problem there leaves the lowered machine a guess,
317/// so what lowering finds under it isn't shown.
318const FORM_FIELDS: [&str; 4] = ["os", "service", "with", "app"];
319
320/// Whether lowered problem `p` only follows from a machine the authored spec's own checks
321/// already refused (a catalogue service, a machine without its OS): it would say the same thing
322/// less clearly. A problem elsewhere on the machine (a bad variable name) refuses nothing: its
323/// other fields are still checked.
324fn follows_from(found: &[Problem], p: &Problem, source: &syntax::Spec) -> bool {
325    let refused: Vec<&str> = source
326        .machines
327        .keys()
328        .filter(|m| {
329            let at = FieldPath::new("machines").key(m);
330            found.iter().any(|f| f.path == at || FORM_FIELDS.iter().any(|k| f.path.within(&at.key(k))))
331        })
332        .map(String::as_str)
333        .collect();
334    let services = source.machines.values().any(|m| matches!(m.kind, syntax::Kind::Service(_)));
335    p.path.as_str() == "machines" && !refused.is_empty()
336        || refused
337            .iter()
338            .any(|m| p.path.within(&FieldPath::new("machines").key(m)) || p.reason.contains(&format!("`{m}`")))
339        || services && p.path.as_str().starts_with("secrets.")
340}
341
342/// A lowered spec's problem, at the field the author wrote.
343fn translated(lowered: &Lowered, p: &isoloom_spec::Problem) -> Problem {
344    let path = lowered.trail.path(&p.at);
345    Problem::new(p.code, path, p.message.clone())
346}
347
348#[cfg(test)]
349mod tests {
350    use std::borrow::Cow;
351
352    use isoloom_spec::model::Target;
353
354    use super::*;
355    use crate::testing;
356
357    /// A lab each target lowers its own way: an existing network on Docker and on Proxmox.
358    const LAB: &str = "version: 1\nname: t\nnetworks: { lan: { cidr: 10.9.0.0/24 }, corp: { cidr: 10.8.0.0/24 } }\nmachines:\n  web: { networks: { lan: 10, corp: 10 }, os: debian@12, app: { image: nginx, steps: [{ run: a.sh }] } }\noverrides: { docker: { networks: { corp: { external: corp } } }, proxmox: { networks: { corp: { external: vmbr9 } } } }\n";
359
360    #[test]
361    fn every_scope_is_lowered_once_and_kept() {
362        let lab = testing::lab(LAB);
363        let scopes = std::iter::once(Scope::Lab).chain(Target::ALL.into_iter().map(Scope::Target));
364        for scope in scopes {
365            assert!(matches!(lab.raw(scope), Cow::Borrowed(_)), "{scope:?}");
366            assert_eq!(
367                lab.raw(scope).spec,
368                lower(lab.source(), lab.catalogue(), scope).spec,
369                "{scope:?}: its own lowering"
370            );
371        }
372    }
373
374    #[test]
375    fn two_labs_of_one_spec_are_equal_whatever_each_has_worked_out() {
376        let (a, b) = (testing::lab(LAB), testing::lab(LAB));
377        assert_eq!(a.problems(), []);
378        assert_eq!(a, b);
379        assert_ne!(a, testing::lab(&LAB.replace("lan: 10,", "lan: 11,")));
380    }
381
382    #[test]
383    fn an_existing_network_is_checked_under_the_override_that_names_it() {
384        let lab = testing::lab(
385            "version: 1\nname: t\nnetworks: { lan: { cidr: 10.9.0.0/24 } }\nmachines:\n  web: { networks: { lan: 10 }, os: debian@12 }\noverrides:\n  proxmox: { networks: { lan: { external: 'vmbr 1' } } }\n  docker: { networks: { lan: { external: 'corp net' } } }\n",
386        );
387        let paths: Vec<String> = lab.problems().iter().map(|p| p.path.to_string()).collect();
388        assert!(paths.contains(&"overrides.proxmox.networks.lan.external".to_owned()), "{paths:?}");
389        assert!(paths.contains(&"overrides.docker.networks.lan.external".to_owned()), "{paths:?}");
390    }
391
392    #[test]
393    fn a_machine_the_authored_checks_refuse_shows_nothing_lowering_finds_under_it() {
394        // `db` has no OS: the authored check says so, and what its lowered form lacks isn't shown.
395        let lab = testing::lab(
396            "version: 1\nname: t\nnetworks: { lan: { cidr: 10.9.0.0/24 } }\nmachines:\n  db: { networks: { lan: 10 }, app: { steps: [{ run: a.sh }] }, depends_on: [ghost] }\n  web: { networks: { lan: 11 }, app: { image: nginx }, depends_on: [nowhere] }\n",
397        );
398        let paths: Vec<String> = lab.problems().iter().map(|p| p.path.to_string()).collect();
399        assert!(paths.contains(&"machines.db.os".to_owned()), "{paths:?}");
400        assert!(!paths.iter().any(|p| p.starts_with("machines.db.depends_on")), "{paths:?}");
401        assert!(
402            paths.iter().any(|p| p.starts_with("machines.web.depends_on")),
403            "an unrefused machine's: {paths:?}"
404        );
405    }
406
407    #[test]
408    fn a_secret_no_machine_gets_is_a_problem_unless_a_catalogue_service_may_read_it() {
409        let paths = |machines: &str| -> Vec<String> {
410            let lab = testing::lab(&format!("version: 1\nname: t\nsecrets: {{ db: {{ env: DB }} }}\nmachines:\n{machines}"));
411            lab.problems().iter().map(|p| p.path.to_string()).collect()
412        };
413        assert!(paths("  web: { app: { image: nginx } }\n").contains(&"secrets.db".to_owned()));
414        let with_service = paths("  db: { os: debian@12, service: postgres }\n");
415        assert!(!with_service.iter().any(|p| p.starts_with("secrets.")), "{with_service:?}");
416    }
417
418    #[test]
419    fn a_copy_step_takes_a_file_of_the_project_that_exists() {
420        let dir = std::env::temp_dir().join(format!("isoloom-plan-copy-{}", std::process::id()));
421        std::fs::create_dir_all(dir.join("site")).expect("a project folder");
422        let lab = testing::lab(
423            "version: 1\nname: t\nmachines:\n  web: { os: debian@12, app: { steps: [{ copy: { from: site, to: /srv } }, { copy: { from: missing, to: /m } }] } }\n",
424        );
425        let found: Vec<(String, String)> = steps::step_files(lab.source(), &dir)
426            .into_iter()
427            .map(|p| (p.path.to_string(), p.reason))
428            .collect();
429        std::fs::remove_dir_all(&dir).ok();
430        assert_eq!(
431            found,
432            [(
433                "machines.web.app.steps[1].copy.from".to_owned(),
434                "`missing` doesn't exist in the project folder".to_owned()
435            )]
436        );
437        // A path outside the project is refused when the spec is read.
438        for outside in ["/etc/x", "a/../../b"] {
439            let refused = Lab::parse(&format!(
440                "version: 1\nname: t\nmachines:\n  web: {{ os: debian@12, app: {{ steps: [{{ copy: {{ from: {outside}, to: /x }} }}] }} }}\n"
441            ))
442            .expect_err("outside the project");
443            assert_eq!(refused[0].path.as_str(), "machines.web.app.steps[0].copy.from", "{outside}");
444        }
445    }
446}