isoloom_plan/configure/
tools.rs1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
17#[non_exhaustive]
18pub enum Configure {
19 Shell,
21 CloudInit,
23 Ansible,
25}
26
27impl Configure {
28 pub const ALL: [Configure; 3] = [Configure::Shell, Configure::CloudInit, Configure::Ansible];
30
31 #[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 #[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 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 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#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
98pub struct OutPath(String);
99
100impl OutPath {
101 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq, Hash)]
124#[non_exhaustive]
125pub enum Handoff {
126 Image {
128 dockerfile: OutPath,
130 },
131 Inline {
134 script: OutPath,
136 },
137 UserData {
139 document: OutPath,
141 },
142 Controller {
144 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}