Skip to content

Ship the Cloud Native Buildpacks lifecycle in the builder image, and refuse legibly without it - #591

Merged
incognick merged 1 commit into
mainfrom
feat/buildpacks-lifecycle
Aug 14, 2026
Merged

Ship the Cloud Native Buildpacks lifecycle in the builder image, and refuse legibly without it#591
incognick merged 1 commit into
mainfrom
feat/buildpacks-lifecycle

Conversation

@incognick

Copy link
Copy Markdown
Contributor

Closes #590.

A build of a repository with no Dockerfile ran /cnb/lifecycle/creator inside an image that
carried no lifecycle, so the whole explanation a failed build gave was
sh: line 30: /cnb/lifecycle/creator: No such file or directory. Both halves of that are fixed
here: the lifecycle is in the builder image, and the branch refuses in a sentence when it is not.

The lifecycle

images/burrow-builder/Dockerfile is rebased onto heroku/builder:26 — a published Cloud
Native Buildpacks builder for Ubuntu 26.04 — and installs buildah on top of it, rather than
copying a lifecycle into the previous buildah base.

The direction matters. A buildpacks build runs the buildpacks inside the builder image's own root
filesystem
and exports what they produce onto the run image that builder declares; the two are
published and tested as a pair. Splicing /cnb into an unrelated base does run — the lifecycle
reads /cnb and never inspects the image it is running in — but it unpairs them, and anything a
buildpack compiles rather than downloads (node-gyp, a Python C extension, cgo) then links against
one distribution's C library and has to load on another's. That failure surfaces at runtime, in
someone else's application, long after the build went green. So the buildpacks side chooses the
base and buildah, which has no such pairing, is installed into it.

Heroku's builder over Paketo's, on three counts:

  • Size. ~473 MB compressed, against ~780 MB for paketobuildpacks/ubuntu-resolute-builder and
    ~1.5 GB for ubuntu-noble-builder. This image is pulled per build.
  • UID. It runs as UID/GID 1000, which is already the UID the build Job pins, and it has the
    /etc/passwd entry the lifecycle needs for the user it drops to. Paketo's current builders are
    1001 and would require changing the Job's fixed UID to match.
  • Coverage. Its buildpacks cover .NET, Go, Java, Node.js, PHP, Python, Ruby and Scala. Paketo's
    small builders are not the cheaper answer — they carry only Go and Java, so the interpreted
    languages this path exists for would not detect at all.

Ubuntu 26.04 carries buildah 1.42, the same series the previous quay.io/buildah/stable base
carried, so the working Dockerfile path is not moved onto an older builder by the change. It does
need the helpers that base bundled and a distribution package does not pull in: uidmap for the
setuid uid-map helpers, and netavark/aardvark-dns/slirp4netns for the container network
buildah configures for each RUN step — without netavark it refuses before the first instruction.

Size delta: 261 MB → 533 MB compressed (1.48 GB unpacked), measured by pushing both to a
registry and summing layers. That is the price of a language-detecting builder in the same image
as a container builder, and it is paid on the pull, which is per node rather than per build.

The refusal, which stands on its own

The buildpacks branch now tests for the lifecycle before running it and fails with a sentence
naming the cause and the ways out, in the voice the insecure-registry refusal beside it already
uses. The builder image bundles a lifecycle, so this is not the expected path — but the branch
invokes an absolute path inside an image the install can override (BURROW_BUILD_IMAGE), and the
shell's answer for a missing file is not an answer a user can act on.

What the Job had to grow

The lifecycle writes into /layers — 208 MB for a trivial Node application — and the Job mounted
no such path, so it gets a writable emptyDir, mounted for both branches because the branch is
chosen inside the pod from the cloned tree (ADR-0053 §3). /workspace, $HOME and /tmp were
already mounted and are the only other paths the lifecycle writes; the platform directory is
created in the image.

DOCKER_CONFIG now names the already-mounted registry-auth directory. The lifecycle does not read
$REGISTRY_AUTH_FILE — it resolves credentials through the docker keychain — so without this a
no-Dockerfile build would authenticate as nobody and its push to a private registry would be
refused. buildah prefers $REGISTRY_AUTH_FILE, so naming the same file twice leaves the Dockerfile
path unchanged.

Verified

