Skip to main content

isoloom_render/configure/
dockerfile.rs

1//! The Dockerfile Isoloom generates from a container's recipe (design §2.2–§2.3): `FROM` its
2//! OS's official image (pinned by digest), the project folder (`/opt/isoloom`, the same path a
3//! VM has) as its working folder, then each step in order. Scripts and copies are the
4//! Dockerfile's own `COPY` and `RUN`, a template a `COPY` of the file Isoloom filled in, and every
5//! other step one `RUN` of the shell library's call. A service doesn't run at build: Isoloom's
6//! runner ([`super::runner`]) is the image's entrypoint, and starts it. The project folder is the
7//! build context.
8
9use isoloom_plan::configure::{ContainerProcess, Resource, RunPlan, TemplatePlan};
10use isoloom_plan::{Build, Environment, Image, MachineShape};
11use isoloom_spec::syntax::Mode;
12
13use super::{Configured, rendered_path, runner, shell};
14use crate::steps::PROJECT_DIR;
15use crate::{GeneratedFile, header};
16
17/// Where the shell library is in the image.
18const LIBRARY: &str = "/usr/local/lib/isoloom/configure.sh";
19
20/// Where the runner is in the image.
21const RUNNER: &str = "/usr/local/bin/isoloom-run";
22
23/// The generated Dockerfiles of `env`'s containers, each at its path (its base image pulled
24/// through the registry mirrors), with what each builds from: the shell library, the runner
25/// and the templates filled in.
26#[must_use]
27pub fn files(env: &Environment) -> Vec<GeneratedFile> {
28    let mut out = Vec::new();
29    for (name, mp) in &env.machines {
30        let (
31            Some(Image::Container {
32                build: Some(Build::Generated { dockerfile, base, process }),
33                ..
34            }),
35            Some(c),
36            MachineShape::Container,
37        ) = (&mp.image, Configured::of(env, name), mp.shape)
38        else {
39            continue;
40        };
41        out.push(GeneratedFile {
42            path: dockerfile.clone(),
43            contents: render(&c, &env.images.pull(base), process),
44        });
45        if super::uses_library(c.recipe()) && !out.iter().any(|f| f.path == shell::LIBRARY_PATH) {
46            out.push(shell::library_file());
47        }
48        out.extend(runner::file(&c, process));
49        out.extend(templates(&c));
50    }
51    out
52}
53
54/// The Dockerfile of machine `c`'s image, from `base`, running `process`.
55#[must_use]
56pub fn render(c: &Configured<'_>, base: &str, process: &ContainerProcess) -> String {
57    let library = if super::uses_library(c.recipe()) {
58        format!("COPY {} {LIBRARY}\n", shell::LIBRARY_PATH)
59    } else {
60        String::new()
61    };
62    // `start` is the runner's, at each start; the build installs and configures.
63    let recipe = c.recipe();
64    let steps: String = recipe.install.iter().chain(&recipe.configure).filter_map(|r| instructions(c, r)).collect();
65    let entrypoint = if process.is_runner() {
66        // The image's own command stays its arguments, which the runner hands its entrypoint.
67        let command = match process {
68            ContainerProcess::Started { command, .. } => serde_json::to_string(command).unwrap_or_else(|_| "[]".to_owned()),
69            _ => "[]".to_owned(),
70        };
71        format!(
72            "{}COPY --chmod=0755 {} {RUNNER}\nENTRYPOINT [\"{RUNNER}\"]\nCMD {command}\n",
73            start_files(c),
74            runner::path(c.machine())
75        )
76    } else {
77        String::new()
78    };
79    // The project folder is the working folder only when the build or the runner uses it; a bare
80    // passthrough of an official image (no steps, the image's own entrypoint) keeps the image's
81    // own working folder, which an app may rely on to find its files.
82    let workdir = if process.is_runner() || !steps.is_empty() {
83        format!("WORKDIR {PROJECT_DIR}\n")
84    } else {
85        String::new()
86    };
87    format!(
88        "{}# The container form of machine `{}`: its OS's image and its steps.\nFROM {base}\n{workdir}{library}{steps}{entrypoint}",
89        header("#"),
90        c.machine()
91    )
92}
93
94/// The project files the recipe's `start` steps read when the runner runs them at each start
95/// (their scripts, their copies' sources), copied into the image's project folder.
96fn start_files(c: &Configured<'_>) -> String {
97    c.recipe()
98        .start
99        .iter()
100        .filter_map(|r| match r {
101            Resource::Run(run) => Some(run.script.as_str()),
102            Resource::Copy(cp) => Some(cp.from.as_str()),
103            _ => None,
104        })
105        .map(|path| format!("COPY {path} {path}\n"))
106        .collect::<Vec<_>>()
107        .concat()
108}
109
110/// One step's instructions; `None` for a step a container doesn't run at build (a service, which
111/// the runner starts; a firewall, which a container doesn't have).
112fn instructions(c: &Configured<'_>, step: &Resource) -> Option<String> {
113    let lines = match step {
114        Resource::Run(run) => script(run),
115        Resource::Copy(cp) => {
116            let owner = cp.owner.as_ref().map(|o| format!("--chown={o} ")).unwrap_or_default();
117            let mode = cp.mode.map(|m| format!("--chmod={m} ")).unwrap_or_default();
118            format!("COPY {owner}{mode}{} {}\n", cp.from, cp.to)
119        }
120        Resource::Template(t) => template(c, t),
121        Resource::Service(_) | Resource::Firewall(_) | Resource::Reboot | Resource::Playbook(_) => return None,
122        other => {
123            let call = shell::call(c, other)?;
124            let ignore = if shell::literal_dollar(&call) { "# hadolint ignore=SC2016\n" } else { "" };
125            format!("{ignore}RUN . {LIBRARY} && {call}")
126        }
127    };
128    Some(lines)
129}
130
131/// A project script, copied and run (behind its `creates`).
132fn script(run: &RunPlan) -> String {
133    let script = &run.script;
134    let line = match &run.creates {
135        Some(path) => format!("[ -e {path} ] || sh {script}"),
136        None => format!("sh {script}"),
137    };
138    format!("COPY {script} {script}\nRUN {line}\n")
139}
140
141/// A template: the file Isoloom filled in, copied with its owner, group and mode (`0644` when it
142/// gives none); a template that can't be filled in fails the build, saying why.
143fn template(c: &Configured<'_>, t: &TemplatePlan) -> String {
144    if let Err(why) = c.template(&t.from) {
145        return format!("RUN . {LIBRARY} && {}\n", shell::words("isoloom_fail", [why]));
146    }
147    let chown = match (&t.owner, &t.group) {
148        (Some(o), Some(g)) => format!("--chown={o}:{g} "),
149        (Some(o), None) => format!("--chown={o} "),
150        (None, Some(g)) => format!("--chown=root:{g} "),
151        (None, None) => String::new(),
152    };
153    let mode = t.mode.unwrap_or(Mode::CONFIG);
154    format!("COPY {chown}--chmod={mode} {} {}\n", rendered_path(c.machine(), &t.from), t.to)
155}
156
157/// The templates machine `c` builds with, filled in: one file each, at
158/// [`rendered_path`] (those that can't be filled in fail the build instead).
159fn templates(c: &Configured<'_>) -> Vec<GeneratedFile> {
160    c.recipe()
161        .resources()
162        .filter_map(|r| match r {
163            Resource::Template(t) => Some(GeneratedFile {
164                path: rendered_path(c.machine(), &t.from),
165                contents: c.template(&t.from).ok()?,
166            }),
167            _ => None,
168        })
169        .collect()
170}
171
172#[cfg(test)]
173mod tests;