Skip to main content

isoloom_plan/environment/
image.rs

1//! What a machine runs from on a target: a container image (published, or built from the
2//! project's Dockerfile or from the steps Isoloom turns into one) or a VM's OS.
3
4use indexmap::IndexMap;
5use isoloom_spec::model::{BaseImage, Machine, Spec};
6
7use crate::configure::{ContainerProcess, Recipe};
8
9use super::MachineShape;
10use crate::images::{Images, tag_of};
11
12/// What a machine runs from.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum Image {
15    /// A container image.
16    Container {
17        /// The reference to run (pulled through a mirror when one is set; a built image's tag).
18        reference: String,
19        /// How the project builds it, if it does.
20        build: Option<Build>,
21    },
22    /// A VM's operating system.
23    Vm {
24        /// The OS id (`ubuntu-24.04`, `windows-2022`...).
25        os: String,
26    },
27}
28
29/// A container image the project builds.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum Build {
32    /// The project's own Dockerfile (`app.build`).
33    Own {
34        /// The build context, from the project folder.
35        context: String,
36        /// The Dockerfile, from the project folder, when not the context's own.
37        dockerfile: Option<String>,
38        /// The Dockerfile's stage to build, when not its last.
39        target: Option<String>,
40        /// The spec's build arguments.
41        args: IndexMap<String, String>,
42    },
43    /// A Dockerfile Isoloom generates from the machine's recipe ([`super::MachinePlan::recipe`]),
44    /// built with the project folder as its context.
45    Generated {
46        /// Where the generated Dockerfile is written, from the project folder
47        /// (`.isoloom/docker/build/<machine>/Dockerfile`).
48        dockerfile: String,
49        /// The image it starts `FROM`: the OS's official image, pinned by digest.
50        base: String,
51        /// What the container runs: Isoloom's runner as its entrypoint for its services, its own
52        /// command, or nothing (idle).
53        process: ContainerProcess,
54    },
55}
56
57/// Where a machine's generated Dockerfile is written, from the project folder.
58#[must_use]
59pub(crate) fn generated_dockerfile(machine: &str) -> String {
60    format!(".isoloom/docker/build/{machine}/Dockerfile")
61}
62
63/// What machine `name` runs from on the target, in `shape`, its configuration resolved as
64/// `recipe`.
65pub(super) fn of(spec: &Spec, shape: MachineShape, name: &str, m: &Machine, recipe: Option<&Recipe>) -> Option<Image> {
66    match shape {
67        MachineShape::Container => {
68            let d = m.docker.as_ref()?;
69            let tag = tag_of(spec, name, d.image.as_deref());
70            let own = d.build.as_ref().map(|context| Build::Own {
71                context: context.clone(),
72                dockerfile: d.dockerfile.clone(),
73                target: d.target.clone(),
74                args: d.args.clone(),
75            });
76            let generated = d.generated.as_ref().map(|g| Build::Generated {
77                dockerfile: generated_dockerfile(name),
78                base: g.base.clone(),
79                process: match (g.from, &g.after_start) {
80                    // An entry's official image keeps its own entrypoint and command, after the
81                    // entry's `start` steps when Isoloom's runner runs them.
82                    (BaseImage::Entry, Some(image)) => ContainerProcess::Started {
83                        entrypoint: image.entrypoint.clone(),
84                        command: image.command.clone(),
85                    },
86                    (BaseImage::Entry, None) => ContainerProcess::Image,
87                    // Lowering refuses a container whose services it can't run, so this resolves.
88                    _ => recipe
89                        .and_then(|r| ContainerProcess::resolve(r, m.command.as_ref(), !m.ports.is_empty()).ok())
90                        .unwrap_or(ContainerProcess::Idle),
91                },
92            });
93            Some(Image::Container {
94                reference: Images::of(spec).pull(&tag),
95                build: own.or(generated),
96            })
97        }
98        MachineShape::Vm => m.vm.as_ref().map(|vm| Image::Vm { os: vm.os.clone() }),
99        MachineShape::Supplied | MachineShape::External => None,
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use isoloom_spec::model::Target;
106
107    use super::{Build, Image};
108    use crate::configure::ContainerProcess;
109    use crate::testing;
110
111    #[test]
112    fn a_container_runs_its_image_its_build_or_the_one_isoloom_generates_and_a_vm_its_os() {
113        let env = testing::environment(
114            "version: 1\nname: lab\nmachines:\n  pub: { app: { image: nginx } }\n  own: { app: { build: { context: web, dockerfile: web/Dockerfile.dev, target: prod, args: { A: '1' } } } }\n  gen: { os: debian@12, app: { steps: [{ packages: [curl] }] } }\n  user: { access: true }\n",
115            Target::Docker,
116        );
117        let image = |m: &str| env.machines.get(m).and_then(|m| m.image.clone());
118        assert_eq!(
119            image("pub"),
120            Some(Image::Container {
121                reference: "nginx".into(),
122                build: None
123            })
124        );
125        let Some(Image::Container {
126            reference,
127            build: Some(Build::Own {
128                context,
129                dockerfile,
130                target,
131                args,
132            }),
133        }) = image("own")
134        else {
135            panic!("a build of the project's own")
136        };
137        assert_eq!(
138            (reference.as_str(), context.as_str(), dockerfile.as_deref(), target.as_deref()),
139            ("isoloom/lab-own", "web", Some("web/Dockerfile.dev"), Some("prod"))
140        );
141        assert_eq!(args.get("A").map(String::as_str), Some("1"));
142        let Some(Image::Container {
143            reference: generated,
144            build: Some(Build::Generated {
145                dockerfile: written,
146                base,
147                process,
148            }),
149        }) = image("gen")
150        else {
151            panic!("a generated build")
152        };
153        assert_eq!(
154            (generated.as_str(), written.as_str()),
155            ("isoloom/lab-gen", ".isoloom/docker/build/gen/Dockerfile")
156        );
157        assert!(base.starts_with("debian:12@sha256:"));
158        assert_eq!(process, ContainerProcess::Idle, "nothing to run: kept idle");
159        assert_eq!(image("user"), None, "a supplied machine runs from nothing of ours");
160        let vm = testing::environment("version: 1\nname: lab\nmachines:\n  m: { os: debian@12 }\n", Target::Vagrant);
161        assert_eq!(vm.machines.get("m").and_then(|m| m.image.clone()), Some(Image::Vm { os: "debian-12".into() }));
162    }
163}