1use crate::generate::{Artifact, ArtifactFormat};
12
13use super::Deployment;
14
15pub const DOCKERFILE: &str = "deploy/Dockerfile";
17pub const DOCKERIGNORE: &str = ".dockerignore";
20
21pub const ALPINE: &str = "3.21";
25pub const TOOLCHAIN_FILE: &str = "rust-toolchain.toml";
29
30pub fn rust_image(rust: &str) -> String {
38 format!("rust:{rust}-alpine{ALPINE}")
39}
40
41pub fn runtime_image() -> String {
50 format!("alpine:{ALPINE}")
51}
52
53pub 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
87fn 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}
95pub const COMMIT_ARG: &str = "MAJORDOMUS_BUILD_COMMIT";
99pub const DIRTY_ARG: &str = "MAJORDOMUS_BUILD_DIRTY";
101pub const RUN_USER: &str = "majordomus";
103pub const APP_DIR: &str = "/app";
105pub const LOCAL_DIR: &str = ".ai/local";
108
109fn header(comment: &str, source: &str, what: &str) -> String {
112 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
124fn 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
134fn 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
146pub const BUILDER_STAGES: [&str; 6] = ["toolchain", "recipe", "deps", "build", "release", "binary"];
167
168pub const BINARY_ARG: &str = "MAJORDOMUS_BINARY_FROM";
174pub const RELEASE_CONTEXT: &str = "release-binary";
177
178pub const SCCACHE_DIR: &str = "/sccache";
194pub const SCCACHE_MOUNT_ID: &str = "majordomus-sccache";
197pub const SCCACHE_SIZE: &str = "2G";
203
204pub 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 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 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
317pub 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 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
344pub 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 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 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 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 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 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 assert!(!release
554 .iter()
555 .any(|l| l.contains("cargo") || l.contains(COMMIT_ARG)));
556 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 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}