Neither path can be exercised by the test suite — TestBuildIntegration is still a skipped
tracker, and the k3d suite never runs a build — so both were run locally against the built image,
as the pod runs them (privileged, UID 1000, 2 GiB, the emptyDirs mounted, the recipe taken verbatim
from buildScript):

  • Dockerfile path: builds and pushes to a plain-HTTP registry with --tls-verify=false, and
    writes the digest to the termination log. Unchanged behaviour on the new base.
  • Buildpacks path: a Node application with no Dockerfile detects, builds and pushes in 35
    seconds; the digest read out of /layers/report.toml matches the pushed manifest. The produced
    image's entrypoint is the lifecycle's web process and it records CNB_LAYERS_DIR=/layers.
  • The refusal: running the same recipe in an image with no lifecycle prints the sentence and
    exits 1.

Still true, and out of scope here

The buildpacks path still cannot push to the plain-HTTP in-cluster registry and still refuses when
the target is one (ADR-0054 §5). The lifecycle does support it — -insecure-registry /
CNB_INSECURE_REGISTRIES — so lifting that limit is now a small change, but it is a decision with
its own record rather than a rider on this one.

A build of a repository with no Dockerfile invoked /cnb/lifecycle/creator inside a
builder image that carried no lifecycle, so the entire explanation a 27-second failed
build gave was `sh: line 30: /cnb/lifecycle/creator: No such file or directory`. The
image's own header said a missing lifecycle would fail fast with a missing-lifecycle
error; there was no such error, only the shell's.

Rebase the builder image onto heroku/builder:26, a published Cloud Native Buildpacks
builder for Ubuntu 26.04, and install buildah into it rather than copying a lifecycle
into the previous buildah base. A buildpacks build runs the buildpacks inside the
builder image's own root filesystem and exports the result onto the run image that
builder declares: the two are a published, tested pair, and splicing /cnb into an
unrelated base unpairs them, so anything a buildpack compiles rather than downloads
links against one distribution's C library and then has to load on another's. The
buildpacks side therefore chooses the base, and buildah, which has no such pairing,
is installed on top.

Heroku's builder over Paketo's on size (~473 MB compressed against ~780 MB for
ubuntu-resolute-builder and ~1.5 GB for ubuntu-noble-builder, on an image pulled per
build), on UID (1000, already the UID the Job pins, where Paketo's current builders
are 1001), and on coverage (.NET, Go, Java, Node.js, PHP, Python, Ruby and Scala,
where Paketo's small builders carry only Go and Java). Ubuntu 26.04 carries buildah
1.42, the same series the previous base carried, so the Dockerfile path is not moved
onto an older builder; it does need the helpers that base bundled and a distribution
package does not pull in — uidmap, and netavark/aardvark-dns/slirp4netns for the
container network buildah configures for each RUN step, without which it refuses
before the first instruction.

Test for the lifecycle before running it, whatever image is wired. The builder image
bundles one, so this is not the expected path, but the branch invokes an absolute path
inside an image the install can override, and the shell's answer for a missing file is
not one a user can act on. The refusal names the cause and the ways out, in the voice
the insecure-registry refusal beside it already uses.

Mount the layers directory the lifecycle writes into — 208 MB for a trivial Node
application, and previously not mounted at all — as a writable emptyDir, for both
branches, because the branch is chosen inside the pod from the cloned tree. Point
DOCKER_CONFIG at the already-mounted registry auth: the lifecycle does not read
REGISTRY_AUTH_FILE but resolves credentials through the docker keychain, so without it
a no-Dockerfile build would authenticate as nobody. buildah prefers REGISTRY_AUTH_FILE,
so the Dockerfile path is unchanged.

The buildpacks path still cannot push to the plain-HTTP in-cluster registry and still
refuses when the target is one (ADR-0054 §5).

Signed-off-by: Nicholas Phillips <nsphilli@gmail.com>
@incognick incognick added the pr-ready A PR is open and ready for review label Aug 14, 2026
@incognick
incognick merged commit 0ce8f78 into main Aug 14, 2026
6 checks passed
@incognick
incognick deleted the feat/buildpacks-lifecycle branch August 14, 2026 16:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr-ready A PR is open and ready for review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A build with no Dockerfile fails with a shell error, not a reason

1 participant