use std::borrow::Cow;
use std::collections::BTreeMap;
use std::path::Path;
use std::sync::OnceLock;
use isoloom_catalogue::Catalogue;
use isoloom_spec::images::Table;
use isoloom_spec::model::{self, Target};
use isoloom_spec::syntax::{self, Code, FieldPath, Problem, Problems, ProjectPath};
use crate::lower::{Lowered, Scope, lower};
mod steps;
mod templates;
#[derive(Debug, Clone, PartialEq)]
pub struct Lab {
source: syntax::Spec,
images: Table,
templates: templates::Texts,
catalogue: &'static Catalogue,
memo: Memo,
}
fn built_in() -> &'static Catalogue {
static EMPTY: OnceLock<Catalogue> = OnceLock::new();
Catalogue::embedded().unwrap_or_else(|_| EMPTY.get_or_init(Catalogue::default))
}
const SCOPES: usize = Target::ALL.len() + 1;
#[derive(Debug, Clone, Default)]
struct Memo {
lowered: [OnceLock<Lowered>; SCOPES],
problems: OnceLock<Problems>,
}
impl PartialEq for Memo {
fn eq(&self, _: &Memo) -> bool {
true
}
}
fn slot(scope: Scope) -> Option<usize> {
match scope {
Scope::Lab => Some(0),
Scope::Target(t) => Target::ALL.iter().position(|x| *x == t).map(|i| i + 1),
}
}
impl Lab {
#[must_use]
pub fn new(source: syntax::Spec) -> Lab {
Lab {
source,
images: Table::default(),
templates: templates::Texts::new(),
catalogue: built_in(),
memo: Memo::default(),
}
}
pub fn parse(yaml: &str) -> Result<Lab, Problems> {
syntax::parse(yaml).map(Lab::new)
}
#[must_use]
pub fn with_images(mut self, images: Table) -> Lab {
self.images = images;
self
}
#[must_use]
pub fn with_templates(mut self, texts: BTreeMap<ProjectPath, String>) -> Lab {
self.templates = texts;
self
}
#[must_use]
pub fn template_paths(&self) -> Vec<ProjectPath> {
templates::paths(&self.source)
}
fn finish(&self, spec: &model::Spec) -> model::Spec {
templates::add(self.images.apply(spec), &self.templates)
}
#[must_use]
pub fn source(&self) -> &syntax::Spec {
&self.source
}
#[must_use]
pub fn catalogue(&self) -> &'static Catalogue {
self.catalogue
}
#[must_use]
pub fn lowered(&self, scope: Scope) -> Lowered {
let mut l = self.raw(scope).into_owned();
l.spec = self.finish(&l.spec);
l
}
fn raw(&self, scope: Scope) -> Cow<'_, Lowered> {
match slot(scope).and_then(|i| self.memo.lowered.get(i)) {
Some(cell) => Cow::Borrowed(cell.get_or_init(|| lower(&self.source, self.catalogue, scope))),
None => Cow::Owned(lower(&self.source, self.catalogue, scope)),
}
}
#[must_use]
pub fn for_target(&self, target: Target) -> model::Spec {
self.finish(&self.raw(Scope::Target(target)).spec)
}
#[must_use]
pub fn spec(&self) -> model::Spec {
self.finish(&self.raw(Scope::Lab).spec)
}
#[must_use]
pub fn possible(&self) -> Vec<Target> {
if self.source.cloud.is_some() {
return vec![Target::CloudServices];
}
if self.source.machines.is_empty() {
return Vec::new();
}
Target::ALL
.into_iter()
.filter(|t| *t != Target::CloudServices)
.filter(|t| crate::lower::missing_form(&self.source, self.catalogue, *t).is_none())
.filter(|t| *t != Target::Hybrid || isoloom_spec::targets::mixed(&self.spec()))
.filter(|t| self.source.targets.as_ref().is_none_or(|wanted| wanted.contains(t)))
.collect()
}
#[must_use]
pub fn refusal(&self, target: Target) -> Option<Problem> {
let first = self.raw(Scope::Target(target)).refusals.first().cloned();
first.map(|p| self.place(p))
}
#[must_use]
pub fn problems(&self) -> Problems {
self.memo.problems.get_or_init(|| self.find_problems()).clone()
}
fn find_problems(&self) -> Problems {
let mut out = syntax::check(&self.source);
if let Err(broken) = Catalogue::embedded() {
add(
&mut out,
self.services()
.map(|at| Problem::new(Code::Refused, at, format!("this Isoloom's catalogue doesn't load: {broken}"))),
);
}
let lowered = self.raw(Scope::Lab);
add(&mut out, lowered.problems.iter().cloned());
let model: Vec<Problem> = isoloom_spec::validate(&lowered.spec)
.iter()
.map(|p| translated(&lowered, p))
.filter(|p| !follows_from(&out, p, &self.source))
.collect();
add(&mut out, model);
for (key, target) in override_targets(&self.source.overrides) {
let on_target = self.raw(Scope::Target(target));
let found: Vec<Problem> = isoloom_spec::validate(&on_target.spec)
.iter()
.map(|p| under_overrides_of(key, translated(&on_target, p)))
.filter(|p| !follows_from(&out, p, &self.source))
.collect();
add(&mut out, found);
}
self.sorted(out)
}
#[must_use]
pub fn problems_with_files(&self, dir: &Path) -> Problems {
let mut out = self.problems();
let lowered = self.raw(Scope::Lab);
add(
&mut out,
isoloom_spec::validate_files(&lowered.spec, dir).iter().map(|p| translated(&lowered, p)),
);
add(&mut out, steps::step_files(&self.source, dir));
add(&mut out, steps::setting_files(&self.source, self.catalogue, dir));
self.sorted(out)
}
fn sorted(&self, problems: Problems) -> Problems {
let mut placed: Problems = problems.into_iter().map(|p| self.place(p)).collect();
placed.sort_by_key(|p| p.span.map(|s| (s.line, s.column)));
placed
}
}
impl Lab {
fn services(&self) -> impl Iterator<Item = FieldPath> + '_ {
self.source
.machines
.iter()
.filter(|(_, m)| matches!(m.kind, syntax::Kind::Service(_)))
.map(|(name, _)| FieldPath::new("machines").key(name).key("service"))
}
fn place(&self, mut p: Problem) -> Problem {
if p.span.is_none()
&& let Some((base, clone)) = self
.source
.clones
.iter()
.find_map(|(b, c)| c.iter().find(|n| p.path.within(&FieldPath::new("machines").key(n))).map(|n| (b, n)))
{
let rest = p.path.as_str().strip_prefix(&format!("machines.{clone}")).unwrap_or_default();
p.span = self.source.spans.find(&FieldPath::new(format!("machines.{base}{rest}")));
}
self.source.spans.place(p)
}
}
#[must_use]
pub fn problems_in(yaml: &str) -> Problems {
Lab::parse(yaml).map_or_else(|refused| refused, |lab| lab.problems())
}
fn add(out: &mut Problems, found: impl IntoIterator<Item = Problem>) {
for p in found {
if !out.iter().any(|o| o.path == p.path) {
out.push(p);
}
}
}
fn override_targets(o: &syntax::Overrides) -> Vec<(&'static str, Target)> {
[
("docker", o.docker.is_some(), Target::Docker),
("kubernetes", o.kubernetes.is_some(), Target::Kubernetes),
("proxmox", o.proxmox.is_some(), Target::Proxmox),
("esxi", o.esxi.is_some(), Target::Esxi),
]
.into_iter()
.filter(|(_, written, _)| *written)
.map(|(key, _, target)| (key, target))
.collect()
}
fn under_overrides_of(key: &str, mut p: Problem) -> Problem {
if let Some(rest) = p.path.as_str().strip_prefix("overrides.docker.")
&& key != "docker"
{
p.path = FieldPath::new(format!("overrides.{key}.{rest}"));
}
p
}
const FORM_FIELDS: [&str; 4] = ["os", "service", "with", "app"];
fn follows_from(found: &[Problem], p: &Problem, source: &syntax::Spec) -> bool {
let refused: Vec<&str> = source
.machines
.keys()
.filter(|m| {
let at = FieldPath::new("machines").key(m);
found.iter().any(|f| f.path == at || FORM_FIELDS.iter().any(|k| f.path.within(&at.key(k))))
})
.map(String::as_str)
.collect();
let services = source.machines.values().any(|m| matches!(m.kind, syntax::Kind::Service(_)));
p.path.as_str() == "machines" && !refused.is_empty()
|| refused
.iter()
.any(|m| p.path.within(&FieldPath::new("machines").key(m)) || p.reason.contains(&format!("`{m}`")))
|| services && p.path.as_str().starts_with("secrets.")
}
fn translated(lowered: &Lowered, p: &isoloom_spec::Problem) -> Problem {
let path = lowered.trail.path(&p.at);
Problem::new(p.code, path, p.message.clone())
}
#[cfg(test)]
mod tests {
use std::borrow::Cow;
use isoloom_spec::model::Target;
use super::*;
use crate::testing;
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";
#[test]
fn every_scope_is_lowered_once_and_kept() {
let lab = testing::lab(LAB);
let scopes = std::iter::once(Scope::Lab).chain(Target::ALL.into_iter().map(Scope::Target));
for scope in scopes {
assert!(matches!(lab.raw(scope), Cow::Borrowed(_)), "{scope:?}");
assert_eq!(
lab.raw(scope).spec,
lower(lab.source(), lab.catalogue(), scope).spec,
"{scope:?}: its own lowering"
);
}
}
#[test]
fn two_labs_of_one_spec_are_equal_whatever_each_has_worked_out() {
let (a, b) = (testing::lab(LAB), testing::lab(LAB));
assert_eq!(a.problems(), []);
assert_eq!(a, b);
assert_ne!(a, testing::lab(&LAB.replace("lan: 10,", "lan: 11,")));
}
#[test]
fn an_existing_network_is_checked_under_the_override_that_names_it() {
let lab = testing::lab(
"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",
);
let paths: Vec<String> = lab.problems().iter().map(|p| p.path.to_string()).collect();
assert!(paths.contains(&"overrides.proxmox.networks.lan.external".to_owned()), "{paths:?}");
assert!(paths.contains(&"overrides.docker.networks.lan.external".to_owned()), "{paths:?}");
}
#[test]
fn a_machine_the_authored_checks_refuse_shows_nothing_lowering_finds_under_it() {
let lab = testing::lab(
"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",
);
let paths: Vec<String> = lab.problems().iter().map(|p| p.path.to_string()).collect();
assert!(paths.contains(&"machines.db.os".to_owned()), "{paths:?}");
assert!(!paths.iter().any(|p| p.starts_with("machines.db.depends_on")), "{paths:?}");
assert!(
paths.iter().any(|p| p.starts_with("machines.web.depends_on")),
"an unrefused machine's: {paths:?}"
);
}
#[test]
fn a_secret_no_machine_gets_is_a_problem_unless_a_catalogue_service_may_read_it() {
let paths = |machines: &str| -> Vec<String> {
let lab = testing::lab(&format!("version: 1\nname: t\nsecrets: {{ db: {{ env: DB }} }}\nmachines:\n{machines}"));
lab.problems().iter().map(|p| p.path.to_string()).collect()
};
assert!(paths(" web: { app: { image: nginx } }\n").contains(&"secrets.db".to_owned()));
let with_service = paths(" db: { os: debian@12, service: postgres }\n");
assert!(!with_service.iter().any(|p| p.starts_with("secrets.")), "{with_service:?}");
}
#[test]
fn a_copy_step_takes_a_file_of_the_project_that_exists() {
let dir = std::env::temp_dir().join(format!("isoloom-plan-copy-{}", std::process::id()));
std::fs::create_dir_all(dir.join("site")).expect("a project folder");
let lab = testing::lab(
"version: 1\nname: t\nmachines:\n web: { os: debian@12, app: { steps: [{ copy: { from: site, to: /srv } }, { copy: { from: missing, to: /m } }] } }\n",
);
let found: Vec<(String, String)> = steps::step_files(lab.source(), &dir)
.into_iter()
.map(|p| (p.path.to_string(), p.reason))
.collect();
std::fs::remove_dir_all(&dir).ok();
assert_eq!(
found,
[(
"machines.web.app.steps[1].copy.from".to_owned(),
"`missing` doesn't exist in the project folder".to_owned()
)]
);
for outside in ["/etc/x", "a/../../b"] {
let refused = Lab::parse(&format!(
"version: 1\nname: t\nmachines:\n web: {{ os: debian@12, app: {{ steps: [{{ copy: {{ from: {outside}, to: /x }} }}] }} }}\n"
))
.expect_err("outside the project");
assert_eq!(refused[0].path.as_str(), "machines.web.app.steps[0].copy.from", "{outside}");
}
}
}