1use std::collections::BTreeSet;
10use std::num::NonZeroU32;
11
12use isoloom_manifest::{
13 Action, Check, CheckRunner, Condition, Delivery, Error as ManifestError, Machine, Name, Network, Param, PathTemplate, PublishMode, RelPath, Run,
14 SecretSlot, Step, StepId, Template,
15};
16use isoloom_plan::Environment;
17
18use crate::steps::{CLOUD_INIT_WAIT, INSTALL_DOCKER, PROJECT_DIR, PUBLISHED_FROM_OUTSIDE};
19
20#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct GuestCommand {
24 pub id: StepId,
26 pub note: Option<String>,
28 pub command: Template,
30 pub timeout_s: u32,
32}
33
34impl GuestCommand {
35 pub fn step(&self, way_in: &dyn Fn(&Template) -> Result<Run, ManifestError>) -> Result<Step, ManifestError> {
42 let mut s = Step::action(self.id.clone(), Action::SshRun);
43 s.run = way_in(&self.command)?;
44 s.note.clone_from(&self.note);
45 s.timeout_s = NonZeroU32::new(self.timeout_s);
46 Ok(s)
47 }
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct GuestRunner {
53 pub id: Name,
55 pub from: String,
57 pub checks: Vec<Check>,
59 pub command: GuestCommand,
61 pub on_timeout: Vec<GuestCommand>,
63}
64
65impl GuestRunner {
66 pub fn runner(&self, way_in: &dyn Fn(&Template) -> Result<Run, ManifestError>) -> Result<CheckRunner, ManifestError> {
72 Ok(CheckRunner {
73 id: self.id.clone(),
74 from: self.from.clone(),
75 checks: self.checks.clone(),
76 step: self.command.step(way_in)?,
77 on_timeout: self.on_timeout.iter().map(|c| c.step(way_in)).collect::<Result<_, _>>()?,
78 })
79 }
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct GuestCompose {
85 pub machines: Vec<Machine>,
89 pub networks: Vec<Network>,
91 pub slots: Vec<SecretSlot>,
95 pub params: Vec<Param>,
97 pub start: Vec<GuestCommand>,
99 pub ready: Vec<Condition>,
101 pub check_setup: Vec<GuestCommand>,
103 pub runners: Vec<GuestRunner>,
105 pub status: GuestCommand,
107}
108
109impl GuestCompose {
110 #[must_use]
113 pub fn forwarded(&self) -> Vec<String> {
114 let vars: BTreeSet<String> = self
115 .slots
116 .iter()
117 .flat_map(|s| s.deliver.iter())
118 .filter_map(|d| match d {
119 Delivery::Env { var, .. } => Some(var.to_string()),
120 _ => None,
121 })
122 .collect();
123 vars.into_iter().collect()
124 }
125
126 #[must_use]
129 pub fn machines_at(&self, address: &Template) -> Vec<Machine> {
130 let mut machines = self.machines.clone();
131 for p in machines.iter_mut().flat_map(|m| m.ports.iter_mut()) {
132 if let Some(publish) = &mut p.publish {
133 publish.mode = PublishMode::Fixed;
134 publish.address = Some(address.clone());
135 }
136 }
137 machines
138 }
139}
140
141pub fn ssh_run(host: Template, user: &str) -> impl Fn(&Template) -> Result<Run, ManifestError> {
144 let user = Template::text(user);
145 move |command: &Template| super::action_with(Action::SshRun, [("host", host.clone()), ("user", user.clone()), ("command", command.clone())])
146}
147
148const SETUP_S: u32 = 1800;
150
151pub fn into_vm(way_in: &dyn Fn(&Template) -> Result<Run, ManifestError>, setup: &RelPath, root: &str) -> Result<Vec<Step>, ManifestError> {
160 let docker = GuestCommand {
161 id: StepId::parse("docker")?,
162 note: Some("Installing Docker in the VM…".into()),
163 command: Template::text("sudo sh -s"),
164 timeout_s: SETUP_S,
165 };
166 let mut docker = docker.step(way_in)?;
167 docker.stdin = Some(PathTemplate::relative(setup));
168 let mut archive = Step::action(StepId::parse("archive")?, Action::ProjectArchive);
169 archive.run = super::action_with(Action::ProjectArchive, [("root", Template::text(root))])?;
170 archive.note = Some("Archiving the project…".into());
171 let copy = GuestCommand {
172 id: StepId::parse("project")?,
173 note: Some("Copying the project into the VM…".into()),
174 command: Template::text(&format!(
175 "sudo rm -rf {PROJECT_DIR} && sudo mkdir -p {PROJECT_DIR} && sudo tar -xzf - -C {PROJECT_DIR}"
176 )),
177 timeout_s: 600,
178 };
179 let mut copy = copy.step(way_in)?;
180 copy.stdin = Some(PathTemplate::in_state_dir(&RelPath::parse("project.tgz")?));
181 Ok(vec![docker, archive, copy])
182}
183
184#[must_use]
189pub fn setup_script(env: &Environment) -> String {
190 let prelude = crate::steps::linux(env, "", &isoloom_plan::Step::Prelude).unwrap_or_default();
191 format!(
192 "#!/bin/sh\n{}# The Docker host's set-up, piped into `sudo sh -s` over SSH by its run manifest.\nset -e\n{CLOUD_INIT_WAIT}\n{prelude}{INSTALL_DOCKER}\n{PUBLISHED_FROM_OUTSIDE}",
193 crate::header("#")
194 )
195}
196
197#[cfg(test)]
198mod tests {
199 use isoloom_manifest::{Argv, EnvName, HostArgv, Identifier, NonEmpty, Program, SlotKind, StepSelection};
200
201 use super::*;
202
203 fn command(id: &str, text: &str) -> GuestCommand {
204 GuestCommand {
205 id: StepId::parse(id).unwrap(),
206 note: Some("Starting…".into()),
207 command: Template::text(text),
208 timeout_s: 60,
209 }
210 }
211
212 fn vagrant_ssh(command: &Template) -> Result<Run, ManifestError> {
213 let args = vec![Template::text("ssh"), Template::text("-c"), command.clone()];
214 Ok(Run::Argv(HostArgv::Every(Argv::new(Program::parse("vagrant")?, args))))
215 }
216
217 #[test]
218 fn a_command_becomes_a_step_of_the_vms_way_in() {
219 let s = command("start", "docker compose up").step(&vagrant_ssh).unwrap();
220 assert_eq!(s.id.as_str(), "start");
221 assert_eq!(s.note.as_deref(), Some("Starting…"));
222 assert_eq!(s.timeout_s.map(NonZeroU32::get), Some(60));
223 let Run::Argv(HostArgv::Every(argv)) = &s.run else { panic!("{:?}", s.run) };
224 assert_eq!(argv.args().last().map(ToString::to_string).as_deref(), Some("docker compose up"));
225 }
226
227 #[test]
228 fn a_vm_terraform_made_gets_docker_then_the_project_over_ssh() {
229 let way_in = ssh_run(Template::text("10.0.0.9"), "isoloom");
230 let steps = into_vm(&way_in, &RelPath::parse("setup.sh").unwrap(), "../../..").unwrap();
231 let ids: Vec<&str> = steps.iter().map(|s| s.id.as_str()).collect();
232 assert_eq!(ids, ["docker", "archive", "project"]);
233 assert_eq!(steps[0].stdin.as_ref().map(ToString::to_string).as_deref(), Some("setup.sh"));
234 assert_eq!(
235 steps[2].stdin.as_ref().map(ToString::to_string).as_deref(),
236 Some("${run.state_dir}/project.tgz")
237 );
238 let Run::Action { action: Action::SshRun, with } = &steps[2].run else {
239 panic!("{:?}", steps[2].run)
240 };
241 let pairs: Vec<(String, String)> = with.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
242 assert_eq!(
243 pairs,
244 [
245 (
246 "command".into(),
247 "sudo rm -rf /opt/isoloom && sudo mkdir -p /opt/isoloom && sudo tar -xzf - -C /opt/isoloom".into()
248 ),
249 ("host".into(), "10.0.0.9".into()),
250 ("user".into(), "isoloom".into())
251 ]
252 );
253 let Run::Action {
254 action: Action::ProjectArchive,
255 with: root,
256 } = &steps[1].run
257 else {
258 panic!()
259 };
260 assert_eq!(root.values().map(ToString::to_string).collect::<Vec<_>>(), ["../../.."]);
261 }
262
263 #[test]
264 fn the_old_copy_cleared_is_always_the_fixed_project_folder() {
265 let way_in = ssh_run(Template::text("10.0.0.9"), "isoloom");
266 let steps = into_vm(&way_in, &RelPath::parse("setup.sh").unwrap(), "../../..").unwrap();
267 let Run::Action { with, .. } = &steps[2].run else { panic!() };
268 let command = with.iter().find(|(k, _)| k.as_str() == "command").map(|(_, v)| v.to_string()).unwrap();
269 assert!(command.starts_with("sudo rm -rf /opt/isoloom && "), "{command}");
271 assert!(!command.contains('$') && !command.contains('*') && !command.contains('`'), "{command}");
272 assert_eq!(PROJECT_DIR, "/opt/isoloom");
273 }
274
275 #[test]
276 fn the_setup_script_waits_for_cloud_init_then_installs_docker() {
277 let env = crate::testing::env(
278 "version: 1\nname: t\nnetworks: { lan: { cidr: 10.9.0.0/24 } }\nmachines:\n web: { networks: { lan: 10 }, app: { image: nginx } }\n",
279 isoloom_spec::model::Target::Docker,
280 );
281 let script = setup_script(&env);
282 assert!(script.starts_with("#!/bin/sh\n# Generated by isoloom"), "{script}");
283 assert!(
284 script.ends_with(&format!(
285 "set -e\ncloud-init status --wait >/dev/null 2>&1 || true\n{INSTALL_DOCKER}\n{PUBLISHED_FROM_OUTSIDE}"
286 )),
287 "{script}"
288 );
289 }
290
291 #[test]
292 fn the_variables_forwarded_are_the_slots_each_once() {
293 let slot = |name: &str, var: &str| SecretSlot {
294 name: Identifier::parse(name).unwrap(),
295 kind: SlotKind::Input,
296 required: false,
297 sensitive: true,
298 machines: Vec::new(),
299 providers: BTreeSet::new(),
300 source: None,
301 default: None,
302 deliver: NonEmpty::one(Delivery::Env {
303 var: EnvName::parse(var).unwrap(),
304 steps: StepSelection::Every,
305 }),
306 };
307 let guest = GuestCompose {
308 machines: Vec::new(),
309 networks: Vec::new(),
310 slots: vec![slot("TOKEN", "TOKEN"), slot("FLAG", "FLAG"), slot("again", "TOKEN")],
311 params: Vec::new(),
312 start: Vec::new(),
313 ready: Vec::new(),
314 check_setup: Vec::new(),
315 runners: Vec::new(),
316 status: command("ps", "docker compose ps"),
317 };
318 assert_eq!(guest.forwarded(), ["FLAG", "TOKEN"]);
319 }
320}