Skip to main content

majordomus_cli/deploy/
render.rs

1//! The deployment artifacts, rendered from the canonical deployment object: the container
2//! image definition and the ignore file that bounds its build context. No provider
3//! configuration is rendered: no hosting provider is a target (see [`super`]).
4//!
5//! Neither is authoritative and neither is written by hand. Each carries a header naming
6//! the object it came from and the command that regenerates it, and
7//! `majordomus generate --check` refuses a tree where one of them was edited. That is the
8//! whole point of the kind: the port, the routes and the build inputs are stated once, and
9//! these files are views of that one statement.
10
11use crate::generate::{Artifact, ArtifactFormat};
12
13use super::Deployment;
14
15/// Where the image definition goes.
16pub const DOCKERFILE: &str = "deploy/Dockerfile";
17/// Where the build context's ignore file goes. Docker reads it from the context root, so
18/// it lives at the repository root whatever the image definition's own path is.
19pub const DOCKERIGNORE: &str = ".dockerignore";
20
21/// The Alpine release both stages are built on: the builder links against its musl and the
22/// runtime runs the result, so the two are one number rather than two that could disagree.
23/// Pinned, because a floating tag would make the image a different thing on different days.
24pub const ALPINE: &str = "3.21";
25/// The repository file whose exact `channel` is the builder's Rust version. It is the pin
26/// rustup, the workflows and every developer already build with; the image does not get a
27/// second one (`rust:1.85` stood here while the repository built with 1.98.1).
28pub const TOOLCHAIN_FILE: &str = "rust-toolchain.toml";
29
30/// The builder image for an exact Rust version: `rust:<version>-alpine<ALPINE>`.
31///
32/// ```
33/// use majordomus_cli::deploy::render::{rust_image, ALPINE};
34///
35/// assert_eq!(rust_image("1.98.1"), format!("rust:1.98.1-alpine{ALPINE}"));
36/// ```
37pub fn rust_image(rust: &str) -> String {
38    format!("rust:{rust}-alpine{ALPINE}")
39}
40
41/// The runtime base: the same Alpine release as the builder.
42///
43/// ```
44/// use majordomus_cli::deploy::render::{runtime_image, rust_image};
45///
46/// let alpine = runtime_image().strip_prefix("alpine:").unwrap().to_string();
47/// assert!(rust_image("1.98.1").ends_with(&format!("-alpine{alpine}")));
48/// ```
49pub fn runtime_image() -> String {
50    format!("alpine:{ALPINE}")
51}
52
53/// The exact Rust version a builder stage may pin, read from what the repository declares
54/// in [`TOOLCHAIN_FILE`]. A channel that is not a version number (`stable`, `nightly`) or no
55/// declaration at all is refused, with the correction: an image built from a floating
56/// channel is not reproducible, and guessing a version would be the second pin this
57/// function exists to prevent.
58///
59/// ```
60/// use majordomus_cli::deploy::render::builder_rust;
61///
62/// let repo = tempfile::tempdir().unwrap();
63/// assert!(builder_rust(repo.path()).is_err(), "no pin, no builder");
64/// std::fs::write(repo.path().join("rust-toolchain.toml"), "[toolchain]\nchannel = \"1.98.1\"\n").unwrap();
65/// assert_eq!(builder_rust(repo.path()).unwrap(), "1.98.1");
66/// ```
67pub fn builder_rust(root: &std::path::Path) -> Result<String, String> {
68    let declared = crate::environment::toolchain::declared(root)
69        .into_iter()
70        .find(|t| t.id == "rust" && t.declared_by == TOOLCHAIN_FILE)
71        .and_then(|t| t.declared);
72    match declared {
73        Some(v) if is_exact_version(&v) => Ok(v),
74        Some(v) => Err(format!(
75            "{TOOLCHAIN_FILE} declares the Rust channel `{v}`, which is not an exact version; \
76             the deployment's builder stage pins the toolchain the repository builds with, so \
77             set `channel = \"<major>.<minor>.<patch>\"` there"
78        )),
79        None => Err(format!(
80            "the deployment's builder stage pins the Rust toolchain the repository builds with, \
81             and this repository declares none: add {TOOLCHAIN_FILE} with a `[toolchain]` \
82             table whose `channel` is \"<major>.<minor>.<patch>\""
83        )),
84    }
85}
86
87/// `1.98.1`: three dot-separated numbers, nothing else.
88fn is_exact_version(v: &str) -> bool {
89    let parts: Vec<&str> = v.split('.').collect();
90    parts.len() == 3
91        && parts
92            .iter()
93            .all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))
94}
95/// The build argument that carries the commit into the builder stage. It is the variable the
96/// crate's build script reads, so an image built with it answers the commit on its health
97/// routes and one built without it answers `unknown`.
98pub const COMMIT_ARG: &str = "MAJORDOMUS_BUILD_COMMIT";
99/// The build argument that says whether that commit's tree was dirty (`true`/`false`).
100pub const DIRTY_ARG: &str = "MAJORDOMUS_BUILD_DIRTY";
101/// The unprivileged user the deployed process runs as.
102pub const RUN_USER: &str = "majordomus";
103/// Where the layer and the tool distribution live inside the image.
104pub const APP_DIR: &str = "/app";
105/// The layer's checkout-local half, relative to [`APP_DIR`]: never in the build context
106/// (the ignore file excludes it), and the one directory the deployed process writes.
107pub const LOCAL_DIR: &str = ".ai/local";
108
109/// The comment header every generated artifact carries: what wrote it, from what, and how
110/// to change it. A contributor who edits the output is told which file to change instead.
111fn header(comment: &str, source: &str, what: &str) -> String {
112    // The first line is the one every generated artifact of this repository opens with;
113    // `generate --check` reads exactly that, and this renderer predates the contract.
114    format!(
115        "{comment} {header}\n\
116         {comment} {what}\n\
117         {comment} GENERATED by `majordomus generate deployment` from {source}.\n\
118         {comment} Do not edit: `majordomus generate --check` refuses a hand edit. Change\n\
119         {comment} the deployment object and regenerate.\n\n",
120        header = crate::generate::HEADER
121    )
122}
123
124/// The directory of the package the builder stage compiles: the one input that is a build
125/// input rather than something the running process reads.
126fn package_dir(d: &Deployment) -> Option<&str> {
127    d.build
128        .inputs
129        .iter()
130        .map(String::as_str)
131        .find(|p| p.rsplit('/').next() == Some(d.build.package.as_str()))
132}
133
134/// The inputs the running process reads: everything the build context carries except the
135/// package the builder compiles.
136fn runtime_inputs(d: &Deployment) -> Vec<&str> {
137    let pkg = package_dir(d);
138    d.build
139        .inputs
140        .iter()
141        .map(String::as_str)
142        .filter(|p| Some(*p) != pkg)
143        .collect()
144}
145
146/// The stages of the builder, in order, before the runtime stage. Each is a layer cache
147/// boundary, and what invalidates each is the point of the split:
148///
149/// - `toolchain`: the pinned Rust image, the C library headers and sccache, which every
150///   later `cargo` runs behind (see [`SCCACHE_DIR`]); only a toolchain pin moves it.
151/// - `recipe`: the package's manifest and lockfile with the package's own version masked
152///   and its bench, test and example targets dropped. Its output, not the files it reads,
153///   is what the next stage copies, so a release that bumps only the version reaches the
154///   same recipe and does not recompile a dependency.
155/// - `deps`: every dependency downloaded and compiled against stub sources, in the profile,
156///   features and target of the real build. It is ordinary layer content — unlike a
157///   `--mount=type=cache` directory, which a registry cache export never carries — so a
158///   fresh runner that imports the cache gets the compiled dependencies too.
159/// - `build`: the real sources over the compiled dependencies; only the package itself
160///   compiles. The commit arguments live here and nowhere earlier, so a new commit never
161///   reaches a dependency.
162/// - `release`: a release's own published executable, taken from the `release-binary` build
163///   context ([`RELEASE_CONTEXT`]) instead of compiled; see [`BINARY_ARG`].
164/// - `binary`: whichever of `build` and `release` [`BINARY_ARG`] names — the one stage the
165///   runtime takes its executable from, so the runtime is the same whatever made the binary.
166pub const BUILDER_STAGES: [&str; 6] = ["toolchain", "recipe", "deps", "build", "release", "binary"];
167
168/// The build argument that chooses where the image's executable comes from: `build` (the
169/// default) compiles it from the context, `release` takes the release's own published,
170/// checksummed and attested executable (`.github/workflows/release.yml`). BuildKit builds only
171/// the stages the final image reaches, so with `release` nothing is compiled at all, and the
172/// image a release publishes runs the very bytes its archives ship.
173pub const BINARY_ARG: &str = "MAJORDOMUS_BINARY_FROM";
174/// The named build context a `release` build is given: a directory holding
175/// `majordomus-<arch>` for each platform (`amd64`, `arm64`), BuildKit's TARGETARCH names.
176pub const RELEASE_CONTEXT: &str = "release-binary";
177
178/// Where sccache keeps compiled units inside the builder: a BuildKit cache mount, never layer
179/// content. Two caches with two jobs, because neither does the other's:
180///
181/// - the `deps` *layer* travels with a registry cache export, so a fresh runner gets every
182///   dependency compiled while the lockfile is unchanged — and loses all of them the moment it
183///   changes;
184/// - the sccache *mount* is keyed by each compilation's own inputs, so when the lockfile moves
185///   only the crates that actually changed recompile, and it is shared by every build on one
186///   builder whatever directory it was started from — every worktree and branch on a machine.
187///   A mount never leaves its builder: on an ephemeral runner the workflow carries it in and
188///   out explicitly, or it starts empty, which is slower and never wrong.
189///
190/// A unit sccache cannot see the whole of is compiled, not guessed: the package itself, whose
191/// sources and commit change with every build, is a miss by construction, and the image's
192/// smoke test checks the commit it names against the one it was built from.
193pub const SCCACHE_DIR: &str = "/sccache";
194/// The id of the sccache mount, the name under which a builder keeps it between builds and
195/// the name a workflow restores it by.
196pub const SCCACHE_MOUNT_ID: &str = "majordomus-sccache";
197/// The bound sccache evicts to: the dependency graph compiled for one profile and target is
198/// well under it, and a bound is what keeps a long-lived builder's disk from growing forever.
199/// The builder also sets `SCCACHE_IDLE_TIMEOUT=0`: sccache's server stops after 600 s without
200/// a new request, and the package's own release compile alone ran 612 s on a loaded builder,
201/// so the server stopped under it and the wrapper compiled the package a second time.
202pub const SCCACHE_SIZE: &str = "2G";
203
204/// The container image, as a multi-stage build: builder stages that hold the toolchain
205/// ([`BUILDER_STAGES`]) and a runtime that holds the binary, the layer and nothing else.
206/// The runtime carries no compiler, no cargo registry, no repository history and no shell
207/// history — what is not copied in cannot leak out. `rust` is the exact version the builder
208/// pins, from [`builder_rust`].
209pub fn dockerfile(d: &Deployment, source: &str, rust: &str) -> String {
210    let mut out = header(
211        "#",
212        source,
213        "The container image of this repository's deployment.",
214    );
215    out.push_str(&format!(
216        "# Where the executable comes from: `build` compiles it below; `release` takes a\n\
217         # release's published executable from the {RELEASE_CONTEXT} build context instead.\n\
218         ARG {BINARY_ARG}=build\n\n"
219    ));
220    let pkg = package_dir(d).unwrap_or("apps");
221    let lib = d.build.package.replace('-', "_");
222    // The recipe's two programs, kept out of `format!` so the shell's braces stay single.
223    // Cargo.toml: the [package] version becomes 0.0.0 and every [[bench]], [[test]] and
224    // [[example]] table is dropped (cargo refuses a manifest whose target files are absent,
225    // and none of them is part of `cargo build --bin`). Cargo.lock: the version recorded
226    // for the package itself becomes 0.0.0, so `--locked` agrees with the masked manifest.
227    let manifest_awk = r#"/^\[/ { t = $0; skip = (t ~ /^\[\[(bench|test|example)\]\]$/) } t == "[package]" && /^version *=/ { $0 = "version = \"0.0.0\"" } !skip"#;
228    let lock_awk = format!(
229        r#"p && /^version = / {{ $0 = "version = \"0.0.0\""; p = 0 }} $0 == "name = \"{package}\"" {{ p = 1 }} 1"#,
230        package = d.build.package
231    );
232    out.push_str(&format!(
233        "FROM {image} AS toolchain\n\
234         RUN apk add --no-cache musl-dev sccache\n\
235         ENV RUSTC_WRAPPER=sccache SCCACHE_DIR={SCCACHE_DIR} SCCACHE_CACHE_SIZE={SCCACHE_SIZE} SCCACHE_IDLE_TIMEOUT=0 CARGO_INCREMENTAL=0\n\
236         WORKDIR /src\n\n\
237         # The dependency graph and nothing else; see BUILDER_STAGES in the renderer.\n\
238         FROM toolchain AS recipe\n\
239         COPY {pkg}/Cargo.toml {pkg}/Cargo.lock /recipe/in/\n\
240         RUN mkdir -p /recipe/out \\\n\
241         \x20 && awk '{manifest_awk}' /recipe/in/Cargo.toml > /recipe/out/Cargo.toml \\\n\
242         \x20 && awk '{lock_awk}' /recipe/in/Cargo.lock > /recipe/out/Cargo.lock\n\n\
243         # Every dependency compiled, as layer content a registry cache can carry.\n\
244         FROM toolchain AS deps\n\
245         COPY --from=recipe /recipe/out/ ./{pkg}/\n\
246         RUN --mount=type=cache,id={SCCACHE_MOUNT_ID},target={SCCACHE_DIR} mkdir -p {pkg}/src \\\n\
247         \x20 && echo 'fn main() {{}}' > {pkg}/src/main.rs && echo 'fn main() {{}}' > {pkg}/build.rs \\\n\
248         \x20 && touch {pkg}/src/lib.rs \\\n\
249         \x20 && cargo build --locked --{profile} --manifest-path {pkg}/Cargo.toml --package {package} --bin {binary} \\\n\
250         \x20 && rm -rf {pkg}/src {pkg}/build.rs {pkg}/target/{profile}/.fingerprint/{package}-* \\\n\
251         \x20   {pkg}/target/{profile}/build/{package}-* {pkg}/target/{profile}/deps/{lib}-* \\\n\
252         \x20   {pkg}/target/{profile}/deps/lib{lib}-* {pkg}/target/{profile}/deps/{binary}-* \\\n\
253         \x20   {pkg}/target/{profile}/{binary} {pkg}/target/{profile}/{binary}.d \\\n\
254         \x20 && sccache --show-stats\n\n\
255         FROM deps AS build\n\
256         COPY {pkg} ./{pkg}\n\
257         # The commit this image is built from, compiled into the executable so that\n\
258         # /api/v1/live and /api/v1/ready can name it. The build context carries no .git,\n\
259         # so the builder cannot ask git and the build must say it:\n\
260         #   docker build --build-arg {COMMIT_ARG}=\"$(git rev-parse HEAD)\" ...\n\
261         # Left unset, the deployed process answers `unknown` rather than a guess. Declared\n\
262         # here and in no earlier stage, so a new commit never invalidates a dependency.\n\
263         ARG {COMMIT_ARG}=unknown\n\
264         ARG {DIRTY_ARG}=unknown\n\
265         RUN --mount=type=cache,id={SCCACHE_MOUNT_ID},target={SCCACHE_DIR} \\\n\
266         \x20 cargo build --locked --offline --{profile} --manifest-path {pkg}/Cargo.toml --package {package} --bin {binary} \\\n\
267         \x20 && install -Dm755 {pkg}/target/{profile}/{binary} /out/{binary} && sccache --show-stats\n\n\
268         # A release's own executable, published, checksummed and attested, compiled by nothing\n\
269         # here: --build-arg {BINARY_ARG}=release --build-context {RELEASE_CONTEXT}=<dir>, where\n\
270         # <dir> holds {binary}-amd64 and {binary}-arm64.\n\
271         FROM scratch AS release\n\
272         ARG TARGETARCH\n\
273         COPY --from={RELEASE_CONTEXT} {binary}-${{TARGETARCH}} /out/{binary}\n\n\
274         FROM ${{{BINARY_ARG}}} AS binary\n\n",
275        image = rust_image(rust),
276        profile = d.build.profile,
277        package = d.build.package,
278        binary = d.build.binary,
279    ));
280    out.push_str(&format!(
281        "FROM {runtime}\n\
282         # An unprivileged user, which is also why the declared port is above 1024.\n\
283         RUN adduser -D -H -u 10001 {RUN_USER}\n\
284         WORKDIR {APP_DIR}\n\
285         COPY --from=binary /out/{binary} /usr/local/bin/{binary}\n",
286        runtime = runtime_image(),
287        binary = d.build.binary,
288    ));
289    for input in runtime_inputs(d) {
290        out.push_str(&format!("COPY {input} {APP_DIR}/{input}\n"));
291    }
292    if runtime_inputs(d).contains(&".ai") {
293        // The served layer's checkout-local half is the one place the process writes (its
294        // server lease and state): the build context never carries it, and everything copied
295        // above is root's, so without this the unprivileged process stopped at start with
296        // `/app/.ai/local/state/mcp: Permission denied` and the image never served.
297        out.push_str(&format!(
298            "# The one directory the process writes: the layer's checkout-local half.\n\
299             RUN install -d -o {RUN_USER} -g {RUN_USER} {APP_DIR}/{LOCAL_DIR}\n"
300        ));
301    }
302    out.push_str(&format!(
303        "USER {RUN_USER}\n\
304         EXPOSE {port}\n\
305         # The address, the port and the interface are the object's; this command names the\n\
306         # deployment and reads them from it. Discovery is a filesystem walk because the\n\
307         # image carries the layer without the repository that tracks it.\n\
308         ENTRYPOINT [\"{binary}\"]\n\
309         CMD [\"serve\", \"--discovery\", \"filesystem\", \"--deployment\", \"{id}\"]\n",
310        port = d.listen.port,
311        binary = d.build.binary,
312        id = d.id,
313    ));
314    out
315}
316
317/// The build context: nothing but the declared inputs. Everything is excluded and each
318/// input is let back in, so a file that appears in the repository tomorrow does not appear
319/// in the image tomorrow.
320pub fn dockerignore(d: &Deployment, source: &str) -> String {
321    let mut out = header(
322        "#",
323        source,
324        "What the deployment's build context carries: only the declared inputs.",
325    );
326    out.push_str("*\n");
327    for input in &d.build.inputs {
328        out.push_str(&format!("!{input}\n"));
329    }
330    // The image definition is not an input of the deployment; it is a view of it. It is
331    // let back in because a builder that reads the context needs the file it builds from.
332    out.push_str(&format!("!{DOCKERFILE}\n"));
333    out.push_str(
334        "\n# and never these, wherever they appear inside an input\n\
335         **/.git\n\
336         **/target\n\
337         **/node_modules\n\
338         **/.DS_Store\n\
339         .ai/local\n",
340    );
341    out
342}
343
344/// The two artifacts of one deployment; `rust` is the builder's exact Rust version.
345pub fn artifacts(d: &Deployment, source: &str, rust: &str) -> Vec<Artifact> {
346    vec![
347        Artifact {
348            path: DOCKERFILE.into(),
349            document: "deployment".into(),
350            format: ArtifactFormat::Text,
351            schema: None,
352            source: source.into(),
353            content: dockerfile(d, source, rust),
354        },
355        Artifact {
356            path: DOCKERIGNORE.into(),
357            document: "deployment".into(),
358            format: ArtifactFormat::Text,
359            schema: None,
360            source: source.into(),
361            content: dockerignore(d, source),
362        },
363    ]
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369
370    fn deployment() -> Deployment {
371        serde_json::from_value(serde_json::json!({
372            "schema": "deployment/v1",
373            "kind": "deployment",
374            "id": "example",
375            "title": "The example deployment",
376            "application": "example",
377            "build": {
378                "package": "majordomus-cli",
379                "binary": "majordomus",
380                "profile": "release",
381                "inputs": ["apps/majordomus-cli", "share"],
382            },
383            "listen": { "port": 8080, "interface": "all" },
384            "health": { "liveness": "/api/v1/live", "readiness": "/api/v1/ready" },
385            "resources": { "cpu_kind": "shared", "cpus": 1, "memory_mb": 256 },
386            "machines": { "count": 1, "min_running": 0, "autostart": true, "autostop": true },
387            "region": "fra",
388        }))
389        .expect("the example deployment parses")
390    }
391
392    /// The lines of one stage, from its `FROM` to the next.
393    fn stage<'a>(file: &'a str, name: &str) -> Vec<&'a str> {
394        let mut lines = file
395            .lines()
396            .skip_while(|l| !l.ends_with(&format!(" AS {name}")));
397        let first = lines.next().into_iter();
398        first
399            .chain(lines.take_while(|l| !l.starts_with("FROM ")))
400            .collect()
401    }
402
403    #[test]
404    fn the_builder_pins_the_version_it_is_given_and_the_runtime_the_same_alpine() {
405        let file = dockerfile(&deployment(), "x.yaml", "1.98.1");
406        assert!(file.contains("FROM rust:1.98.1-alpine3.21 AS toolchain\n"));
407        assert!(file.contains("\nFROM alpine:3.21\n"));
408        // the version appears once: a second statement of it is the drift being removed
409        assert_eq!(file.matches("1.98.1").count(), 1);
410        let other = dockerfile(&deployment(), "x.yaml", "1.99.0");
411        assert!(other.contains("FROM rust:1.99.0-alpine3.21 AS toolchain\n"));
412    }
413
414    #[test]
415    fn the_stages_are_the_declared_ones_in_order() {
416        let file = dockerfile(&deployment(), "x.yaml", "1.98.1");
417        let named: Vec<&str> = file
418            .lines()
419            .filter_map(|l| l.strip_prefix("FROM "))
420            .filter_map(|l| l.split(" AS ").nth(1))
421            .collect();
422        assert_eq!(named, BUILDER_STAGES);
423    }
424
425    #[test]
426    fn nothing_before_the_build_stage_reads_a_source_file_or_the_commit() {
427        let file = dockerfile(&deployment(), "x.yaml", "1.98.1");
428        for name in ["toolchain", "recipe", "deps"] {
429            for line in stage(&file, name) {
430                assert!(
431                    !line.contains(COMMIT_ARG) && !line.contains(DIRTY_ARG),
432                    "{name} names the commit: {line}"
433                );
434                if let Some(copy) = line.strip_prefix("COPY ") {
435                    assert!(
436                        copy.starts_with("--from=recipe ")
437                            || copy.starts_with(
438                                "apps/majordomus-cli/Cargo.toml apps/majordomus-cli/Cargo.lock "
439                            ),
440                        "{name} copies more than the dependency graph: {line}"
441                    );
442                }
443            }
444        }
445        let build = stage(&file, "build");
446        assert!(build.contains(&"COPY apps/majordomus-cli ./apps/majordomus-cli"));
447        assert!(build
448            .iter()
449            .any(|l| *l == format!("ARG {COMMIT_ARG}=unknown")));
450    }
451
452    #[test]
453    fn the_compiled_dependencies_are_layer_content_and_sccache_is_the_only_mount() {
454        // a registry cache export carries layers and never a cache mount's contents, which
455        // is the whole reason the dependencies are compiled into a stage of their own; the
456        // sccache mount sits beside that layer, and the target directory is never mounted
457        let file = dockerfile(&deployment(), "x.yaml", "1.98.1");
458        let mount = format!("--mount=type=cache,id={SCCACHE_MOUNT_ID},target={SCCACHE_DIR} ");
459        assert_eq!(file.matches("--mount=type=cache").count(), 2);
460        assert_eq!(file.matches(&mount).count(), 2);
461        for name in ["deps", "build"] {
462            assert!(
463                stage(&file, name).join("\n").contains(&mount),
464                "{name} has no sccache"
465            );
466        }
467        assert!(stage(&file, "toolchain")
468            .join("\n")
469            .contains("ENV RUSTC_WRAPPER=sccache SCCACHE_DIR=/sccache"));
470        // a server that idles out under one long compile makes the wrapper compile it twice
471        assert!(stage(&file, "toolchain")
472            .join("\n")
473            .contains(" SCCACHE_IDLE_TIMEOUT=0 "));
474        let deps = stage(&file, "deps").join("\n");
475        assert!(deps.contains("cargo build --locked --release"));
476        assert!(stage(&file, "build")
477            .join("\n")
478            .contains("cargo build --locked --offline --release"));
479    }
480
481    #[test]
482    fn the_runtime_carries_the_binary_and_the_inputs_and_no_toolchain() {
483        let file = dockerfile(&deployment(), "x.yaml", "1.98.1");
484        let runtime: Vec<&str> = file
485            .lines()
486            .skip_while(|l| *l != "FROM alpine:3.21")
487            .collect();
488        assert!(runtime.contains(&"COPY --from=binary /out/majordomus /usr/local/bin/majordomus"));
489        assert!(runtime.contains(&"COPY share /app/share"));
490        assert!(runtime.contains(&"USER majordomus"));
491        assert!(!runtime
492            .iter()
493            .any(|l| l.contains("cargo") || l.contains("apk add")));
494    }
495
496    #[test]
497    fn rendering_is_deterministic() {
498        let d = deployment();
499        assert_eq!(
500            dockerfile(&d, "x.yaml", "1.98.1"),
501            dockerfile(&d, "x.yaml", "1.98.1")
502        );
503    }
504
505    #[test]
506    fn only_an_exact_version_is_a_builder_pin() {
507        assert!(is_exact_version("1.98.1"));
508        for floating in [
509            "stable",
510            "1.98",
511            "nightly-2026-01-01",
512            "1.98.1-beta",
513            "",
514            "1..1",
515        ] {
516            assert!(!is_exact_version(floating), "{floating} accepted");
517        }
518    }
519
520    #[test]
521    fn the_pin_is_read_from_the_toolchain_file_and_its_absence_is_refused() {
522        let dir = tempfile::tempdir().expect("a temporary directory");
523        let err = builder_rust(dir.path()).expect_err("no file, no pin");
524        assert!(err.contains(TOOLCHAIN_FILE), "{err}");
525        std::fs::write(
526            dir.path().join(TOOLCHAIN_FILE),
527            "[toolchain]\nchannel = \"stable\"\n",
528        )
529        .expect("write");
530        let err = builder_rust(dir.path()).expect_err("a floating channel is no pin");
531        assert!(err.contains("`stable`"), "{err}");
532        std::fs::write(
533            dir.path().join(TOOLCHAIN_FILE),
534            "[toolchain]\nchannel = \"1.98.1\"\n",
535        )
536        .expect("write");
537        assert_eq!(builder_rust(dir.path()).as_deref(), Ok("1.98.1"));
538    }
539
540    #[test]
541    fn the_executable_is_compiled_by_default_and_a_release_brings_its_own() {
542        let file = dockerfile(&deployment(), "x.yaml", "1.98.1");
543        // declared before the first FROM, so a FROM may name it; the default compiles
544        let first_from = file.find("\nFROM ").expect("a FROM");
545        assert!(file[..first_from].contains(&format!("\nARG {BINARY_ARG}=build\n")));
546        assert!(file.contains(&format!("\nFROM ${{{BINARY_ARG}}} AS binary\n")));
547        let release = stage(&file, "release");
548        assert_eq!(release[0], "FROM scratch AS release");
549        assert!(release.contains(&"ARG TARGETARCH"));
550        assert!(release
551            .contains(&"COPY --from=release-binary majordomus-${TARGETARCH} /out/majordomus"));
552        // nothing in the release stage compiles or reads the commit
553        assert!(!release
554            .iter()
555            .any(|l| l.contains("cargo") || l.contains(COMMIT_ARG)));
556        // and the runtime takes its executable from `binary` and from nowhere else
557        assert_eq!(file.matches("COPY --from=build ").count(), 0);
558        assert_eq!(file.matches("COPY --from=binary ").count(), 1);
559    }
560
561    #[test]
562    fn the_unprivileged_process_owns_the_one_directory_it_writes() {
563        let file = dockerfile(
564            &deployment_with(&["apps/majordomus-cli", ".ai"]),
565            "x.yaml",
566            "1.98.1",
567        );
568        let runtime: Vec<&str> = file
569            .lines()
570            .skip_while(|l| *l != "FROM alpine:3.21")
571            .collect();
572        let owned = "RUN install -d -o majordomus -g majordomus /app/.ai/local";
573        let at = |needle: &str| runtime.iter().position(|l| *l == needle);
574        let (copy, own, user) = (at("COPY .ai /app/.ai"), at(owned), at("USER majordomus"));
575        assert!(copy < own && own < user, "{copy:?} {own:?} {user:?}");
576        // a deployment that serves no layer has no layer to write into
577        let none = dockerfile(&deployment(), "x.yaml", "1.98.1");
578        assert!(!none.contains(".ai/local"));
579    }
580
581    fn deployment_with(inputs: &[&str]) -> Deployment {
582        let mut d = deployment();
583        d.build.inputs = inputs.iter().map(|s| s.to_string()).collect();
584        d
585    }
586}