Skip to main content

isoloom_render/run_manifest/
guest.rs

1//! Compose inside a VM (`docker-vm`, `cloud-docker`): the lab's containers, and the shell commands
2//! that start, check and read them there, as the Compose backend works them out
3//! ([`GuestCompose`]). The backend that makes the VM runs each command through its own way in
4//! (`vagrant ssh`, or [`ssh_run`] to a VM Terraform made) and adds the VM itself. A VM Terraform
5//! made is readied the same way on every platform first ([`into_vm`]): [`setup_script`] (Docker)
6//! piped over SSH, then the project's archive. Shared here, below the backends, so none depends on
7//! another and Compose in a VM exists once.
8
9use 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/// A shell command line run in the VM, as one step of the VM's backend. One line for the VM's
21/// login shell, with no single quote: `vagrant ssh -c` wraps it in single quotes.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct GuestCommand {
24    /// The step's id.
25    pub id: StepId,
26    /// What the runner says when it starts.
27    pub note: Option<String>,
28    /// The command line.
29    pub command: Template,
30    /// How long it may run.
31    pub timeout_s: u32,
32}
33
34impl GuestCommand {
35    /// The step running the command through `way_in` (the VM's: a `vagrant ssh` argv, an
36    /// `ssh.run`), with its id, note and time limit.
37    ///
38    /// # Errors
39    ///
40    /// What `way_in` refuses.
41    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/// A check runner run in the VM.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct GuestRunner {
53    /// Its id.
54    pub id: Name,
55    /// Where it runs, for the report.
56    pub from: String,
57    /// What it reports, in order.
58    pub checks: Vec<Check>,
59    /// The command that runs it.
60    pub command: GuestCommand,
61    /// What clears its one-off container once it ran out of time.
62    pub on_timeout: Vec<GuestCommand>,
63}
64
65impl GuestRunner {
66    /// The runner, its commands through `way_in`.
67    ///
68    /// # Errors
69    ///
70    /// What `way_in` refuses.
71    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/// The lab's containers in a VM, and the commands that run them there.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct GuestCompose {
85    /// The containers and Compose's own services, as the `docker` target's manifest has them,
86    /// each published port `fixed` at its `publish` number (the VM's backend says where it
87    /// answers).
88    pub machines: Vec<Machine>,
89    /// The lab's networks, named as Compose makes them in the VM.
90    pub networks: Vec<Network>,
91    /// What the containers read at start (inputs, secrets), each delivered as an environment
92    /// variable to the [`start`](Self::start) steps creating the containers that read it: the
93    /// VM's way in forwards it ([`GuestCompose::forwarded`]).
94    pub slots: Vec<SecretSlot>,
95    /// Non-secret values the commands read (whether derived checks run).
96    pub params: Vec<Param>,
97    /// Everything up and healthy, then each leaf job attached.
98    pub start: Vec<GuestCommand>,
99    /// When the containers count as ready: each start step succeeded.
100    pub ready: Vec<Condition>,
101    /// Before the check runners (the access machine's stand-in).
102    pub check_setup: Vec<GuestCommand>,
103    /// The check runners.
104    pub runners: Vec<GuestRunner>,
105    /// Reads the containers: `docker compose ps --all --format json`, nothing before Docker is in.
106    pub status: GuestCommand,
107}
108
109impl GuestCompose {
110    /// The variables the start steps read from their environment, which the VM's way in must
111    /// forward into it (SSH's `SendEnv`, accepted by the VM's `AcceptEnv`): sorted, each once.
112    #[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    /// The machines, each published port answering at `address` (this computer's loopback, the
127    /// VM's address).
128    #[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
141/// The way into a VM Terraform made: `ssh.run` to `host` (`${outputs.ip}`) as `user`, with the
142/// runner's key.
143pub 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
148/// How long the set-up script may take: cloud-init's end, then Docker's install.
149const SETUP_S: u32 = 1800;
150
151/// A VM Terraform made, readied for Compose through `way_in`: `setup` (the generated
152/// [`setup_script`], beside the manifest) piped into `sudo sh -s`, then the project archived
153/// (`project.archive`, `root` its folder from the manifest's) and piped into `tar` in the VM's
154/// project folder, replacing what an earlier copy left.
155///
156/// # Errors
157///
158/// What `way_in` refuses, or a path or id the manifest refuses (a bug: they are fixed).
159pub 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/// The Docker host's set-up script for `env`, run as root (`sudo sh -s`): once cloud-init is done,
185/// the spec's constraints (proxy, mirrors, NTP), then Docker from Docker's own script unless it is
186/// there, and published ports answering whatever the containers' routes
187/// ([`PUBLISHED_FROM_OUTSIDE`]). Running it again changes nothing.
188#[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        // A literal path, nothing a shell expands: no variable can turn it into `rm -rf /`.
270        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}