1use 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#[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
31fn 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
38const SCOPES: usize = Target::ALL.len() + 1;
40
41#[derive(Debug, Clone, Default)]
45struct Memo {
46 lowered: [OnceLock<Lowered>; SCOPES],
48 problems: OnceLock<Problems>,
50}
51
52impl PartialEq for Memo {
53 fn eq(&self, _: &Memo) -> bool {
56 true
57 }
58}
59
60fn 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 #[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 pub fn parse(yaml: &str) -> Result<Lab, Problems> {
87 syntax::parse(yaml).map(Lab::new)
88 }
89
90 #[must_use]
93 pub fn with_images(mut self, images: Table) -> Lab {
94 self.images = images;
95 self
96 }
97
98 #[must_use]
102 pub fn with_templates(mut self, texts: BTreeMap<ProjectPath, String>) -> Lab {
103 self.templates = texts;
104 self
105 }
106
107 #[must_use]
109 pub fn template_paths(&self) -> Vec<ProjectPath> {
110 templates::paths(&self.source)
111 }
112
113 fn finish(&self, spec: &model::Spec) -> model::Spec {
115 templates::add(self.images.apply(spec), &self.templates)
116 }
117
118 #[must_use]
120 pub fn source(&self) -> &syntax::Spec {
121 &self.source
122 }
123
124 #[must_use]
126 pub fn catalogue(&self) -> &'static Catalogue {
127 self.catalogue
128 }
129
130 #[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 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 #[must_use]
148 pub fn for_target(&self, target: Target) -> model::Spec {
149 self.finish(&self.raw(Scope::Target(target)).spec)
150 }
151
152 #[must_use]
154 pub fn spec(&self) -> model::Spec {
155 self.finish(&self.raw(Scope::Lab).spec)
156 }
157
158 #[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 #[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 #[must_use]
187 pub fn problems(&self) -> Problems {
188 self.memo.problems.get_or_init(|| self.find_problems()).clone()
189 }
190
191 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 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 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 #[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 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 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 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#[must_use]
276pub fn problems_in(yaml: &str) -> Problems {
277 Lab::parse(yaml).map_or_else(|refused| refused, |lab| lab.problems())
278}
279
280fn 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
290fn 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
305fn 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
316const FORM_FIELDS: [&str; 4] = ["os", "service", "with", "app"];
319
320fn 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
342fn 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 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 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 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}