Give the Dockerfile-building step a builder image, and make §9n true again #118
Labels
No labels
blocked
bug
duplicate
effort
high
effort
low
effort
medium
effort
minimal
enhancement
help wanted
invalid
pr
feedback
pr
reviewing
question
status
backlog
status
document
status
done
status
implement
status
pending
status
planning
status
reviewing
waiting
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
tepichord/milner#118
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Overview
#107 gave this cluster the ability to build a Dockerfile in a step: a
hostUsers: falsepod,machine.sysctls.user.max_user_namespaces = 11255, aseccompProfile: Unconfined, and a suite that names the one step allowed to ask.The capability is real. annia-infra #310's first pipeline built its image in that
pod and is green (pipelines #10 and #11 in that repo), and no step anywhere is
privileged — #107's stated fallback, one named
privileged: truestep, was neverneeded and stays unused.
What is not real is the shape #107 published. Its proof item 2 measured
podman build --storage-driver=vfsonquay.io/podman/stable:v5.8.7-immutable—a distro podman, on a distro filesystem, with setuid helpers,
/etc/subuid,/etc/containers, fuse, and a login session, building a Dockerfile that neverneeded a foreign
chown. The consumer's command is a different toolchain:nixpkgs' podman inside
nix develop, over the sharednix-storePVC, building aDockerfile whose
RUNisnix build. The PoC passed for that toolchain and notfor this one, which is why the shape in §9n —
BUILDAH_ISOLATION: chrootandSTORAGE_DRIVER: vfsand nothing else — does not work, and why the consumer'sstep has four measures around it instead.
This issue does what #107's own Pre-Mortem 1 pointed at: move the measures into
something milner owns and tests, make the documentation say what was measured,
and write down which image path needs which measures so the next consumer is not
re-deriving them from a doc that is wrong.
What is measured today
The consumer's step reaches green with four measures and one landmine. Each is a
fact about the toolchain; only one is a kernel rule.
unshare --map-users=0,0,$IDS --map-groups=0,0,$IDS --mount --propagation private+podman build --userns=hostuid_mapreads0 0 1in every shape, plain and--userns=host).chownto any uid but 0 then failsEINVAL, which is whatnix profile installandnix builddo (error: changing ownership of "/nix/store": Invalid argument). A wider map through podman's own flags is refused (write to \gid_map`: Operation not permitted). With the wide map,chown` succeeds for 0, 1, 1000, 30000 and 65534, and the whole Dockerfile builds-v /proc:/procmount \proc` to `proc`: Operation not permitted`, in every shape tested). Binding the pod's own procfs works: the build then sees the same procfs superblock, and buildah leaves a caller-supplied mount alonemkdir -p /run/user/0/libpod/tmp$XDG_RUNTIME_DIR; a pod has no login session to make/run/user/0. Without the directory every podman call outside the namespace dies (unable to create a new pause process: … pause.pid: No such file or directory). Inside the namespace it is not needed — podman there already holds the id range it would spawn a pause process to getSTORAGE_DRIVER: vfs/dev/fuseBUILDAH_ISOLATION: chroot— in #107's YAML and in §9nerror setting supplemental groups list: operation not permitted(containers/buildah#6947 — fixed on buildah main, in neither v1.43.2 nor v1.43.4)Two things that table is not. It is not a criticism of
hostUsers: false, thesysctl, PSA or the allowlist — those are right, necessary, and unchanged here.
And it is not "podman in a pod needs workarounds": a busybox-and-echo Dockerfile
builds in that pod under the single-id map with no
unshareat all. What paysthese costs is the specific combination —
nix buildinsidepodman buildinside a user-namespaced pod — because nix's build sandbox needs ids the
single-id map cannot name.
1. A builder image, not a recipe in every consumer's YAML
The measures are the same for every consumer, and they are pod facts rather than
repository facts, yet today each consumer re-derives them from a doc. Move them
into an image milner owns, publishes, and tests — in the shape §15a already
gives images here: a
dockerTools.buildLayeredImagederivation, no base image,and nothing mounted at runtime for the tool to work.
What it carries
mount) at the dev shell's pins, so local and CI remain one tool.a Dockerfile whose
RUNisnix profile install/nix buildworks insidethe image without fetching nix itself, and against the shared store when the
consumer mounts it. This is what makes the image a builder rather than a
podman wrapper, and it is why the entrypoint below is load-bearing for nix and
not only for podman: nix's sandbox chowns to build users, which needs a map
wider than one id (measured, table above).
/etc/containers/policy.jsonand/etc/containers/registries.conf— podmanrefuses to build without the former (
no policy.json file found at any of the above paths)./etc/subuidand/etc/subgid, and a writable runtime dir (the entrypoint mayequally make both at start).
What its entrypoint does (the measured sequence, in one command, so no
consumer writes it again)
grep -oE '[0-9]+$' /proc/self/uid_map | tail -1; noawk, the step images have none;namespace (the pushes do);
unshare --map-users=0,0,$IDS --map-groups=0,0,$IDS --mount --propagation private;podman build --userns=host -v /proc:/proc --storage-driver=vfs …;--digestfile, take the credential on stdin — never inargv (AGENTS.md) — and compare the registry's served manifest digest against
the pushed one, the check §15d already runs over a derivation's push.
Behaviour worth fixing in the interface: a context, a tag, a registry
destination, a token on stdin; nothing else required.
What a consumer's step then looks like
The namespace, the namespace-and-proc recipe, the storage driver and the digest
check stop being a per-repository transcription, and the allowlist entry keeps
its meaning: one named step, in a namespace of its own.
How the builder itself is built: by this repo's workaround-free path —
nix build+skopeo, the same shape asoz/router— so there is nobootstrapping paradox: the builder image needs none of the measures it provides.
That is worth stating in §9n, because it is the cleanest evidence that the
measures are podman's and not milner's.
2.
docs/woodpecker-ci.md§9n made true againBUILDAH_ISOLATION: chroot(buildah#6947 at the pin) and keepSTORAGE_DRIVER: vfswith its reason.hostUsers: false,seccompProfile: Unconfined, the runtime dir, the wideunshare,-v /proc:/proc,--userns=host, and the fetch of the map width from the pod.does (a repo that cannot use the image should still be able to read what
happens).
pipeline", and the real invariant is at most one namespaced step per run,
which is now two entries on disjoint events —
build-imageonpull_request,publishonpush(annia-infra's suite already asserts disjointness ratherthan a count; milner's own allowlist stays empty). Say it that way in §9n, and
in
.woodpecker/gates.yaml's "one named step per pipeline" prose.unshare -U truesucceeding asevidence that a namespace is enough: a namespace that exists is not a namespace
wide enough to
chownin. That sentence is the one that misled this work.3. Which path milner recommends, written down
docs/nix.md§15 and §9n should say plainly that a derivation(
dockerTools.buildLayeredImage+skopeo) needs none of these measures —no user namespace, no procfs mount, no vfs, no pause process — and that a
Dockerfile-building step pays all of them. A repository that already has a flake
should prefer the derivation; a Dockerfile is for an app whose build genuinely is
one. annia-infra is the case in point: #310's own table removed
packages.annia-imagein favour of the Dockerfile, and this issue is what makesthat removal cheap to revisit.
If story 20b needs rewording to stay true, reword it here — in the change that
contradicts it (AGENTS.md). Its "exactly one step per pipeline" half is still
true as written (the two namespaced steps never meet in one run); its "the same
command I run locally" half is the claim this work qualifies, and after item 1
it is true in the sense the story means: one command, with the pod-specific parts
in the tool.
4. Two experiments that could remove a measure each
Both are one lab pod in
woodpecker-runtime, and the outcome — positive ornegative — goes into §9n as a measurement.
overlayinstead ofvfs. Unprivilegedoverlayfs is supported on this kernel (6.x); what blocks it today is that the
pod's root is itself an overlay. If it works, the re-pull cost of
vfsgoesaway; this is the follow-up §9n already names, and it should be measured
rather than assumed.
unshare --pid --forkin the entrypoint. A pid namespace created insidethe user namespace is owned by it, which is the precondition for mounting
procfs — so this is the one candidate that could remove the measure that is a
kernel rule, making
-v /proc:/procunnecessary. Worth one pod.unshare, does the build still need--userns=host, or does podman's own nested map now inherit the width?5. What must not change
privileged: true, in milner or in any consumer. The agent'scapabilitiesfield is Drop-only in v3.18.0, so a builder cannot even ask formore; #107's fallback stays unused.
policy/k8s.regounchanged, no exemption, andhostUsers: trueis never the builder (that is node root).user.max_user_namespaces = 11255isalready raised and sufficient (measured).
milner/**anonymous-pushexception — that is #110's and its own follow-up.
story is right and this table wins nothing.
The pause this issue puts on annia-infra
annia-infra #310/#318 is paused until this lands: #318 stays unmerged, and
that repo's
.woodpecker/ci.yamlis not touched while this is in flight. Whenthis is done, annia-infra's step becomes the builder image in its own change, and
its merge runs
publish— which is where theZOT_TOKENsecret-extension proof(#110) happens. Nothing about that proof is blocked by this issue; it is simply
not run twice.
Acceptance Criteria
dockerTools.buildLayeredImage),is built and pushed by this repo's pipeline with
nix build+skopeo,and is pinned where a consumer step pins it
configs, subuid/subgid, and an entrypoint whose required arguments are a
context, a tag and a destination (token on stdin)
RUNisnix buildbuilds inside it, in ahostUsers: falsepod, against the shared store, from a step that runs nounshareof its own — the recipe lives in the entrypointdocs/woodpecker-ci.md§9n carries noBUILDAH_ISOLATION: chroot, carriesthe measured shape with a reason per line, and states the allowlist
invariant as at most one namespaced step per run, two disjoint events
unshare -U truesucceedingas evidence that a namespace is enough
docs/nix.md§15 and §9n say which path needs which measures, and story20b is either confirmed true as written or reworded in this change
.woodpecker/gates.yaml's "one named step per pipeline" prose matches theper-run invariant
is removed from the doc and the entrypoint
python3 -m pytest tests/green,NAMESPACED_ALLOWLISTstill empty in thisrepo's suite, and
conftest test --policy policy/unchanged over the layersafter this lands
Testing Plan
python3 -m pytest tests/ -v— includingtest_static.py's allowlistinvariant and
test_docs.py's story guardsconftest test --policy policy/over the rendered layers with the new imagepinned: rule 3 reads only
privileged, so nothing should change — that is theassertion
woodpecker-runtimewith noresources(so the namespaceLimitRangeapplies), running the entrypoint against a busybox context andagainst a
nix buildcontext, then deleted — the same harness this work used,and the reason the LimitRange matters: a step pod requests 5Gi
lifts
Related
publishhalf consumeserror setting supplemental groups list, chrootisolation), #6910 (overlay over the build context)
.woodpecker/gates.yaml's builder comment blockNaina:
Overview
#107 gave the cluster a real unprivileged Dockerfile-building capability, but §9n publishes a shape measured against a distro podman on a distro filesystem, not the consumer's nixpkgs-podman-inside-
nix developtoolchain. This plan moves the four working measures into a builder image this repo owns and builds as adockerTools.buildLayeredImageflake output pushed withnix build+skopeo, then rewrites §9n,docs/nix.md§15 and.woodpecker/gates.yaml's builder block to say what was actually measured.Technical Approach
Put the whole recipe in one on-PATH script. The Woodpecker agent overrides a step image's entrypoint and runs
commandsas/bin/sh -c, soconfig.Cmdis never reached by a consumer step; the recipe must be an executable.1Add the image and its check to the derivation family.
podmanBuilderCheckfollowsreaperCheck(docs/nix.md §15c)10: extract the image's own layers and assert/bin/build-and-push,/bin/podman,/bin/nix,/etc/containers/policy.json,/etc/subuidresolve inside, that the nix isnixVersions.nix_2_35(not the pin's defaultnix2.34.8)5 —nix evalon this pin givesnix= 2.34.8 andnixVersions.nix_2_35= 2.35.2, and thatconfig.Cmdreads/bin/build-and-pushfrom the image's own config bytes.Expose the tag and image in
packagesso the step never retypes the pin.Reword the builder comment block in
.woodpecker/gates.yaml(~line 434):one named step per pipeline or none→ at most one namespaced step per run.Add a
build-podman-builderstep that copiesbuild-ozexactly (nixos/nix:2.35.2,volumes: [nix-store:/nix], thennix build→ check →./#skopeo copy --digestfile→ curl+sha256sum served-digest compare).7 It never runs the builder image, sotest_no_step_in_the_pipeline_is_privileged's image-name guard stays satisfied; the store mount is what realises the builder's closure into the store a consumer later mounts.2Rewrite
docs/woodpecker-ci.md§9n: builder image first (consumer YAML, entrypoint arguments, token on stdin), raw recipe beneath as "what the image does"; delete onlyBUILDAH_ISOLATION: chrootand keepSTORAGE_DRIVER: vfswith its reason (the allowlist test requires bothhostUsers: falseandSTORAGE_DRIVER: vfssubstrings3); restate the invariant as at most one namespaced step per run, with the two disjoint events (build-imageonpull_request,publishonpush); replace theunshare -U truesentence with the width argument.State which path needs which measures in
docs/nix.md§15 and §9n: a derivation (buildLayeredImage+skopeo) needs none of the namespace/procfs/vfs/pause-process measures; a Dockerfile step pays all of them.Run experiments 1–3 in one lab pod in
woodpecker-runtime(noresources, so the LimitRange applies; delete after) and record the outcome in §9n either way: overlay-on-a-PVC instead ofvfs;unshare --pid --fork --mount-procto drop the-v /proc:/procbind; and whether--userns=hostis still needed inside the wide map. Any measure removed comes out of the doc and the entrypoint in the same change.Implementation Details
flake.nixbuildAndPush,podmanBuilderTag,podmanBuilderImage,podmanBuilderCheck; packages entries. Usepkgs.nixVersions.nix_2_35, neverpkgs.nix..woodpecker/gates.yamlbuild-podman-builderstep. Watch${...}substitution: only Woodpecker vars or:-defaults.9docs/woodpecker-ci.mddocs/nix.mddocs/storage.mdbuild-podman-builder.USER_STORIES.mdtests/test_static.pybuild-podman-builderto thewriterslists (lines ~1127, ~4712)8a 8b; extendtest_a_user_namespaced_step_is_named_on_the_allowlistto assert §9n has noBUILDAH_ISOLATION, keepshostUsers: false+STORAGE_DRIVER: vfs, and says per-run.Constraints / out-of-scope notes
policy/k8s.rego, zot credentials or themilner/**anonymous-push exception (#110),worker/main.tf's sysctl, or annia-infra's.woodpecker/ci.yaml(paused).containers/storage#6968in the context does not resolve on GitHub — dropped from the plan; Experiment 1 cites the kerneluserxattr/NFS docs instead.annia-infra, its own paused change); milner has no in-tree manifest to guard, so no newci/*-tag-guard.sh.NAMESPACED_ALLOWLISTstays{}in this repo: milner builds the builder but never runs it.4Acceptance Criteria
.#podmanBuilderImageis adockerTools.buildLayeredImageoutput, built/checked/pushed bybuild-podman-builderwithnix build+skopeo, at a tag derived from the flake pin.policy.json/registries.conf, subuid/subgid, andbuild-and-push <context> <tag> <registry>with the token on stdin.RUNisnix buildbuilds inside it, in ahostUsers: falsepod, against the shared store, with nounsharein the step.BUILDAH_ISOLATION: chroot, keepsSTORAGE_DRIVER: vfswith its reason, states per-run/at-most-one, and drops theunshare -U trueevidence.docs/nix.md§15 and §9n say which path needs which measures; story 20b is true as written or reworded here..woodpecker/gates.yaml's comment block matches the per-run invariant.python3 -m pytest tests/green,NAMESPACED_ALLOWLISTstill empty,conftest test --policy policy/unchanged.Testing Plan
python3 -m pytest tests/ -v(incl. the allowlist andtest_docs.pystory guards);python3 -m pytest tests/ -k step_commandsbefore editing.woodpecker/.nix build .#podmanBuilderCheck && cat result—/bin/*, config files andCmdresolve inside the image's own layers.conftest test --policy policy/over rendered layers: rule 3 reads onlyprivileged, so no change is the assertion.woodpecker-runtime(noresources) runningbuild-and-pushagainst a busybox context and anix buildcontext, plus Experiments 1–3, then deleted.Related Issues
publishhalf consumes.setgroups), #6910 (overlay over the build context).Research Findings
buildah#6947is fixed by v1.45.0, but the flake pin'spodman 5.8.4vendors buildah v1.43.2, soBUILDAH_ISOLATION: chrootstill fails at the pin;pkgs.buildahbeing 1.45.0 does not helppodman build.newuidmap/newgidmap; the entrypoint'sunshare --map-usersis load-bearing, and the pin's util-linux 2.42.2 supports it (root writes maps directly since v2.40).commandsvia/bin/sh -c, sobuild-and-pushmust be an executable onPATH, notconfig.Cmd.dockerTools.buildLayeredImagemergescontentsbin/into/binand runsextraCommandsunprivileged, which is the right place for/etc/containers/*and subuid/subgid.6userxattroption and a non-NFS upper dir; milner's local-path PVCs meet the filesystem precondition (Experiment 1 is the measurement).pkgs.nixis 2.34.8 while the seeded store is nix 2.35.2, so the image must spellpkgs.nixVersions.nix_2_35or it reads a newer store schema it cannot parse.milner/**andpreview/**are anonymous-push today, so the builder's own push needs no token, but the interface still takes one on stdin for consumerannia/**destinations.Pre-Mortem
woodpecker-runtimepod and their outcomes recorded in §9n, but the coding agent has no cluster credentials or kubectl access, so the step either stalls or ships unverified prose. Because the suite only checks image contents and doc text, nothing catches the gap.test_a_user_namespaced_step_is_named_on_the_allowlistrequires §9n to keephostUsers: falseandSTORAGE_DRIVER: vfs,test_no_step_in_the_pipeline_is_privilegedrejects any step image namedpodman, andtest_docs.pycaps story 20b at 90 words, so a rewrite that drops or rephrases one of these fails the build. The Woodpecker${...}substitution rule forgates.yamlcomments is a second way the same edit breaks a test.pkgs.nix2.34.8 slips in instead ofpkgs.nixVersions.nix_2_35, orbuild-podman-builderfails to realise the full closure into thenix-storePVC, a consumer mounting the store over/nixeither hits a schema-version error or loses the image's tools. This surfaces only in the paused downstream consumer (annia-infra #310), long after the change looks green here.