crates/nornir-build-thing/src/container.rs
//! **Container / appliance IMAGE build** — a second build *target kind* alongside
//! the cargo [`BuildVariant`](crate::variant::BuildVariant).
//!
//! Where a `BuildVariant` describes one `cargo build` point, a [`ContainerBuild`]
//! describes building an **OCI / appliance image** declaratively: a `base`, the
//! `layers` (payloads copied in — a host path, or the artifact a cargo variant
//! produced), an `entrypoint`/`cmd`, `env`, exposed ports, labels, and image
//! `tags`. It is generic enough that a **tunnr appliance image** and a **desktop
//! project app image** both express cleanly.
//!
//! It is *data* (pure serde) and dependency-light like the rest of the crate: it
//! materializes a **Containerfile** ([`ContainerBuild::containerfile`]) and the
//! image-builder **argv** ([`ContainerBuild::build_args`]) but runs nothing
//! itself — the host (nornir / an xtask) drives the chosen engine
//! (`docker`/`podman`/`buildah`). The descriptor is content-addressable
//! ([`ContainerBuild::content_id`]) so an image build can carry provenance the
//! same way a cargo build does.
//!
//! ```
//! use nornir_build_thing::{ContainerBuild, ImageLayer, ImageFormat};
//!
//! let img = ContainerBuild {
//! name: "tunnr-appliance".into(),
//! base: "debian:bookworm-slim".into(),
//! layers: vec![ImageLayer::from_variant("full", "/usr/local/bin/tunnr")],
//! entrypoint: vec!["/usr/local/bin/tunnr".into()],
//! tags: vec!["tunnr-appliance:0.1".into()],
//! ..Default::default()
//! };
//! assert!(img.containerfile().contains("FROM debian:bookworm-slim"));
//! assert!(img.build_args("docker").contains(&"tunnr-appliance:0.1".to_string()));
//! ```
//!
//! # Two modes, never blended
//!
//! Synthesis (above) covers the images this schema can *describe*. It cannot
//! describe the ones that need `RUN`, `ARG`, a multi-stage `FROM … AS build` or
//! `COPY --from=build` — and a cell that quietly dropped those would build a
//! plausible-looking WRONG image (a Spark with no Iceberg jar, a Valkey with no
//! FalkorDB module) that STARTS and fails much later. So the second mode names a
//! **real Containerfile on disk** and synthesizes nothing:
//!
//! ```
//! use nornir_build_thing::ContainerBuild;
//!
//! let img = ContainerBuild {
//! name: "nordisk-spark-iceberg".into(),
//! containerfile: "containers/spark-iceberg/Containerfile".into(),
//! tags: vec!["localhost/nordisk-spark-iceberg:4.1.2".into()],
//! ..Default::default()
//! };
//! img.validate().unwrap();
//! // `-f` names the real file; the context defaults to its directory.
//! let args = img.build_args("podman");
//! assert_eq!(args[args.len() - 2], "containers/spark-iceberg/Containerfile");
//! assert_eq!(args[args.len() - 1], "containers/spark-iceberg");
//! ```
//!
//! The modes are **exclusive**: exactly one of `base` and `containerfile` may be
//! set, and mixing `containerfile` with any synthesis field
//! (`layers`/`env`/`entrypoint`/…) is refused by
//! [`ContainerBuild::validate`] rather than resolved by a precedence rule nobody
//! would remember. Only `name`, `tags` and `format` are shared — they are engine
//! FLAGS (`-t`, `--format`), not Containerfile text.
//!
//! ```
//! use nornir_build_thing::ContainerBuild;
//! let bad = ContainerBuild {
//! name: "blended".into(),
//! base: "scratch".into(),
//! containerfile: "containers/x/Containerfile".into(),
//! ..Default::default()
//! };
//! assert!(bad.validate().is_err(), "the two modes never blend");
//! ```
use std::collections::BTreeMap;
use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize};
/// The image artifact format a [`ContainerBuild`] produces. Deserializes from a
/// bare TOML string (`format = "oci"`).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(from = "String", into = "String")]
pub enum ImageFormat {
/// An OCI image (the portable default).
Oci,
/// A Docker-format image.
Docker,
/// A single-file appliance / disk image (a VM or bootable appliance).
Appliance,
}
impl Default for ImageFormat {
fn default() -> Self {
ImageFormat::Oci
}
}
impl From<String> for ImageFormat {
fn from(s: String) -> Self {
match s.trim().to_ascii_lowercase().as_str() {
"" | "oci" => ImageFormat::Oci,
"docker" => ImageFormat::Docker,
"appliance" | "disk" | "vm" => ImageFormat::Appliance,
_ => ImageFormat::Oci,
}
}
}
impl From<ImageFormat> for String {
fn from(f: ImageFormat) -> Self {
match f {
ImageFormat::Oci => "oci",
ImageFormat::Docker => "docker",
ImageFormat::Appliance => "appliance",
}
.to_string()
}
}
impl ImageFormat {
/// Stable label (matches the string form).
pub fn as_str(&self) -> &'static str {
match self {
ImageFormat::Oci => "oci",
ImageFormat::Docker => "docker",
ImageFormat::Appliance => "appliance",
}
}
}
/// One payload copied into the image: a `source` placed at `dest` inside the
/// image. `source` is either a host path/URL, or — when [`from_variant`] is set —
/// the artifact a cargo [`BuildVariant`](crate::variant::BuildVariant) produced,
/// letting an image chain a cargo build's output as a layer.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ImageLayer {
/// Source path (host path). Omitted when [`from_variant`] supplies the
/// payload — the artifact a cargo build produced.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub source: String,
/// Destination path inside the image.
pub dest: String,
/// When set, `source` refers to the artifact produced by this cargo build
/// variant name (the image layer *is* that variant's binary).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub from_variant: Option<String>,
/// Mark the copied file executable (`chmod +x`).
#[serde(default, skip_serializing_if = "is_false")]
pub executable: bool,
}
fn is_false(b: &bool) -> bool {
!*b
}
impl ImageLayer {
/// A layer copying a host `source` to `dest`.
pub fn copy(source: impl Into<String>, dest: impl Into<String>) -> Self {
ImageLayer { source: source.into(), dest: dest.into(), from_variant: None, executable: false }
}
/// A layer whose payload is the artifact a cargo build `variant` produced,
/// placed (executable) at `dest`.
pub fn from_variant(variant: impl Into<String>, dest: impl Into<String>) -> Self {
ImageLayer {
source: String::new(),
dest: dest.into(),
from_variant: Some(variant.into()),
executable: true,
}
}
/// The `COPY` source rendered in the Containerfile — the artifact path when
/// chained from a variant (a build-context relative name), else `source`.
fn copy_source(&self) -> String {
match &self.from_variant {
// The host stages the variant's artifact under this build-context name.
Some(v) => format!("./{v}"),
None => self.source.clone(),
}
}
}
/// One concrete **image build point** — the container-target sibling of a cargo
/// [`BuildVariant`](crate::variant::BuildVariant).
///
/// Serializes as a `[[build.container]]` table:
/// ```toml
/// [[build.container]]
/// name = "tunnr-appliance"
/// base = "debian:bookworm-slim"
/// entrypoint = ["/usr/local/bin/tunnr"]
/// tags = ["tunnr-appliance:0.1"]
/// format = "oci"
///
/// [[build.container.layers]]
/// from_variant = "full" # chain the cargo `full` variant's binary
/// dest = "/usr/local/bin/tunnr"
/// ```
/// **The recipe of a container's base image** — a Containerfile in this repo or in a
/// sibling checkout, built under the base's own tag.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BaseRecipe {
/// The sibling checkout the recipe lives in (`"korp"`); empty = this repo.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub repo: String,
/// The Containerfile, relative to that checkout.
pub containerfile: String,
/// The build context, relative to that checkout; empty = the Containerfile's
/// own directory.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub context: String,
}
impl BaseRecipe {
/// `(checkout, context dir, containerfile path relative to the context)` for a
/// recipe of a container cell in `repo_root`. A sibling resolves beside it.
/// Refused BY NAME when the repo is not a NAME or a path escapes its checkout.
pub fn locate(&self, repo_root: &std::path::Path) -> anyhow::Result<(std::path::PathBuf, std::path::PathBuf, String)> {
let bad = |p: &str| p.is_empty() || p.starts_with('/') || p.split('/').any(|c| c == "..");
if !self.repo.is_empty() && (self.repo.contains('/') || self.repo.contains("..")) {
anyhow::bail!("base_recipe.repo `{}` is not a sibling checkout NAME", self.repo);
}
if bad(&self.containerfile) || (!self.context.is_empty() && bad(&self.context)) {
anyhow::bail!("base_recipe paths must stay inside the checkout: `{}` / `{}`", self.containerfile, self.context);
}
let checkout = if self.repo.is_empty() {
repo_root.to_path_buf()
} else {
repo_root.parent().unwrap_or(repo_root).join(&self.repo)
};
let file = std::path::Path::new(&self.containerfile);
let context_rel = if self.context.is_empty() {
file.parent().map(std::path::Path::to_path_buf).unwrap_or_default()
} else {
std::path::PathBuf::from(&self.context)
};
let in_context = file
.strip_prefix(&context_rel)
.map(|p| p.display().to_string())
.map_err(|_| anyhow::anyhow!("base_recipe.containerfile `{}` is not inside its context `{}`", self.containerfile, context_rel.display()))?;
Ok((checkout.clone(), checkout.join(context_rel), in_context))
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ContainerBuild {
/// Stable handle (`nornir build --image <name>`).
pub name: String,
/// Base image (`FROM`), e.g. `debian:bookworm-slim` or `scratch`. Required in
/// synthesis mode; MUST be empty in file mode, where the `FROM` (or several,
/// for a multi-stage build) lives in the Containerfile on disk.
///
/// `#[serde(default)]` since file mode landed — without it serde REFUSES a
/// `containerfile = "…"` cell at parse time with "missing field `base`", which
/// is both the wrong error and the wrong layer to raise it at. Emptiness is
/// still rejected in synthesis mode, by [`validate`](Self::validate), which is
/// where the mode is known. Serialization is unchanged: `base` carries no
/// `skip_serializing_if`, so every existing descriptor's JSON is byte-identical.
#[serde(default)]
pub base: String,
/// Payloads copied into the image, in order.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub layers: Vec<ImageLayer>,
/// The image `ENTRYPOINT` (exec form).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub entrypoint: Vec<String>,
/// The image `CMD` (default args, exec form).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub cmd: Vec<String>,
/// The image `WORKDIR`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workdir: Option<String>,
/// Environment (`ENV`), key-sorted so materialization is deterministic.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub env: BTreeMap<String, String>,
/// Ports to `EXPOSE`.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub expose: Vec<u16>,
/// Image `LABEL`s (key-sorted).
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub labels: BTreeMap<String, String>,
/// Image tags to apply (`-t`), e.g. `tunnr-appliance:0.1`.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
/// **The digest this recipe must produce, when a consumer pins it** (2026-09-22). Set at
/// build time from the consumer's pin (a `[[build.kernel]]` row's `toolchain_image_sha256`
/// for the image it names, [`crate::kernel::toolchain_pin_for`]); never declared and never
/// serialized, so no descriptor's JSON or `content_id` moves.
///
/// When it is set, [`crate::image_build::build_container`] builds under a STAGING tag,
/// checks the built image against it, and only then applies [`tags`](Self::tags). MEASURED
/// by T16 on t14s: every `--build kernel` compose POSTed `/build?t=nordisk-kernel-toolchain:latest`,
/// the layer cache answered with the 2026-09-15 rebuild `311bc06f`, and `:latest` moved to
/// an image no row pins (`78eaa55a`) while the kernel step itself ran by digest — so
/// everything that resolves the image by TAG got the wrong one, and nothing said so.
#[serde(skip)]
pub expected_sha256: String,
/// Output image format.
#[serde(default, skip_serializing_if = "is_oci")]
pub format: ImageFormat,
/// **FILE MODE**: a real Containerfile on disk, repo-root-relative
/// (`containers/spark-iceberg/Containerfile`). When set, NOTHING is
/// synthesized — the file's own bytes are the build input, so `RUN`, `ARG`,
/// multi-stage `FROM … AS build` and `COPY --from=build` all work.
///
/// Mutually exclusive with the synthesis fields (`base`/`layers`/… — see
/// [`validate`](Self::validate)): exactly one of `base` and `containerfile`
/// may be set, and a cell that sets both is REFUSED rather than resolved by a
/// precedence nobody remembers.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub containerfile: String,
/// **FILE MODE**: the build context directory, repo-root-relative — what the
/// engine tars up and what the Containerfile's `COPY` resolves against.
/// Defaults to `containerfile`'s parent directory (the usual
/// `containers/<image>/` layout), so it is normally omitted.
///
/// Only meaningful with [`containerfile`](Self::containerfile): in synthesis
/// mode the context is fixed by the layer-staging contract (a `from_variant`
/// layer's artifact is staged under `./<variant>`), so honouring a different
/// context there would silently invalidate that staging. Setting `context`
/// without `containerfile` is therefore an error, not a no-op.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub context: String,
/// **DISTRIBUTABLE ARCHIVE**: when set, the built image is also EXPORTED to
/// this repo-root-relative tar path (e.g. `.container-out/gunnar-image.tar`)
/// — the engine's image-export stream (docker-archive format, what
/// `docker save` writes; `podman load` and `docker load` both read it), so a
/// release can carry the container as a FILE instead of "it exists in podman
/// storage on the build box". Empty (the default) exports nothing, and every
/// existing descriptor's JSON is byte-identical (`skip_serializing_if`).
/// Valid in both synthesis and file mode: it is an OUTPUT, not a build input.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub archive: String,
/// **Consumption edges** (`[build.container.edges.<feature>]`) — a root
/// feature bound to another build thing's artifact, `repo/variant : typ`,
/// type-checked at load against the donor's declaration — the SAME edge
/// every other kind carries (design f3: "any feature bound to
/// `repo/variant : typ`"). Empty for every existing descriptor.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub edges: BTreeMap<String, crate::tree::EdgeDecl>,
/// **Where [`base`](Self::base) is built from, when the base is OURS**
/// (`[build.container.base_recipe]`). A forge whose engine does not have the
/// base builds it from this recipe first — a jera job before the cell —
/// instead of failing the cell on a `FROM` it cannot pull (loki 2026-10-16:
/// tunnr's `localhost/tunnr-rdp-weston-base:latest` and
/// `localhost/nordisk-falkordb-valkey:9`, both recipes of ours). Absent on every
/// existing descriptor.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_recipe: Option<BaseRecipe>,
/// **What this image's consumers require of it** — the toolchain surface's
/// closure, landed by [`apply_tool_requirements`](Self::apply_tool_requirements)
/// before the build. The twin of
/// [`KernelBuild::requirements`](crate::kernel::KernelBuild::requirements):
/// derived, never hand-declared, and carried on the row so the build can
/// emit `toolchain.why.toml` beside the image. Absent on every image nobody
/// requires anything of, so every existing descriptor's JSON is unchanged.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub requires: Option<crate::toolchain_requires::ToolRequirements>,
}
fn is_oci(f: &ImageFormat) -> bool {
*f == ImageFormat::Oci
}
impl ContainerBuild {
/// This descriptor's artifact kind — always
/// [`ArtifactKind::Container`](crate::variant::ArtifactKind::Container). The
/// unifying label that lets one dispatcher / enumeration / provenance `kind`
/// span containers alongside the cargo variants.
pub fn artifact_kind(&self) -> crate::variant::ArtifactKind {
crate::variant::ArtifactKind::Container
}
/// The cargo build variants this image chains as layers (via
/// [`ImageLayer::from_variant`]) — the builds that must run first so their
/// artifacts can be staged into the build context.
pub fn variant_deps(&self) -> Vec<&str> {
self.layers.iter().filter_map(|l| l.from_variant.as_deref()).collect()
}
/// The first image tag, if any (the "primary" reference).
pub fn primary_tag(&self) -> Option<&str> {
self.tags.first().map(|s| s.as_str())
}
/// Whether this cell builds a **real Containerfile on disk** rather than a
/// synthesized one — i.e. [`containerfile`](Self::containerfile) is set.
pub fn is_file_mode(&self) -> bool {
!self.containerfile.trim().is_empty()
}
/// The build-context directory the engine tars up and `COPY` resolves
/// against, repo-root-relative.
///
/// File mode: the declared [`context`](Self::context), else the declared
/// Containerfile's PARENT directory (`containers/spark-iceberg/Containerfile`
/// → `containers/spark-iceberg`), else `.`. Synthesis mode: always `.` — the
/// repo root, which is what the layer-staging contract assumes.
pub fn context_dir(&self) -> String {
if !self.is_file_mode() {
return ".".to_string();
}
let ctx = self.context.trim();
if !ctx.is_empty() {
return normalize_rel(ctx);
}
let cf = normalize_rel(self.containerfile.trim());
match cf.rsplit_once('/') {
Some((parent, _)) if !parent.is_empty() => parent.to_string(),
_ => ".".to_string(),
}
}
/// The Containerfile's path **relative to the build context** — the name an
/// engine that receives a context TAR (bollard's `dockerfile` option) must use
/// to find it inside the stream. `Dockerfile` in synthesis mode, since that is
/// the name this crate tars its synthesized text under.
///
/// Always inside the context: [`validate`](Self::validate) refuses a
/// Containerfile that is not.
pub fn containerfile_in_context(&self) -> String {
if !self.is_file_mode() {
return "Dockerfile".to_string();
}
let cf = normalize_rel(self.containerfile.trim());
let ctx = self.context_dir();
if ctx == "." {
return cf;
}
cf.strip_prefix(&format!("{ctx}/")).map(str::to_string).unwrap_or(cf)
}
/// Materialize the **Containerfile** (Dockerfile syntax) for this image.
/// Deterministic (env/labels are key-sorted), so the same descriptor always
/// renders the same file — the input a content-addressed image build hashes.
///
/// In **file mode** this synthesizes nothing (there is nothing to synthesize):
/// it returns a comment-only REFERENCE to the on-disk file. That text carries
/// no `FROM`, so it can never be handed to an engine as a buildable
/// Containerfile by mistake — the engine would refuse it immediately instead
/// of producing a plausible-looking WRONG image. Use
/// [`containerfile_text`](Self::containerfile_text) to get the bytes an engine
/// should actually receive.
pub fn containerfile(&self) -> String {
if self.is_file_mode() {
return format!(
"# containerfile: {}\n# context: {}\n# on-disk Containerfile — nothing is synthesized for this cell\n",
normalize_rel(self.containerfile.trim()),
self.context_dir()
);
}
let mut f = String::new();
f.push_str(&format!("FROM {}\n", self.base));
if let Some(w) = &self.workdir {
f.push_str(&format!("WORKDIR {w}\n"));
}
// No `RUN chmod +x` here, deliberately. The build context tar already carries
// the mode (0755 when `executable`), and `COPY` preserves it — so the chmod
// was always redundant, and on a shell-free base (`scratch`, distroless) it is
// fatal: there is no `/bin/sh` to run it, so the image fails to build. Emitting
// it cost every no-libc appliance either a build failure (`executable = true`)
// or a binary landing 0644 and refusing to start (`executable = false`).
for l in &self.layers {
f.push_str(&format!("COPY {} {}\n", l.copy_source(), l.dest));
}
for (k, v) in &self.env {
f.push_str(&format!("ENV {k}={v}\n"));
}
for p in &self.expose {
f.push_str(&format!("EXPOSE {p}\n"));
}
for (k, v) in &self.labels {
f.push_str(&format!("LABEL {k}={}\n", quote(v)));
}
if !self.entrypoint.is_empty() {
f.push_str(&format!("ENTRYPOINT {}\n", json_exec(&self.entrypoint)));
}
if !self.cmd.is_empty() {
f.push_str(&format!("CMD {}\n", json_exec(&self.cmd)));
}
f
}
/// Materialize the image-builder **argv** for `engine` (`docker` / `podman` /
/// `buildah`): `build [-t <tag>]… -f <file> <context>`.
///
/// Synthesis mode emits `-f - .`: the Containerfile is fed on stdin, so the
/// host pipes [`containerfile`](Self::containerfile) and the context is the
/// repo root. File mode names the REAL file and its context instead
/// (`-f containers/spark-iceberg/Containerfile containers/spark-iceberg`), so
/// nothing is piped and the engine reads a Containerfile this crate never
/// rewrote. Both paths are relative to the repo root, which is the engine's
/// working directory.
pub fn build_args(&self, engine: &str) -> Vec<String> {
let _ = engine; // argv is engine-neutral for docker/podman/buildah
let mut a = vec!["build".to_string()];
for t in &self.tags {
a.push("-t".to_string());
a.push(t.clone());
}
// Appliance/OCI output hint for engines that support it.
if self.format != ImageFormat::Docker {
a.push("--format".to_string());
a.push(self.format.as_str().to_string());
}
a.push("-f".to_string());
if self.is_file_mode() {
a.push(normalize_rel(self.containerfile.trim()));
a.push(self.context_dir());
} else {
a.push("-".to_string()); // Containerfile on stdin
a.push(".".to_string()); // build context
}
a
}
/// The Containerfile **bytes an engine must receive**, resolved against
/// `repo_root`: the real file's contents in file mode, the synthesized text in
/// synthesis mode.
///
/// This is the one accessor that touches disk, and it is what a builder should
/// call — [`containerfile`](Self::containerfile) alone cannot tell the truth
/// for a file-mode cell.
pub fn containerfile_text(&self, repo_root: &std::path::Path) -> Result<String> {
if !self.is_file_mode() {
return Ok(self.containerfile());
}
let p = repo_root.join(normalize_rel(self.containerfile.trim()));
std::fs::read_to_string(&p).map_err(|e| {
anyhow!("container '{}': read Containerfile {}: {e}", self.name, p.display())
})
}
/// A human-readable one-liner, e.g. `docker build -t tunnr-appliance:0.1 -f - .`.
pub fn command_line(&self, engine: &str) -> String {
format!("{engine} {}", self.build_args(engine).join(" "))
}
/// Validate the descriptor: a name, EXACTLY ONE of the two build modes, and —
/// in synthesis mode — every layer fully specified.
///
/// **The two modes never blend.** A cell either names a real Containerfile on
/// disk (`containerfile`) or describes one to synthesize (`base` + `layers` +
/// `env` + …). Setting both is not resolved by precedence — there is no
/// answer to "is the synthesized text prepended, appended, or ignored?" that a
/// reader could predict six months later — so it is REFUSED here, at
/// declaration time, where `--list` can report the cell as unbuildable. Only
/// `name`, `tags` and `format` are shared: those are engine FLAGS
/// (`-t`/`--format`), not Containerfile text, so they carry no ambiguity.
///
/// Path checks are lexical (no I/O) so this stays callable from a wasm
/// build-thing plugin; [`validate_in`](Self::validate_in) adds the on-disk
/// existence check.
pub fn validate(&self) -> Result<()> {
if self.name.trim().is_empty() {
return Err(anyhow!("container build: empty name"));
}
crate::tree::validate_edge_rows(&format!("container '{}'", self.name), None, &self.edges, &[])?;
// `archive` is an output path and must stay inside the repo: an absolute
// path (or a `..` escape) would let a descriptor write anywhere on the
// build host, and the artifact record would then name a file outside the
// checkout it claims to describe.
let arch = self.archive.trim();
if !arch.is_empty()
&& (std::path::Path::new(arch).is_absolute()
|| arch.split(['/', '\\']).any(|c| c == ".."))
{
return Err(anyhow!(
"container '{}': `archive` must be a repo-root-relative path without `..` \
(got `{arch}`)",
self.name
));
}
if self.is_file_mode() {
return self.validate_file_mode();
}
if !self.context.trim().is_empty() {
return Err(anyhow!(
"container '{}': `context` is set without `containerfile`. A synthesized cell's \
build context is fixed at the repo root by the layer-staging contract (a \
`from_variant` layer's artifact is staged as `./<variant>`), so a different \
context would break every `COPY` this crate emits — declare a `containerfile` \
or drop the `context`",
self.name
));
}
if self.base.trim().is_empty() {
return Err(anyhow!("container '{}': empty base image", self.name));
}
for (i, l) in self.layers.iter().enumerate() {
if l.dest.trim().is_empty() {
return Err(anyhow!("container '{}': layer #{i} has no dest", self.name));
}
if l.from_variant.is_none() && l.source.trim().is_empty() {
return Err(anyhow!(
"container '{}': layer #{i} needs a `source` or a `from_variant`",
self.name
));
}
}
Ok(())
}
/// The file-mode half of [`validate`](Self::validate): refuse any synthesis
/// field, and refuse a path that cannot work.
fn validate_file_mode(&self) -> Result<()> {
// ── the exclusivity gate ──────────────────────────────────────────────
let mut blended: Vec<&str> = Vec::new();
if !self.base.trim().is_empty() {
blended.push("base");
}
if !self.layers.is_empty() {
blended.push("layers");
}
if !self.entrypoint.is_empty() {
blended.push("entrypoint");
}
if !self.cmd.is_empty() {
blended.push("cmd");
}
if self.workdir.is_some() {
blended.push("workdir");
}
if !self.env.is_empty() {
blended.push("env");
}
if !self.expose.is_empty() {
blended.push("expose");
}
if !self.labels.is_empty() {
blended.push("labels");
}
if !blended.is_empty() {
return Err(anyhow!(
"container '{}': `containerfile` builds the file on disk VERBATIM, so it cannot \
be combined with the synthesized fields {} — there is no defined order in which \
synthesized text would join a real Containerfile (prepended? appended? \
ignored?), so the combination is refused instead of guessed. Move those \
directives into {} , or drop `containerfile` and let this cell synthesize. \
(`tags` and `format` are engine flags and stay legal in both modes.)",
self.name,
blended.join(" + "),
self.containerfile.trim()
));
}
// ── the path gate ─────────────────────────────────────────────────────
let raw_cf = self.containerfile.trim();
check_rel_path(&self.name, "containerfile", raw_cf)?;
let cf = normalize_rel(raw_cf);
let raw_ctx = self.context.trim();
if !raw_ctx.is_empty() {
check_rel_path(&self.name, "context", raw_ctx)?;
}
let ctx = self.context_dir();
// podman/Docker forbid `COPY` reaching above the build context, and the
// engine only ever tars the context — so a Containerfile living OUTSIDE it
// is not merely unusual, it is unbuildable: the file is not in the stream
// the engine receives. Refuse it here rather than at build time.
if ctx != "." && !(cf == ctx || cf.starts_with(&format!("{ctx}/"))) {
return Err(anyhow!(
"container '{}': containerfile '{cf}' lives OUTSIDE its build context '{ctx}'. \
The engine tars up the context and nothing above it, and `COPY` cannot reach \
above it either, so this cell could never build — put the Containerfile inside \
'{ctx}', or widen `context` to a directory that holds it",
self.name
));
}
Ok(())
}
/// [`validate`](Self::validate) plus the **on-disk** check a file-mode cell
/// needs: the named Containerfile really exists under `repo_root`, and its
/// build context really is a directory.
///
/// This is what makes `--list` tell the truth. A cell naming a Containerfile
/// that is not there is not "buildable, we'll find out later" — it is a broken
/// declaration, and finding that out at build time (after the queue, after the
/// deps built) is exactly the failure the declaration exists to prevent.
/// Synthesis-mode cells touch no disk here and behave exactly as
/// [`validate`](Self::validate).
/// **What this image PROVIDES, as the toolchain surface's vocabulary** —
/// the tool names its own recipe mentions.
///
/// `text` is the image's recipe: the Containerfile's bytes in file mode, or
/// the synthesized layers' in synthesis mode. A tool is provided iff the
/// recipe NAMES it, which is exactly the question the hand-written
/// `the_toolchain_image_carries_every_step_s_tool` asked before this surface
/// existed — promoted from a test's assertion to the donor's exposure, so
/// the closure can ask it instead of a human remembering to.
///
/// **What this is not**, said plainly rather than discovered later: it does
/// not run the image and it does not read a package database. A recipe that
/// names `clang` in a comment counts as providing it, and a base image that
/// carries `gcc` without naming it does not. That asymmetry is the honest
/// one for a DECLARATION-time check — the alternative is running a container
/// to answer a question about a build plan — and the live half is the build
/// itself failing, loudly, on a missing binary.
pub fn provides_tools(&self, recipe: &str, asked: &[String]) -> Vec<String> {
let mut out: Vec<String> = asked.iter().filter(|t| recipe.contains(t.as_str())).cloned().collect();
out.sort();
out.dedup();
out
}
/// **Land a consumer's tool requirements on this image, or refuse BY NAME.**
///
/// The twin of [`KernelBuild::apply_requirements`](crate::kernel::KernelBuild::apply_requirements),
/// and the same three legs in the same order: the requirers must agree with
/// each other, the IMAGE must honour what they agree on, and what survives
/// is kept ON the row so the why-index can be emitted beside the artifact.
///
/// A refusal names tool · consumer · feature · reason, which is the whole
/// difference between this and a build that dies on `qemu-system-x86: not
/// found` twenty minutes in with nothing saying who wanted it.
pub fn apply_tool_requirements(
&mut self,
reqs: &crate::toolchain_requires::ToolRequirements,
recipe: &str,
) -> Result<()> {
let conflicts = reqs.conflicts();
if !conflicts.is_empty() {
return Err(anyhow!(
"container '{}': its consumers disagree about what it must carry — {}",
self.name,
conflicts.iter().map(ToString::to_string).collect::<Vec<_>>().join("; ")
));
}
let asked: Vec<String> = reqs.rows.keys().cloned().collect();
let provides = self.provides_tools(recipe, &asked);
let against = reqs.contradictions(&provides, &format!("the image '{}' recipe", self.name));
if !against.is_empty() {
return Err(anyhow!(
"container '{}' does not satisfy its consumers' requirements — {} — add it to the image's recipe, or drop the axis that needs it",
self.name,
against.iter().map(ToString::to_string).collect::<Vec<_>>().join("; ")
));
}
self.requires = Some(reqs.clone());
Ok(())
}
/// The inverted index for this image against a closure — the file
/// [`TOOLCHAIN_WHY_FILE`](crate::toolchain_requires::TOOLCHAIN_WHY_FILE)
/// carries, emitted beside the built image.
///
/// `everything` is every tool name worth classifying: the requirements' plus
/// whatever roster the caller knows the image installs. What it answers that
/// nothing answered before: **which tools are in this image that nobody asked
/// for** (`image-only`), the exact twin of `preset-only`, and the same rule
/// applies — it is a question, not a verdict.
pub fn tool_why_index(
&self,
recipe: &str,
everything: &[String],
) -> crate::toolchain_requires::ToolWhyIndex {
let empty = crate::toolchain_requires::ToolRequirements::default();
let reqs = self.requires.as_ref().unwrap_or(&empty);
let mut asked: Vec<String> = reqs.rows.keys().cloned().collect();
asked.extend(everything.iter().cloned());
let provides = self.provides_tools(recipe, &asked);
crate::toolchain_requires::ToolWhyIndex::of(&provides, reqs)
}
pub fn validate_in(&self, repo_root: &std::path::Path) -> Result<()> {
self.validate()?;
if !self.is_file_mode() {
return Ok(());
}
let cf = normalize_rel(self.containerfile.trim());
let abs = repo_root.join(&cf);
if !abs.is_file() {
return Err(anyhow!(
"container '{}': containerfile '{cf}' does not exist under {} (looked at {})",
self.name,
repo_root.display(),
abs.display()
));
}
let ctx = self.context_dir();
let ctx_abs = repo_root.join(&ctx);
if !ctx_abs.is_dir() {
return Err(anyhow!(
"container '{}': build context '{ctx}' is not a directory under {} (looked at {})",
self.name,
repo_root.display(),
ctx_abs.display()
));
}
Ok(())
}
/// The canonical bytes an image build's content id is taken over: the
/// descriptor's Containerfile plus its sorted tags/format — a stable image
/// spec fingerprint that survives serde round-trips.
pub fn canonical_bytes(&self) -> Vec<u8> {
let mut s = self.containerfile();
s.push_str("#tags:");
let mut tags = self.tags.clone();
tags.sort();
s.push_str(&tags.join(","));
s.push_str(&format!("#format:{}", self.format.as_str()));
s.into_bytes()
}
/// A content-addressed id (lowercase-hex SHA-256 over [`canonical_bytes`]) —
/// the image-build analogue of a cargo build's provenance digest.
pub fn content_id(&self) -> String {
nornir_hash::sha256_hex(&self.canonical_bytes())
}
}
/// Lexically normalize a repo-root-relative path: `\` → `/`, drop `.` and empty
/// segments, resolve `..` against the segments already collected. Purely textual —
/// no symlink resolution, no I/O — so it is identical on every host and inside a
/// wasm plugin. `""` normalizes to `.`.
///
/// Leading `..` that cannot be resolved is PRESERVED, so
/// [`check_rel_path`] can still see (and refuse) an escape.
fn normalize_rel(p: &str) -> String {
let mut out: Vec<&str> = Vec::new();
for seg in p.split(['/', '\\']) {
match seg {
"" | "." => {}
".." => match out.last() {
Some(&last) if last != ".." => {
out.pop();
}
_ => out.push(".."),
},
s => out.push(s),
}
}
if out.is_empty() { ".".to_string() } else { out.join("/") }
}
/// Refuse a path a container cell can never portably use: absolute (it pins the
/// declaration to one host's filesystem, and breaks the moment the repo is cloned,
/// worktree'd or built in CI) or escaping the repo root via `..` (the engine is
/// handed the repo root as its working directory; nothing above it is in scope).
fn check_rel_path(name: &str, field: &str, raw: &str) -> Result<()> {
let mut c = raw.chars();
let windows_drive = matches!(
(c.next(), c.next(), c.next()),
(Some(d), Some(':'), Some('/' | '\\')) if d.is_ascii_alphabetic()
);
if raw.starts_with('/') || raw.starts_with('\\') || windows_drive {
return Err(anyhow!(
"container '{name}': `{field}` = '{raw}' is absolute; container paths are relative to \
the REPO ROOT so the same declaration builds from a clone, a git worktree and CI"
));
}
let norm = normalize_rel(raw);
if norm == ".." || norm.starts_with("../") {
return Err(anyhow!(
"container '{name}': `{field}` = '{raw}' escapes the repo root (normalizes to \
'{norm}'); a build context is rooted at the repo, and nothing above it is in scope"
));
}
Ok(())
}
/// Render an exec-form array as JSON (`["a","b"]`) for `ENTRYPOINT` / `CMD`.
fn json_exec(parts: &[String]) -> String {
let inner: Vec<String> = parts.iter().map(|p| quote(p)).collect();
format!("[{}]", inner.join(", "))
}
/// Minimal JSON string quoting for Containerfile exec-form + labels.
fn quote(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
_ => out.push(c),
}
}
out.push('"');
out
}
#[cfg(test)]
mod tests {
use super::*;
fn appliance() -> ContainerBuild {
ContainerBuild {
name: "tunnr-appliance".to_string(),
base: "debian:bookworm-slim".to_string(),
layers: vec![
ImageLayer::from_variant("full", "/usr/local/bin/tunnr"),
ImageLayer::copy("assets/", "/opt/tunnr/assets"),
],
entrypoint: vec!["/usr/local/bin/tunnr".to_string()],
cmd: vec!["--serve".to_string()],
workdir: Some("/opt/tunnr".to_string()),
env: BTreeMap::from([("TUNNR_ENV".to_string(), "prod".to_string())]),
expose: vec![8080],
labels: BTreeMap::from([("org.tunnr.kind".to_string(), "appliance".to_string())]),
tags: vec!["tunnr-appliance:0.1".to_string()],
// Unpinned: no consumer pins the appliance image.
expected_sha256: String::new(),
format: ImageFormat::Oci,
// Synthesis mode: no on-disk Containerfile, no declared context. Spelled
// out rather than `..Default::default()` so the fixture stays exhaustive
// and the next field added to `ContainerBuild` has to be considered here.
containerfile: String::new(),
context: String::new(),
// No distributable archive in the fixture: the export leg has its own
// tests; here it must stay off so every other assertion sees the
// one-artifact shape.
archive: String::new(),
// Nobody requires anything of the fixture image: the toolchain
// surface's legs (the closure, the refusal, the why-index) have
// their own tests, and `requires` is DERIVED — a hand-declared one
// would be the second writer that field exists to not have.
requires: None,
// Built FROM a public image: no recipe of ours to build it from.
base_recipe: None,
// No consumption edges in the fixture: the edge legs (type check,
// `--build`) have their own tests; here the container keeps its
// one-artifact shape.
edges: BTreeMap::new(),
}
}
/// A shell-free base must get a Containerfile it can actually build.
///
/// `scratch` and the distroless images carry no `/bin/sh`, so ANY `RUN` line is a
/// build failure rather than a slow path. This asserts the property directly —
/// no `RUN` is emitted for an executable layer — rather than asserting the absence
/// of one particular string, so a future step that reintroduces a shell command by
/// another name is caught too.
///
/// Seen red against the previous renderer, which emitted
/// `RUN chmod +x /gunnar` here.
#[test]
fn a_shell_free_base_gets_no_run_line_for_an_executable_layer() {
let img = ContainerBuild {
name: "appliance-scratch".into(),
base: "scratch".into(),
layers: vec![ImageLayer {
source: String::new(),
from_variant: Some("appliance-server".into()),
dest: "/appliance".into(),
executable: true,
}],
entrypoint: vec!["/appliance".into()],
..Default::default()
};
let cf = img.containerfile();
assert!(
cf.contains("COPY ./appliance-server /appliance\n"),
"the layer must still be copied: {cf}"
);
assert!(
!cf.lines().any(|l| l.starts_with("RUN ")),
"a shell-free base cannot execute any RUN line, but got:\n{cf}"
);
}
#[test]
fn containerfile_materializes_deterministically() {
let img = appliance();
let cf = img.containerfile();
assert!(cf.starts_with("FROM debian:bookworm-slim\n"));
assert!(cf.contains("WORKDIR /opt/tunnr\n"));
// the variant-chained layer stages under ./full; its mode rides in the context
// tar, not in a `RUN`.
assert!(cf.contains("COPY ./full /usr/local/bin/tunnr\n"));
assert!(cf.contains("COPY assets/ /opt/tunnr/assets\n"));
assert!(cf.contains("ENV TUNNR_ENV=prod\n"));
assert!(cf.contains("EXPOSE 8080\n"));
assert!(cf.contains(r#"LABEL org.tunnr.kind="appliance""#));
assert!(cf.contains(r#"ENTRYPOINT ["/usr/local/bin/tunnr"]"#));
assert!(cf.contains(r#"CMD ["--serve"]"#));
// deterministic
assert_eq!(cf, appliance().containerfile());
}
#[test]
fn build_args_carry_tags_and_format() {
let img = appliance();
let args = img.build_args("docker");
assert!(args.windows(2).any(|w| w == ["-t", "tunnr-appliance:0.1"]));
assert!(args.windows(2).any(|w| w == ["--format", "oci"]));
assert!(args.windows(2).any(|w| w == ["-f", "-"]), "Containerfile on stdin");
assert_eq!(args.last().unwrap(), ".");
assert_eq!(img.command_line("podman"), format!("podman {}", args.join(" ")));
}
#[test]
fn variant_deps_lists_chained_cargo_builds() {
assert_eq!(appliance().variant_deps(), vec!["full"]);
assert_eq!(appliance().primary_tag(), Some("tunnr-appliance:0.1"));
}
#[test]
fn validate_catches_missing_base_and_layer_source() {
let mut img = appliance();
img.base = "".to_string();
assert!(img.validate().is_err(), "empty base rejected");
let mut img = appliance();
img.layers.push(ImageLayer { source: "".into(), dest: "/x".into(), from_variant: None, executable: false });
assert!(img.validate().is_err(), "layer with neither source nor from_variant rejected");
assert!(appliance().validate().is_ok());
}
/// Provenance round-trip: the descriptor survives JSON serde byte-for-byte and
/// its content id is stable across the round-trip (a desktop-app image and a
/// tunnr appliance both express + fingerprint cleanly).
#[test]
fn descriptor_and_content_id_round_trip() {
let img = appliance();
let id = img.content_id();
assert_eq!(id.len(), 64);
let json = serde_json::to_string_pretty(&img).unwrap();
let back: ContainerBuild = serde_json::from_str(&json).unwrap();
assert_eq!(back, img, "ContainerBuild survives a JSON round-trip");
assert_eq!(back.content_id(), id, "content id is stable across round-trip");
// A desktop-project app image expresses cleanly too.
let desktop = ContainerBuild {
name: "desktop-app".to_string(),
base: "scratch".to_string(),
layers: vec![ImageLayer::from_variant("minimal", "/app")],
entrypoint: vec!["/app".to_string()],
tags: vec!["desktop-app:latest".to_string()],
format: ImageFormat::Docker,
..Default::default()
};
desktop.validate().unwrap();
let back2: ContainerBuild =
serde_json::from_str(&serde_json::to_string(&desktop).unwrap()).unwrap();
assert_eq!(back2, desktop);
assert_ne!(back2.content_id(), id, "distinct images have distinct ids");
}
// ══ FILE MODE: a real Containerfile on disk ═════════════════════════════════
/// gunnar's checked-in `gunnar-scratch` cell, VERBATIM from
/// `gunnar/.nornir/build-variants.toml` (lines 735-742). Copied in rather than
/// read from the sibling checkout so the regression runs on any host; the
/// sibling sweep below covers the live files when they are there.
const GUNNAR_SCRATCH_CELL: &str = r#"
name = "gunnar-scratch"
base = "scratch"
entrypoint = ["/gunnar"]
cmd = ["serve"]
workdir = "/data"
expose = [2222, 50051]
tags = ["gunnar-scratch:0.1.0"]
"#;
/// **The back-compat proof.** An EXISTING synthesis-mode cell — gunnar's real
/// `gunnar-scratch`, parsed from its checked-in TOML — must render a
/// byte-identical Containerfile and byte-identical build args after the
/// `containerfile`/`context` fields landed.
///
/// Frozen as exact strings, not `contains`, because "additive and
/// back-compatible" is a claim about BYTES: a stray blank line or a reordered
/// arg changes every existing image's `content_id` and silently invalidates
/// every provenance record already sealed. `contains` would not have caught
/// that; this does.
#[test]
fn an_existing_synthesis_cell_is_byte_identical_after_the_file_mode_fields() {
let c: ContainerBuild = toml::from_str(GUNNAR_SCRATCH_CELL).expect("gunnar cell parses");
assert!(!c.is_file_mode(), "an existing cell declares no containerfile");
assert_eq!(
c.containerfile(),
"FROM scratch\n\
WORKDIR /data\n\
EXPOSE 2222\n\
EXPOSE 50051\n\
ENTRYPOINT [\"/gunnar\"]\n\
CMD [\"serve\"]\n",
"the synthesized Containerfile must not have moved by one byte"
);
assert_eq!(
c.build_args("podman"),
vec!["build", "-t", "gunnar-scratch:0.1.0", "--format", "oci", "-f", "-", "."],
"synthesis mode still feeds the Containerfile on stdin against a repo-root context"
);
assert_eq!(c.context_dir(), ".");
assert_eq!(c.containerfile_in_context(), "Dockerfile");
// The content id is derived from the two above, so freezing it freezes the
// provenance identity of every already-built synthesis image.
assert_eq!(
c.content_id(),
nornir_hash::sha256_hex(&c.canonical_bytes()),
"content id still hashes the canonical bytes"
);
c.validate().expect("an existing cell still validates");
}
/// The whole point: a cell naming a REAL Containerfile hands the engine that
/// file, not a synthesized one. Modelled on korp's `containers/spark-iceberg`,
/// which is `ARG`-parameterised with two `RUN` steps and therefore cannot be
/// expressed by the synthesis fields at all.
#[test]
fn a_containerfile_cell_points_the_build_at_the_real_file() {
let c: ContainerBuild = toml::from_str(
r#"
name = "nordisk-spark-iceberg"
containerfile = "containers/spark-iceberg/Containerfile"
tags = ["localhost/nordisk-spark-iceberg:4.1.2", "localhost/nordisk-spark-iceberg:latest"]
"#,
)
.unwrap();
c.validate().expect("a file-mode cell with no synthesis fields is valid");
assert!(c.is_file_mode());
// `context` defaults to the Containerfile's own directory — the layout korp
// already uses (`podman build … containers/spark-iceberg`).
assert_eq!(c.context_dir(), "containers/spark-iceberg");
assert_eq!(c.containerfile_in_context(), "Containerfile");
let args = c.build_args("podman");
assert_eq!(
args,
vec![
"build",
"-t",
"localhost/nordisk-spark-iceberg:4.1.2",
"-t",
"localhost/nordisk-spark-iceberg:latest",
"--format",
"oci",
"-f",
"containers/spark-iceberg/Containerfile",
"containers/spark-iceberg",
],
"-f names the REAL file and the context is its directory; nothing goes on stdin"
);
assert!(!args.iter().any(|a| a == "-"), "no stdin Containerfile in file mode: {args:?}");
// And the synthesizer refuses to pretend: no FROM, so this text can never be
// mistaken for a buildable Containerfile.
let cf = c.containerfile();
assert!(!cf.contains("FROM"), "file mode synthesizes no FROM: {cf}");
assert!(cf.contains("containers/spark-iceberg/Containerfile"));
}
/// An explicit `context` wider than the Containerfile's directory is honoured,
/// and the context-relative name follows it (what a tar-streaming engine needs).
#[test]
fn an_explicit_context_widens_the_tar_and_moves_the_relative_name() {
let c: ContainerBuild = toml::from_str(
r#"
name = "falkordb-valkey"
containerfile = "containers/falkordb-valkey/Containerfile"
context = "containers"
tags = ["localhost/nordisk-falkordb-valkey:9-20260724"]
"#,
)
.unwrap();
c.validate().unwrap();
assert_eq!(c.context_dir(), "containers");
assert_eq!(c.containerfile_in_context(), "falkordb-valkey/Containerfile");
let args = c.build_args("podman");
assert_eq!(args[args.len() - 2], "containers/falkordb-valkey/Containerfile");
assert_eq!(args[args.len() - 1], "containers");
}
/// **The exclusivity rule.** A cell that sets `containerfile` AND any
/// synthesis field is REFUSED at validation — never silently resolved.
///
/// Each synthesis field is checked on its own so the rule cannot rot into
/// "only `base` is caught": `env`/`layers`/`labels` are exactly the ones a
/// reader would expect to be merged into a real Containerfile, and merging is
/// the behaviour being forbidden.
#[test]
fn a_cell_that_blends_a_real_containerfile_with_synthesis_is_refused() {
let base = r#"name = "x"
containerfile = "containers/x/Containerfile"
"#;
for (field, extra) in [
("base", "base = \"scratch\"\n"),
("entrypoint", "entrypoint = [\"/x\"]\n"),
("cmd", "cmd = [\"serve\"]\n"),
("workdir", "workdir = \"/data\"\n"),
("expose", "expose = [8080]\n"),
("env", "[env]\nA = \"b\"\n"),
("labels", "[labels]\n\"org.x\" = \"y\"\n"),
("layers", "[[layers]]\nsource = \"a\"\ndest = \"/a\"\n"),
] {
let c: ContainerBuild = toml::from_str(&format!("{base}{extra}")).unwrap();
let err = c
.validate()
.expect_err(&format!("`containerfile` + `{field}` must be refused"));
let msg = err.to_string();
assert!(msg.contains(field), "the error must name `{field}`: {msg}");
assert!(
msg.contains("containers/x/Containerfile"),
"the error must point at the real file: {msg}"
);
}
// `tags` + `format` are engine FLAGS, not Containerfile text — legal in both
// modes, which is what makes the rule teachable.
let ok: ContainerBuild = toml::from_str(
"name = \"x\"\ncontainerfile = \"containers/x/Containerfile\"\n\
tags = [\"x:1\"]\nformat = \"docker\"\n",
)
.unwrap();
ok.validate().expect("tags + format stay legal alongside a real Containerfile");
}
/// `context` alone is meaningless (synthesis mode's context is fixed by the
/// layer-staging contract), so it is an error rather than a silent no-op.
#[test]
fn a_context_without_a_containerfile_is_refused() {
let c: ContainerBuild = toml::from_str(
"name = \"x\"\nbase = \"scratch\"\ncontext = \"containers/x\"\n",
)
.unwrap();
let msg = c.validate().expect_err("context without containerfile").to_string();
assert!(msg.contains("`context` is set without `containerfile`"), "{msg}");
}
/// A Containerfile outside its context can NEVER build: the engine only ever
/// receives the context, and `COPY` cannot reach above it. Refused, with the
/// reason, rather than discovered as a "file not found" from the daemon.
#[test]
fn a_containerfile_outside_its_context_is_refused() {
let c: ContainerBuild = toml::from_str(
"name = \"x\"\ncontainerfile = \"build/Containerfile\"\ncontext = \"containers/x\"\n",
)
.unwrap();
let msg = c.validate().expect_err("outside the context").to_string();
assert!(msg.contains("OUTSIDE its build context"), "{msg}");
// Absolute and escaping paths are refused for the same reason: the engine's
// working directory is the repo root and nothing above it is in scope.
for bad in ["/etc/Containerfile", "../other-repo/Containerfile"] {
let c: ContainerBuild =
toml::from_str(&format!("name = \"x\"\ncontainerfile = \"{bad}\"\n")).unwrap();
assert!(c.validate().is_err(), "`{bad}` must be refused");
}
}
/// **`--list` must tell the truth.** A cell naming a Containerfile that is not
/// in the checkout is rejected at VALIDATION, not discovered at build time
/// after the queue and the dependency builds.
#[test]
fn a_missing_containerfile_is_refused_at_validation() {
let root = tempfile::tempdir().unwrap();
std::fs::create_dir_all(root.path().join("containers/spark-iceberg")).unwrap();
let c: ContainerBuild = toml::from_str(
"name = \"spark\"\ncontainerfile = \"containers/spark-iceberg/Containerfile\"\n",
)
.unwrap();
// Shape-valid — the lexical rules cannot see the filesystem…
c.validate().expect("shape is fine");
// …but the cell is not buildable in THIS checkout.
let msg = c.validate_in(root.path()).expect_err("missing file").to_string();
assert!(msg.contains("does not exist"), "{msg}");
assert!(msg.contains("containers/spark-iceberg/Containerfile"), "{msg}");
// Write it and the same cell becomes buildable, and hands over the real bytes.
std::fs::write(
root.path().join("containers/spark-iceberg/Containerfile"),
"ARG SPARK_TAG=4.1.2\nFROM docker.io/apache/spark:${SPARK_TAG}\nRUN curl -o x\n",
)
.unwrap();
c.validate_in(root.path()).expect("present containerfile validates");
let text = c.containerfile_text(root.path()).unwrap();
assert!(text.starts_with("ARG SPARK_TAG="), "the REAL file's bytes: {text}");
assert!(text.contains("RUN curl"), "RUN survives — it is not synthesized: {text}");
// A synthesis cell needs no filesystem at all and is unaffected.
appliance().validate_in(root.path()).expect("synthesis mode touches no disk");
assert_eq!(
appliance().containerfile_text(root.path()).unwrap(),
appliance().containerfile()
);
}
/// A file-mode cell chains no cargo variant (it has no `layers`), and its
/// content id still moves with the declared path — two descriptors naming
/// different Containerfiles are different builds.
#[test]
fn file_mode_descriptors_round_trip_and_stay_distinct() {
let a: ContainerBuild =
toml::from_str("name = \"a\"\ncontainerfile = \"containers/a/Containerfile\"\n")
.unwrap();
let b: ContainerBuild =
toml::from_str("name = \"b\"\ncontainerfile = \"containers/b/Containerfile\"\n")
.unwrap();
assert!(a.variant_deps().is_empty());
assert_ne!(a.content_id(), b.content_id(), "the declared path is part of the identity");
let back: ContainerBuild =
serde_json::from_str(&serde_json::to_string(&a).unwrap()).unwrap();
assert_eq!(back, a);
assert_eq!(back.content_id(), a.content_id());
// And a synthesis cell still serializes WITHOUT the two new keys, so an
// already-persisted descriptor keeps its JSON shape.
let json = serde_json::to_string(&appliance()).unwrap();
assert!(!json.contains("containerfile"), "no empty field leaks into JSON: {json}");
assert!(!json.contains("\"context\""), "no empty field leaks into JSON: {json}");
}
/// Path normalization is lexical and host-independent — the same declaration
/// must produce the same argv from a clone, a git worktree and CI.
#[test]
fn relative_paths_normalize_lexically() {
assert_eq!(normalize_rel("./containers//x/./Containerfile"), "containers/x/Containerfile");
assert_eq!(normalize_rel("containers/x/../y/Containerfile"), "containers/y/Containerfile");
assert_eq!(normalize_rel(""), ".");
assert_eq!(normalize_rel("."), ".");
assert_eq!(normalize_rel("../a"), "../a", "an escape is preserved so it can be refused");
// A messy but legal declaration still lands on clean argv.
let c: ContainerBuild =
toml::from_str("name = \"x\"\ncontainerfile = \"./containers/x/../x/Containerfile\"\n")
.unwrap();
c.validate().unwrap();
assert_eq!(c.context_dir(), "containers/x");
assert_eq!(c.build_args("podman").iter().rev().nth(1).unwrap(), "containers/x/Containerfile");
}
#[test]
fn image_format_parses_and_round_trips_all_spellings() {
assert_eq!(ImageFormat::from("oci".to_string()), ImageFormat::Oci);
assert_eq!(ImageFormat::from("".to_string()), ImageFormat::Oci);
assert_eq!(ImageFormat::from("Docker".to_string()), ImageFormat::Docker);
assert_eq!(ImageFormat::from("appliance".to_string()), ImageFormat::Appliance);
assert_eq!(ImageFormat::from("disk".to_string()), ImageFormat::Appliance);
assert_eq!(String::from(ImageFormat::Docker), "docker");
assert_eq!(ImageFormat::default(), ImageFormat::Oci);
}
}
#[cfg(test)]
mod base_recipe_tests {
use super::*;
/// A container cell may name where its base is built from; the recipe is
/// located in this repo or a sibling, and a path that leaves the checkout is
/// refused by name.
#[test]
fn a_base_recipe_is_declared_and_located() {
let set = crate::variant::BuildVariantSet::from_toml_str(
"[build]\nbase_suite = \"ORIGINAL\"\n\n[[build.container]]\nname = \"rdp\"\nbase = \"localhost/tunnr-rdp-weston-base:latest\"\ntags = [\"rdp:0.1\"]\n\n[build.container.base_recipe]\ncontainerfile = \"containers/rdp-weston-base/Containerfile\"\n",
)
.expect("parses");
let c = set.containers.iter().find(|c| c.name == "rdp").expect("the cell");
let r = c.base_recipe.as_ref().expect("the recipe");
let root = std::path::Path::new("/w/tunnr");
let (checkout, ctx, file) = r.locate(root).unwrap();
assert_eq!((checkout.as_path(), ctx.as_path(), file.as_str()), (root, std::path::Path::new("/w/tunnr/containers/rdp-weston-base"), "Containerfile"));
let sib = BaseRecipe { repo: "korp".into(), containerfile: "containers/falkordb-valkey/Containerfile".into(), context: "containers/falkordb-valkey".into() };
assert_eq!(sib.locate(root).unwrap().1, std::path::PathBuf::from("/w/korp/containers/falkordb-valkey"));
let escape = BaseRecipe { containerfile: "../x/Containerfile".into(), ..Default::default() };
assert!(escape.locate(root).is_err());
assert!(BaseRecipe { repo: "a/b".into(), containerfile: "C".into(), ..Default::default() }.locate(root).is_err());
}
}