Skip to main content

isoloom_render/
backend.rs

1//! What every backend is: a target, the capabilities it has, and a rendering of a planned
2//! [`Environment`] into files. Defined here, below every backend crate, so each can implement
3//! it; `isoloom-targets` keeps the registry.
4
5use isoloom_manifest::Manifest;
6use isoloom_plan::{Capabilities, Environment, refusal};
7use isoloom_spec::model::Target;
8
9use crate::{Error, GeneratedFile, Result};
10
11/// The files a backend writes, paths relative to the project folder.
12pub type Files = Vec<GeneratedFile>;
13
14/// A target's renderer.
15pub trait Backend: Sync {
16    /// The target it renders.
17    fn target(&self) -> Target;
18
19    /// What it can render.
20    fn capabilities(&self) -> Capabilities;
21
22    /// The environment's files.
23    ///
24    /// # Errors
25    ///
26    /// [`Error::Refused`] (without writing anything) when the environment needs a capability
27    /// the backend lacks; [`Error::Unsupported`] for a limit of the backend's own target.
28    fn render(&self, env: &Environment) -> Result<Files>;
29
30    /// Why it would refuse `env`, if it would: the plan's requirements against its
31    /// capabilities, without rendering.
32    fn refusal(&self, env: &Environment) -> Option<String> {
33        refusal(&env.requirements, &self.capabilities()).map(|r| r.to_string())
34    }
35
36    /// The run manifest the Isothrum runner executes (`.isoloom/<target>/run.json`), for a target
37    /// that has one; its `lab.message` is left to the caller, who fills it from the resolved
38    /// snapshot. `None` for the targets that have none yet.
39    ///
40    /// # Errors
41    ///
42    /// The backend's [`Error`] when the environment can't run on its target.
43    fn run_manifest(&self, _env: &Environment) -> Result<Option<Manifest>> {
44        Ok(None)
45    }
46
47    /// Every run manifest of the target, each with the folder it goes in under the target's own:
48    /// `docker-vm` has two, one per way it makes its VM (`docker-vm/run.json` with Vagrant,
49    /// `docker-vm/proxmox/run.json` with Terraform). By default, [`Backend::run_manifest`]'s, in
50    /// the target's folder.
51    ///
52    /// # Errors
53    ///
54    /// The backend's [`Error`] when the environment can't run on its target.
55    fn run_manifests(&self, env: &Environment) -> Result<Vec<Placed>> {
56        Ok(self.run_manifest(env)?.into_iter().map(Placed::here).collect())
57    }
58}
59
60/// A run manifest, and the folder it goes in under its target's (`.isoloom/<target>/[<sub>/]`).
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct Placed {
63    /// The folder under the target's, if any (`proxmox`).
64    pub sub: Option<&'static str>,
65    /// The manifest, its message left to the caller.
66    pub manifest: Manifest,
67}
68
69impl Placed {
70    /// `manifest`, in the target's own folder.
71    #[must_use]
72    pub fn here(manifest: Manifest) -> Placed {
73        Placed { sub: None, manifest }
74    }
75
76    /// `manifest`, in the folder `sub` under the target's.
77    #[must_use]
78    pub fn under(sub: &'static str, manifest: Manifest) -> Placed {
79        Placed { sub: Some(sub), manifest }
80    }
81}
82
83/// `env` checked against `caps`, for a backend's `render`.
84///
85/// # Errors
86///
87/// [`Error::Refused`] with the first of the plan's requirements `caps` can't meet.
88pub fn check(target: Target, env: &Environment, caps: &Capabilities) -> Result<()> {
89    match refusal(&env.requirements, caps) {
90        Some(refusal) => Err(Error::Refused { target, refusal }),
91        None => Ok(()),
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use isoloom_plan::{Capabilities, Feature, Requirement};
98    use isoloom_spec::model::Target;
99
100    use super::{Backend, Files, check};
101    use crate::{Error, Result, testing};
102
103    /// A backend with no capability at all.
104    struct Bare;
105
106    impl Backend for Bare {
107        fn target(&self) -> Target {
108            Target::Kubernetes
109        }
110
111        fn capabilities(&self) -> Capabilities {
112            Capabilities::of([])
113        }
114
115        fn render(&self, _env: &isoloom_plan::Environment) -> Result<Files> {
116            Ok(Vec::new())
117        }
118    }
119
120    const ALIASED: &str =
121        "version: 1\nname: t\nnetworks: { lan: { cidr: 10.9.0.0/24 } }\nmachines:\n  web: { networks: { lan: 10 }, aliases: [www], app: { image: nginx } }\n";
122
123    #[test]
124    fn a_backend_refuses_the_first_requirement_it_lacks_and_says_why() {
125        let env = testing::env(ALIASED, Target::Kubernetes);
126        let refused = check(Target::Kubernetes, &env, &Capabilities::of([]));
127        assert!(
128            matches!(&refused, Err(Error::Refused { target: Target::Kubernetes, refusal }) if refusal.requirement == Requirement::MachineAliases),
129            "{refused:?}"
130        );
131        assert_eq!(check(Target::Kubernetes, &env, &Capabilities::of([Feature::MachineAliases])), Ok(()));
132        assert_eq!(
133            Bare.refusal(&env).as_deref(),
134            Some("machine `aliases` (dotted DNS names) aren't Kubernetes Service names")
135        );
136        assert_eq!(Bare.run_manifest(&env), Ok(None));
137        assert_eq!(Bare.run_manifests(&env), Ok(Vec::new()));
138    }
139}