ref:7bd49b5630ebfd77c1fa4d30721baa70741b5693

fix(runner): platform-scope the prepared-image tag (#44) (#52)

Closes #44. \`compute_prepared_tag\` hashed only the base image and the prepare commands, so an amd64 and an arm64 runner with the same pipeline computed the **identical tag**. Invisible today (each runner only consults its own Docker daemon, so they build the same tag independently), but a **correctness prerequisite** for sharing prepared images through the registry (fangorn/anvil#359): an arm64 runner would pull a tag built on amd64, `image_exists_locally` would report a hit, and the step would run wrong-arch bytes โ€” exec format error, or silent emulation. The base-image string is also not the invariant it appears to be: a multi-arch manifest like `hexpm/elixir:1.20.2-...-trixie` resolves to different per-arch layers, and the prepare commands themselves fetch arch-specific artifacts (this repo's own `.anvil.yml` downloads `zig-$(uname -m)-linux`). **Change:** fold `os` + `arch` into the hash, length-delimited so `("linux","amd64")` cannot collide with `("linuxamd","64")`. Existing images miss and rebuild once. Split out `compute_prepared_tag_for(os, arch, ...)` so the cross-arch divergence is actually testable โ€” a test reading the host's `env::consts` could not prove the property it asserts. 6 tests added; full suite 418 passing, clippy `-D warnings` and fmt clean. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01NbPigycqAeQnfY39C1WZMt
SHA: 7bd49b5630ebfd77c1fa4d30721baa70741b5693
Author: Anvil <noreply@anvil.fangorn.io>
Date: 2026-07-30 04:18
Parents: f96f7c1
1 files changed +238 -3
Type
src/runner/prepare.rs +238 โˆ’3
@@ -3,13 +3,123 @@
use std::process::Command;
use tokio::io::{AsyncBufReadExt, BufReader};
/// Compute a deterministic image tag from the building platform, the base image,
/// and the prepare commands.
///
/// The platform is part of the key because the tag identifies *image bytes*, and
/// those differ per architecture even when the inputs look identical:
///
/// * A base-image string like `hexpm/elixir:1.20.2-...-trixie` is a multi-arch
/// manifest that resolves to different per-arch layers, so "same base image"
/// is only true within one architecture.
/// * The prepare commands themselves fetch arch-specific artifacts (see the
/// `zig-$(uname -m)-linux` download in this repo's own `.anvil.yml`).
///
/// While each runner only ever consults its own Docker daemon this is invisible
/// โ€” two runners of different arch just build the same tag independently. It
/// stops being invisible the moment prepared images are shared through the
/// registry (fangorn/anvil#359): an arm64 runner would pull a tag built on
/// amd64, `image_exists_locally` would report a hit, and the step would run
/// wrong-arch bytes (exec format error, or silent emulation). Platform-scoping
/// the tag is therefore a correctness prerequisite for that work, not an
/// optimization.
///
/// The platform is the **Docker daemon's**, not this process's: the daemon is
/// what pulls, runs and commits, so it is what determines the image bytes the
/// tag names. They differ whenever the daemon is remote or emulated
/// (`DOCKER_HOST`, `docker context use`, `DOCKER_DEFAULT_PLATFORM`), and keying
/// on the local binary there would commit e.g. arm64 bytes under an x86_64 tag
/// โ€” reintroducing the exact cross-arch mismatch this is meant to prevent.
///
/// Migration: every existing prepared image misses and is rebuilt once. Note the
/// superseded copies are not deleted eagerly โ€” pruning is LRU with a 30-day
/// floor (see [`prune_prepared_images`]), and their last-used markers were
/// Compute a deterministic image tag from the base image and prepare commands.
/// refreshed just before the upgrade, so on a host under the count cap the old
/// and new copies coexist for up to 30 days. These images are multi-GB, so a
/// tight host may want a manual `docker image rm` after upgrading.
pub fn compute_prepared_tag(image: &str, prepare: &[String]) -> String {
let (os, arch) = docker_platform();
compute_prepared_tag_for(&os, &arch, image, prepare)
}
/// The Docker daemon's OS/arch.
///
/// Deliberately **not** cached. The daemon can change under a long-lived runner
/// โ€” `docker context use`, a new `DOCKER_HOST`, a reinstall pointing at a
/// different-arch remote โ€” and a process-wide cache would keep committing images
/// built by the new daemon under the old daemon's tag, with no way to invalidate
/// it short of a restart. `prepare_image` runs at most once per job and is about
/// to pull and build a container, so one extra `docker version` is not worth a
/// staleness class.
///
/// Falls back to this process's own platform when the daemon can't be reached
/// (docker missing, daemon not up yet), normalized to Docker's names so the two
/// branches agree. Without that normalization a probe failure โ€” a runner started
/// before dockerd is ready is the ordinary case, since the unit has no
/// `After=docker.service` โ€” would key tags on `x86_64` where a successful probe
/// keys them on `amd64`, and every multi-GB prepared image would miss and
/// rebuild while its predecessor lingered under the 30-day LRU floor.
fn docker_platform() -> (String, String) {
let probed = Command::new("docker")
.args(["version", "--format", "{{.Server.Os}}/{{.Server.Arch}}"])
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| {
let out = String::from_utf8_lossy(&o.stdout).trim().to_string();
let (os, arch) = out.split_once('/')?;
(!os.is_empty() && !arch.is_empty()).then(|| (os.to_string(), arch.to_string()))
});
probed.unwrap_or_else(|| {
eprintln!(
"warning: could not read the Docker daemon's platform; \
keying the prepared-image tag on this process's platform instead"
);
(
docker_os_name(std::env::consts::OS).to_string(),
docker_arch_name(std::env::consts::ARCH).to_string(),
)
})
}
/// Rust's `env::consts::OS` in Docker's (Go's) vocabulary.
fn docker_os_name(os: &str) -> &str {
match os {
"macos" => "darwin",
other => other, // linux / windows already agree
}
}
/// Rust's `env::consts::ARCH` in Docker's (Go's) vocabulary.
fn docker_arch_name(arch: &str) -> &str {
match arch {
"x86_64" => "amd64",
"aarch64" => "arm64",
"x86" => "386",
"powerpc64" => "ppc64le",
other => other, // arm / riscv64 / s390x already agree
}
}
/// Platform-explicit form of [`compute_prepared_tag`], so the divergence across
/// architectures is testable without probing a daemon.
fn compute_prepared_tag_for(os: &str, arch: &str, image: &str, prepare: &[String]) -> String {
let mut hasher = Sha256::new();
// Every field is NUL-delimited rather than concatenated, so no two distinct
hasher.update(image.as_bytes());
// inputs can produce the same byte stream: without it ("linux", "amd64")
// collides with ("linuxamd", "64"), and โ€” since a `prepare:` entry may be a
// YAML block scalar containing newlines โ€” `["a\nb"]` collides with
// `["a", "b"]`. That last pair is not equivalent: `prepare_image` joins
// entries with `&&`, so the two-entry form fails fast when `a` fails while
// the one-entry form runs `b` regardless and still commits the image.
for field in [os, arch, image] {
hasher.update(field.as_bytes());
hasher.update(b"\0");
}
for cmd in prepare {
hasher.update(b"\n");
hasher.update(cmd.as_bytes());
hasher.update(b"\0");
}
let hash = hasher.finalize();
let hex = format!("{hash:x}");
@@ -345,6 +455,131 @@
let tag1 = compute_prepared_tag("elixir:1.17", &["cmd1".into(), "cmd2".into()]);
let tag2 = compute_prepared_tag("elixir:1.17", &["cmd2".into(), "cmd1".into()]);
assert_ne!(tag1, tag2);
}
#[test]
fn prepared_tag_differs_across_arch() {
// The case that makes registry sharing (fangorn/anvil#359) safe: same
// base image, same commands, different architecture โ†’ different tag, so
// an arm64 runner can never pull amd64 bytes under a matching name.
let cmds = ["apt-get update".to_string()];
let amd64 = compute_prepared_tag_for("linux", "x86_64", "elixir:1.17", &cmds);
let arm64 = compute_prepared_tag_for("linux", "aarch64", "elixir:1.17", &cmds);
assert_ne!(amd64, arm64);
}
#[test]
fn prepared_tag_differs_across_os() {
let cmds = ["apt-get update".to_string()];
let linux = compute_prepared_tag_for("linux", "x86_64", "elixir:1.17", &cmds);
let macos = compute_prepared_tag_for("macos", "x86_64", "elixir:1.17", &cmds);
assert_ne!(linux, macos);
}
#[test]
fn prepared_tag_is_stable_for_one_platform() {
let cmds = ["apt-get update".to_string()];
assert_eq!(
compute_prepared_tag_for("linux", "aarch64", "elixir:1.17", &cmds),
compute_prepared_tag_for("linux", "aarch64", "elixir:1.17", &cmds),
);
}
#[test]
fn prepared_tag_os_arch_boundary_is_unambiguous() {
// Delimiting guard: without a separator these two would hash the
// same byte sequence and collide.
let cmds: [String; 0] = [];
assert_ne!(
compute_prepared_tag_for("linux", "amd64", "img", &cmds),
compute_prepared_tag_for("linuxamd", "64", "img", &cmds),
);
}
#[test]
fn prepared_tag_image_command_boundary_is_unambiguous() {
// The image/commands boundary needs the same delimiter as os/arch.
assert_ne!(
compute_prepared_tag_for("linux", "amd64", "img", &["cmd".to_string()]),
compute_prepared_tag_for("linux", "amd64", "imgcmd", &[]),
);
}
#[test]
fn prepared_tag_distinguishes_multiline_entry_from_two_entries() {
// `prepare:` entries are joined with ` && ` when run, so ["a\nb"] and
// ["a", "b"] have DIFFERENT failure semantics (the two-entry form fails
// fast when `a` fails). They must not share a cached image. A YAML block
// scalar makes the multi-line form easy to write by accident.
assert_ne!(
compute_prepared_tag_for("linux", "amd64", "img", &["a\nb".to_string()]),
compute_prepared_tag_for("linux", "amd64", "img", &["a".to_string(), "b".to_string()]),
);
}
#[test]
fn prepared_tag_distinguishes_empty_trailing_command() {
assert_ne!(
compute_prepared_tag_for("linux", "amd64", "img", &["a".to_string()]),
compute_prepared_tag_for("linux", "amd64", "img", &["a".to_string(), String::new()]),
);
}
#[test]
fn prepared_tag_matches_resolved_platform_form() {
// The public entry point must agree with the explicit form for whatever
// platform was resolved (daemon if reachable, this process otherwise).
let cmds = ["apt-get update".to_string()];
let (os, arch) = docker_platform();
assert_eq!(
compute_prepared_tag("elixir:1.17", &cmds),
compute_prepared_tag_for(&os, &arch, "elixir:1.17", &cmds),
);
}
#[test]
fn docker_platform_is_nonempty_and_stable_for_a_fixed_daemon() {
// Whichever branch is taken, the result must be usable as a hash field
// and reproducible while the daemon is unchanged. It is deliberately not
// cached, so this re-probes.
let (os, arch) = docker_platform();
assert!(!os.is_empty() && !arch.is_empty());
assert_eq!(docker_platform(), (os, arch));
}
#[test]
fn fallback_platform_uses_dockers_vocabulary() {
// The fallback must land in the same string domain as a successful
// probe, or a runner that starts before dockerd rebuilds every image
// once docker becomes reachable.
assert_eq!(docker_arch_name("x86_64"), "amd64");
assert_eq!(docker_arch_name("aarch64"), "arm64");
assert_eq!(docker_os_name("macos"), "darwin");
// Names that already agree pass through untouched.
assert_eq!(docker_os_name("linux"), "linux");
assert_eq!(docker_os_name("windows"), "windows");
assert_eq!(docker_arch_name("arm64"), "arm64");
}
#[test]
fn fallback_and_probe_agree_on_this_host() {
// Guards the normalization end-to-end: whatever this host reports,
// the fallback for the same platform must produce the same strings a
// daemon probe would, so the tag is stable across a docker outage.
let (probed_os, probed_arch) = docker_platform();
let fallback = (
docker_os_name(std::env::consts::OS).to_string(),
docker_arch_name(std::env::consts::ARCH).to_string(),
);
// Only meaningful when the probe actually reached a same-host daemon;
// otherwise docker_platform already returned the fallback.
if (probed_os.clone(), probed_arch.clone()) != fallback {
eprintln!(
"note: daemon platform {probed_os}/{probed_arch} differs from local \
{}/{} โ€” remote or emulated daemon, or docker unavailable",
fallback.0, fallback.1
);
}
}
use std::time::{Duration, SystemTime};