Skip to main content

isoloom_plan/configure/
tools.rs

1//! The CM tools and how each infrastructure tool hands off to them (design ยง2.4): the container targets bake
2//! the recipe into the image; Vagrant runs shell (PowerShell on Windows) or Ansible from the
3//! controller; Terraform's targets carry cloud-init (PowerShell user data on Windows), shell or
4//! Ansible; existing hosts get Ansible or shell. Each target's default is today's carrier, so
5//! choosing nothing changes no output.
6
7use std::fmt;
8use std::str::FromStr;
9
10use isoloom_spec::model::{Shape, Target};
11
12use super::family::Family;
13use crate::error::{Error, Result};
14
15/// The CM tool that writes a VM's configuration.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
17#[non_exhaustive]
18pub enum Configure {
19    /// The shell library (`configure.sh`), or the PowerShell library on Windows.
20    Shell,
21    /// The shell library carried in cloud-init's user data, run at first boot.
22    CloudInit,
23    /// Ansible, from Isoloom's Linux controller: `site.yml` and its roles.
24    Ansible,
25}
26
27impl Configure {
28    /// Every tool, in the order `--configure` lists them.
29    pub const ALL: [Configure; 3] = [Configure::Shell, Configure::CloudInit, Configure::Ansible];
30
31    /// How `--configure` and `overrides.<target>.configure` write it.
32    #[must_use]
33    pub fn id(self) -> &'static str {
34        match self {
35            Configure::Shell => "shell",
36            Configure::CloudInit => "cloud-init",
37            Configure::Ansible => "ansible",
38        }
39    }
40
41    /// The tool `target` configures a VM of `family` with when none is chosen: today's carrier.
42    /// `None` for a target whose machines are containers (the recipe is baked into the image)
43    /// or cloud services.
44    #[must_use]
45    pub fn default_on(target: Target, family: Family) -> Option<Configure> {
46        match target.needs() {
47            Shape::Docker | Shape::Cloud => None,
48            Shape::External => Some(Configure::Ansible),
49            Shape::Vm | Shape::VmOrLxc if target != Target::Vagrant && family != Family::Windows => Some(Configure::CloudInit),
50            Shape::Vm | Shape::VmOrLxc | Shape::Either => Some(Configure::Shell),
51        }
52    }
53
54    /// Whether `target` can hand its VMs to this tool, else why not.
55    ///
56    /// # Errors
57    ///
58    /// [`Error::Unpaired`]: the container targets bake the recipe into the image, Vagrant's
59    /// cloud-init is experimental and its boxes don't ship it, existing hosts never boot for it.
60    pub fn pairs_with(self, target: Target) -> Result<()> {
61        let why = match (target.needs(), self) {
62            (Shape::Docker, _) => "a container target bakes the recipe into its image: `--configure` doesn't apply",
63            (Shape::Cloud, _) => "cloud services have no machines to configure",
64            (_, Configure::CloudInit) if matches!(target, Target::Vagrant | Target::Hybrid) => {
65                "Vagrant's cloud-init is experimental and VirtualBox-only, and boxes don't ship cloud-init"
66            }
67            (Shape::External, Configure::CloudInit) => "existing hosts don't boot for cloud-init: it runs at a machine's first boot",
68            _ => return Ok(()),
69        };
70        Err(Error::Unpaired { tool: self, target, why })
71    }
72}
73
74impl fmt::Display for Configure {
75    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76        f.write_str(self.id())
77    }
78}
79
80impl FromStr for Configure {
81    type Err = Error;
82
83    /// The tool `id` names (`cloud-init`).
84    ///
85    /// # Errors
86    ///
87    /// [`Error::UnknownTool`], naming the tools.
88    fn from_str(id: &str) -> Result<Configure> {
89        Configure::ALL
90            .into_iter()
91            .find(|c| c.id() == id)
92            .ok_or_else(|| Error::UnknownTool(id.to_owned()))
93    }
94}
95
96/// A generated file's path, relative to the project folder (`.isoloom/docker/build/web/Dockerfile`).
97#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
98pub struct OutPath(String);
99
100impl OutPath {
101    /// The path, when it is relative and stays inside the project folder.
102    #[must_use]
103    pub fn new(path: &str) -> Option<OutPath> {
104        let inside = !path.is_empty() && !path.starts_with('/') && !path.split('/').any(|p| p == ".." || p.is_empty());
105        inside.then(|| OutPath(path.to_owned()))
106    }
107
108    /// The path as written.
109    #[must_use]
110    pub fn as_str(&self) -> &str {
111        &self.0
112    }
113}
114
115impl fmt::Display for OutPath {
116    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
117        f.write_str(&self.0)
118    }
119}
120
121/// How the infrastructure tool reaches a machine's configuration: the file the CM renderer wrote, and who
122/// runs it.
123#[derive(Debug, Clone, PartialEq, Eq, Hash)]
124#[non_exhaustive]
125pub enum Handoff {
126    /// Baked into the container's image: the engine builds this Dockerfile.
127    Image {
128        /// The Dockerfile.
129        dockerfile: OutPath,
130    },
131    /// Run inline by the infrastructure tool's own provisioner (Vagrant's `shell` or `powershell`), or by
132    /// the runner's `ssh.run`/`winrm.run` after `apply`.
133    Inline {
134        /// The script.
135        script: OutPath,
136    },
137    /// Read at first boot from the machine's user data (cloud-init, cloudbase-init).
138    UserData {
139        /// The cloud-config document.
140        document: OutPath,
141    },
142    /// Run by Isoloom's controller once every machine is up.
143    Controller {
144        /// The playbook (`site.yml`).
145        playbook: OutPath,
146    },
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    #[test]
154    fn each_target_defaults_to_todays_carrier() {
155        assert_eq!(Configure::default_on(Target::Docker, Family::Debian), None);
156        assert_eq!(Configure::default_on(Target::Vagrant, Family::Debian), Some(Configure::Shell));
157        assert_eq!(Configure::default_on(Target::Vagrant, Family::Windows), Some(Configure::Shell));
158        assert_eq!(Configure::default_on(Target::Proxmox, Family::RedHat), Some(Configure::CloudInit));
159        assert_eq!(Configure::default_on(Target::CloudVm, Family::Windows), Some(Configure::Shell));
160        assert_eq!(Configure::default_on(Target::External, Family::Debian), Some(Configure::Ansible));
161        assert_eq!(Configure::default_on(Target::Hybrid, Family::Debian), Some(Configure::Shell));
162    }
163
164    #[test]
165    fn a_tool_a_target_cant_hand_off_to_is_refused_with_why() {
166        let e = Configure::CloudInit.pairs_with(Target::Vagrant).unwrap_err();
167        assert!(
168            e.to_string()
169                .starts_with("vagrant: `--configure cloud-init`: Vagrant's cloud-init is experimental"),
170            "{e}"
171        );
172        assert!(Configure::Ansible.pairs_with(Target::Docker).is_err() && Configure::CloudInit.pairs_with(Target::External).is_err());
173        for target in [Target::Proxmox, Target::Esxi, Target::CloudVm] {
174            assert!(Configure::ALL.into_iter().all(|c| c.pairs_with(target).is_ok()), "{target:?}");
175        }
176        assert!(Configure::Ansible.pairs_with(Target::Vagrant).is_ok() && Configure::Shell.pairs_with(Target::External).is_ok());
177    }
178
179    #[test]
180    fn a_tool_reads_back_as_itself() {
181        for c in Configure::ALL {
182            assert_eq!(c.id().parse::<Configure>().ok(), Some(c));
183        }
184        assert!(
185            "puppet"
186                .parse::<Configure>()
187                .unwrap_err()
188                .to_string()
189                .contains("`shell`, `cloud-init` or `ansible`")
190        );
191    }
192
193    #[test]
194    fn a_generated_path_stays_in_the_project() {
195        assert_eq!(
196            OutPath::new(".isoloom/docker/build/web/Dockerfile").map(|p| p.to_string()).as_deref(),
197            Some(".isoloom/docker/build/web/Dockerfile")
198        );
199        for bad in ["", "/etc/x", "../x", "a//b"] {
200            assert_eq!(OutPath::new(bad), None, "{bad}");
201        }
202    }
203}