ADR-1231: Container bases and toolchain versions come from one config file¶
- Status: Accepted (amended by ADR-1306)
- Date: 2026-09-07
- Deciders: Lusoris
- Tags: build, ci, docs, security
Amendment (2026-09-24, ADR-1306): The
nvidia/cudavendor base images (CUDA_BUILDERandCUDA_RUNTIME) were dropped in favor of digest-pinned Ubuntu 26.04 (ubuntu:26.04@sha256:...) matchingDEV_BASE/DEV_UBUNTU, with explicit, version-locked toolkit installation viascripts/ci/install-cuda-toolkit.sh. This decouples CUDA compiler and runtime updates from upstream NVIDIA OCI image publication lag. Rule 3 ofscripts/ci/check-base-image-single-source.shnow enforces thatCUDA_BUILDERandCUDA_RUNTIMEcarryubuntu:${DEV_UBUNTU}@.
Context¶
Every container base image in the tree was pinned by hand at the point of use, and the copies drifted. docker/Dockerfile.controller and docker/Dockerfile.operator were still building on Debian 12 and shipping on distroless-*-debian12 months after the rest of the tree moved to Debian 13. The golang digest quoted in those two files' own header comments did not match the digest in their own FROM line. Two CUDA pins sat on Ubuntu 24.04 while the sibling stage next to them used 26.04.
Worse, four of the pins were not FROM lines at all. dev/Containerfile lifted the Go toolchain out of a golang:1.27-bookworm image with COPY --from=<image>, and docker/Dockerfile.node pulled CUDA, ROCm and oneAPI runtime libraries the same way. Those are base-image pins in every sense that matters — they decide the libc and the SDK the shipped artifact carries — but they are invisible to anyone scanning for FROM, which is why they were the most stale things in the repository.
The same versions also appear in CI workflows, and drifted there independently. ROCm was 7.2.4 in build.yml and 7.2.3 in libvmaf-build-matrix.yml, so CI validated a ROCm the published images never shipped. The Level Zero loader existed at four versions at once — v1.18.5, v1.28.0 twice, v1.29.0, and 1.32.0 — and a skew there does not fail a build: it surfaces at runtime as "No device of requested type available".
Per user direction: define one release configuration and one dev configuration in a single place, rather than changing a version by hand in a dozen files — covering the whole toolchain, not just container bases.
Decision¶
build-config.env at the repository root is the single source of truth for every container base image and toolchain generation. No Dockerfile names a base image directly: each takes it as a build argument whose default mirrors the config, so a plain docker build still works with no wrapper and CI can override any base with --build-arg. Vendor runtime libraries are pulled from named stages rather than COPY --from=<image>, so every pin is an ordinary FROM that both the gate and Renovate can see.
Workflows cannot source a file at parse time, so their run: steps source it at run time (set -a; . ./build-config.env; set +a), and scripts/ci/load-build-config.sh exports every knob into $GITHUB_ENV for steps that need it earlier.
scripts/ci/check-base-image-single-source.sh enforces all of it — including scripts/ci/check-workflow-versions.py for the workflow literals — and make base-images-sync rewrites the mirrors from the config.
Renovate is reconfigured to match. Its docker manager understands ARG X=image + FROM $X, so left alone it would bump the Dockerfile mirrors and leave build-config.env behind — failing this ADR's own gate on Renovate's PRs. One custom manager now matches both the config line and the ARG form across all nine wired files, so a bump lands every copy in a single PR, and a packageRule disables the built-in manager on exactly those files. docker/dev/*.Dockerfile deliberately stays with the built-in manager.
This is deliberately the same shape as scripts/ci/check-default-model-single-source.sh: one authoritative value, mirrors permitted where a consumer cannot read the authority directly, and drift fatal.
Alternatives considered¶
| Option | Pros | Cons | Why not chosen |
|---|---|---|---|
| Config + ARG mirrors + drift gate (chosen) | One edit point; docker build still works standalone; gate makes drift impossible; pins stay FROM lines Renovate already understands | The value appears twice (config + ARG default) | The duplication is enforced, which is the same trade the default-model gate already makes and the repo already trusts |
ARG with no default, values only from a wrapper | Truly one copy of each value | Every bare docker build fails; forkers and IDE tooling break; CI must thread args through six workflows | Breaks the "fork, edit, build" path that motivated the change |
docker buildx bake with an HCL config | Idiomatic; one file drives all targets | Six workflows call docker/build-push-action directly; converting them is a separate, larger change with its own risk | Does not fit the existing CI shape; revisitable later |
| Renovate-only, no config file | Zero new machinery | Renovate updates digests but cannot unify twelve independent pins onto one generation, and never sees COPY --from | Does not solve drift, which was the actual problem |
Consequences¶
- Positive: changing the Debian generation, the Go release, or a GPU SDK is a one-line edit. Four Debian 12 pins and two Ubuntu 24.04 pins are gone. Four base pins that were hidden inside
COPY --fromare now visible to the gate, to Renovate, and to a human reading the file. - Positive:
docker/Dockerfile.controllernow runs as UID 65532. Its old pin wascc-debian12without the:nonrootsuffix, so it ran as root; the shared runtime pin is:nonroot. TheUSERline is written explicitly in the file rather than left implicit in the tag. Both listen ports (8080, 9090) are above 1024, so nothing needs a capability. - Negative: a base image value exists in two places (config and ARG default). The gate is what makes that safe, so the gate is now load-bearing.
- Positive: ROCm moved to the Ubuntu 26.04 variant of 10.0.0. That was verified rather than assumed, because these libraries are copied out of the vendor image into a Debian 13 runtime, where a libc mismatch fails at load rather than at build: the
/opt/rocm/core-10.0/liblayout is identical in both variants and the whole copied closure requires at mostGLIBC_2.28, against Debian 13's 2.41. Worth noting this is the opposite of the oneAPI result — Intel's Ubuntu 26.04 image needs glibc 2.43 and genuinely cannot be copied onto Debian 13 — so the question has to be asked per vendor rather than answered once. - Neutral / follow-ups: oneAPI keeps its Ubuntu 24.04 pins under an explicit, self-closing exemption in the gate. oneAPI 2025 → 2026.1 is a restructure, not a pin swap — see the research digest for the soname, glibc and GPU-driver measurements that determine its shape. Its two entries are the only ones left in the exemption list.
Supply-chain impact¶
- New dependencies: none.
- Removed dependencies:
gcr.io/distroless/cc-debian12,gcr.io/distroless/static-debian12,golang:1.27-bookworm,nvidia/cuda:13.3.1-*-ubuntu24.04. Removal is complete for Debian 12: no live reference remains outside historical records. - Build-time fetches: unchanged. Every base remains digest-pinned; the gate now fails on any pin that is not.
- CVE surface delta: narrows. Debian 12 bases move to Debian 13, and the controller stops running as root.
Verification update — 2026-09-08¶
The 26.04 ROCm choice remains in effect. A later rollback to ADR-1225 restored 24.04 pins without superseding this decision. Fresh registry, HIP compiler and Debian 13 runtime-load checks support restoring 26.04; see the correction evidence.