PR #81's first images run failed in 6s: act_runner 0.2.11 forces docker_host '-' for a tcp:// DOCKER_HOST, and gitea/act drops every bind when valid_volumes is empty. Giving jobs the privileged DinD daemon is node-root for any workflow author, so it is left to the operator; options are in the handoff entry. The driver's failure now names the cause. Co-Authored-By: Claude Opus 5.5 <[email protected]>
18 KiB
date, branch, title
| date | branch | title |
|---|---|---|
| 2026-10-29 | feat/image-pipeline | Images are built, verified and published by CI; Renovate carries the digests into git |
What changed
Operator rule (2026-10-29): container image creation, hosting and lifecycle belong to CI/CD and
the codebase, never to a one-off workstation build/push. Before this branch the Home-Network org's
package list was empty, platform/pm-agent/ pinned an all-zero digest (ImagePullBackOff), and
both build-image.sh scripts said "publishing is an operator action".
-
.gitea/workflows/images.yaml+.gitea/workflows/scripts/images.sh(the logic).- Pull request: build and verify only. No push, and the step references no secret at all.
- Push to
main, orworkflow_dispatchonmain: build, verify, then push togitea.home.keithjackson.dev/home-network/{toolbox,agent-runner,conformance-runner}. Tags aresha-<12 hex>(immutable: the driver refuses to overwrite one) andmain(moving). The digest is read back from the registry with a HEAD on the manifest, compared with what the client printed, and written to the job summary. - Only images whose inputs changed are built. A re-run on the same commit skips images that are already published.
- agent-runner builds FROM the toolbox built in the same run (on main, FROM its just-pushed
digest). Otherwise:
- in verify mode the toolbox is built anyway as its base, because a PR holds no credential;
- in publish mode it uses
AGENT_BASE_IMAGEfrominfra/agent/versions.env; - if that is still the zero placeholder, the toolbox is built and published first. This is how the first run on main bootstraps.
- An always-run step prunes the DinD image store.
-
One build recipe per image, reused by CI.
- New
infra/devcontainer/build-image.sh.dev.sh buildnow delegates to it. It has an in-image verification step that the toolbox never had: every tool is on PATH, and promtool, kubectl, chainsaw, kyverno and sops report their pins. infra/agent/build-image.shandconformance/incluster/build-image.shwere made engine-neutral, because CI runsENGINE=dockeragainst DinD:podman image existsbecameimage inspect, with a pull fallback;{{.Digest}}and--digestfileare gone. No local digest is printed, since only the registry's digest is meaningful;probe-validation.shis fed over stdin instead of a bind mount. Under DinD a bind source is resolved on the daemon's filesystem, which would silently mount an empty directory;probe-validation.shitself gained${BASH_SOURCE[0]:-$0}so it survivesbash -sunderset -u.
AGENT_BASE_IMAGEandIMAGE_TAGcan be overridden from the environment, andBUILD_REVISIONadds anorg.opencontainers.image.revisionlabel.- The "publishing is an operator action" text was replaced throughout.
- New
-
Digest references. Each now carries a
# renovate-digest: image=<repo> tag=mainmarker on the line above it:platform/pm-agent/deployment.yamlandconfigmap.yaml(PM_WORKER_IMAGE), which share one dependency, so they bump in one PR;infra/agent/versions.envAGENT_BASE_IMAGE, which washome-k3s-dev:localand is now the published toolbox by digest (zero placeholder until the first publish);conformance/versions.envCONFORMANCE_RUNNER_IMAGE_DIGEST, which went from""to the zero placeholder because Renovate can only replace a digest it can match.
The admission policies are untouched; the
<repo>@sha256:<digest>form is preserved. -
renovate.json5:- a new
custom.regexmanager for the markers; enabledManagersregex→custom.regex;- a packageRule that disables the
kubernetesmanager for these three repos. Otherwise it would also see the digest-only lines and look them up aslatest; - an
own-imagelabel; ignorePathsnarrowed fromconformance/**to its subdirectories.ignorePathsapplies to custom managers too, so the blanket glob hidconformance/versions.env.
- a new
-
renovate.yaml:renovate-version: 44.167.0pinned, plus aRENOVATE_HOST_RULESdocker rule for the Gitea registry, using the sameRENOVATE_TOKEN, which now also needsread:package. See below for why the version pin is a real fix and not tidiness. -
infra/gitea/registry-retention.sh: dry run by default,--applyto delete. This is an operator action. Per image it keeps:mainandlatest;- the 10 newest
sha-tags; - every tag whose digest is pinned anywhere in
origin/main; - every digest-named version.
-
infra/gitea/conformance-trigger-kubeconfig.sh: the "runner image is digest-pinned" precondition grepped the literal lineCONFORMANCE_RUNNER_IMAGE="${…_REPO}@${…_DIGEST}"for 64 hex digits, so it could never pass. It now sources the file, evaluates the value, and rejects the zero placeholder. -
conformance/tests/l0-static/alert-rules-unit-tested/check.sh: its "the toolbox build passes--build-arg PROMTOOL_VERSION" assertion now readsinfra/devcontainer/build-image.shinstead ofdev.sh. The gate caught this on the first run, because the recipe had moved. -
Docs:
platform/pm-agent/README.md,runbooks/pm-agent.md(two rows),docs/plans/pm-agent-issue-to-pr.md(follow-up 1),conformance/incluster/README.md,infra/devcontainer/README.md, and a new "How images are built" section in.gitea/workflows/README.md.
BLOCKER — the runner cannot build images, and fixing that is a security decision
Measured on this PR's own CI run (images run 325): the job failed in 6 s with
failed to connect to the docker API at unix:///var/run/docker.sock ... no such file. The job
container is given no Docker daemon at all, for two independent reasons, both read from the
exact versions deployed:
- act_runner v0.2.11
internal/app/cmd/daemon.go: whenDOCKER_HOSThas a scheme other thanunix/npipe— heretcp://localhost:2376, the DinD sidecar — it setscfg.Container.DockerHost = "-", which means "never mount into job containers". - gitea/act v0.261.2 (act_runner's
go.modreplace)pkg/container/docker_run.gosanitizeConfig: withvalid_volumesempty (the runner has no config file, so the default[]),hostConfig.Binds = []string{}. Every bind is dropped, the daemon socket included, and so is anyvolumes:a workflow asks for.
I assumed the opposite while designing (from act's getDockerDaemonSocketMountPath, which never
gets the chance to run). That was wrong, and the PR run is what showed it.
Why I did not fix it in this PR. Any fix gives workflow code the DinD daemon. That daemon is
privileged: true, so docker run --privileged -v /:/host from a job is root on the node.
Today a malicious workflow — for example one pushed by a prompt-injected pm-agent worker, which
holds a repo write token — runs in an unprivileged container and holds no cluster credential.
After the fix it does not. That is a change to the security boundary, and it is the operator's
call. It also cannot be tested from a PR anyway: the runner only changes after merge + Flux.
Options, for the operator:
-
Socket to every job (smallest change): in
apps/wave-1/gitea-actions.yaml,- mount a shared
emptyDirat/var/runin both thedindsidecar and therunner, so dockerd's ownunix:///var/run/docker.sockis visible at the same path in both; - set the runner's
DOCKER_HOST=unix:///var/run/docker.sock, and dropDOCKER_TLS_VERIFYandDOCKER_CERT_PATH, because TLS over a unix socket breaks the client; - add an act_runner config file (
CONFIG_FILE) withcontainer.valid_volumes: ["/var/run/docker.sock"]andrunner.file: /data/.runner, so the existing registration is kept.
Consequence: any workflow on any branch can reach node root.
- mount a shared
-
Option 1 on a SECOND runner with its own label (e.g.
image-builder) and its own DinD, withimages.yamlruns-onthat label. The gate runner stays socket-less, and image builds stop competing with the gate for the single slot and its 7 GB node disk. It is not a security boundary: Gitea cannot restrict which workflow may target a label, so any branch could stillruns-on: image-builder. -
A builder that is not a privileged daemon, e.g. a rootless BuildKit sidecar exposed to jobs. That bounds a job to BuildKit's rootless sandbox instead of node root. It needs the verification steps rewritten, because they run the image (as uid 65532,
--read-only), which BuildKit alone does not do. This is a larger piece of work.
Nothing in this PR has to change for options 1 or 2 except runs-on for option 2. The driver
already produced verified images with a stock docker 27 CLI (see What I verified).
Decisions and pushback
-
The push credential is a PAT secret,
REGISTRY_PUSH_TOKEN; the Actions token cannot push. Checked against Gitea v1.24.3 source:services/auth/basic.goBasic.Verifyturns a task token into the pseudo-usergitea-actions(ID −2) and sets no token scope;routers/api/packages/container/container.goAuthenticate(the/v2/tokenexchange every docker client performs) requiresread:package/write:package/allscope, so it answers 401. The Actions token cannot even pull;services/context/package.godetermineAccessModehas// TODO: ActionUser permission checkand no branch for that user. Even past login it would get at most read on a public org;models/repo/repo_unit.goActionsConfighas no token-permissions setting in 1.24.3.
The workflow fails loudly when the secret is absent, copying the
RENOVATE_TOKENpreflight pattern. -
regexvscustom.regex: both work. Switched to the canonicalcustom.regex. In Renovate:lib/config/migrations/custom/enabled-managers-migration.tsrewritesregex→custom.regex;- that migration runs on every repository run (
lib/workers/repository/init/merge.ts→migrateAndValidate), not only in the validator; lib/modules/manager/index.tsgetEnabledManagersListaccepts either spelling;custom.regexexists since 37.51.1 (renovatebot/renovate#24079).
-
The open question hid a bigger bug: the Renovate config on main is invalid for the Renovate the workflow actually runs.
renovatebot/[email protected]defaultsrenovate-versionto39(action.yml,src/docker.ts), which is 39.264.0.- The validator used in #79 was
ghcr.io/renovatebot/renovatewith no tag, i.e.latest, 44.167.0. - Measured:
renovate:39.264.0 renovate-config-validatoron main'srenovate.json5givesERROR: Found errors in configurationwith five errors, allmanagerFilePatterns.fileMatchwas renamed in 40.2.0. - So once #80 fixes the filename, every run would still die at
CONFIG_VALIDATION. - Fixed by pinning
renovate-version: 44.167.0. This branch's config validates clean on it. The only--strictcomplaint is a pending migration of the pre-existing top-level"docker": {"enabled": true}block, which I left alone.
-
Unworkable as briefed: tracking the worker Job template's digest.
infra/agent/worker-job-template.yamlisCOPYed into agent-runner. A Renovate bump there changes agent-runner's inputs, CI publishes a new digest, Renovate bumps it again, and so on forever.pm-dispatchoverwrites that field fromPM_WORKER_IMAGEat runtime (pm-dispatch.py:649), so nothing is lost by leaving it all-zero. It is commented as deliberately untracked.- For the same loop reason,
conformance/versions.envis not an input of the conformance-runner build, and a pipeline-only change does not republish on main.
-
Changed from the brief: retention is a script, not a Gitea cleanup rule.
- Gitea 1.24.3 has no API for cleanup rules (none in
routers/api/v1/api.go; web session+CSRF forms only, inrouters/web/web.go). So a rule cannot be created, or drift-asserted, as code.ci/gitea-settings.shwas therefore not extended. A cheaper proxy (count versions per image) would needread:packageonSETTINGS_API_TOKEN, which it lacks. - A built-in rule is also unsafe here. Gitea stores a manifest as a file of the TAG
version it was pushed under (
routers/api/packages/container/manifest.gocreatePackageAndVersion), and resolves a by-digest pull from those files (models/packages/container/search.go). Deleting the tag a deployed digest was pushed under breaks the@sha256:pull, and the rule's "newest N" knows nothing about what git pins. The script keeps every digest git pins.
- Gitea 1.24.3 has no API for cleanup rules (none in
-
Auto-merge. Digest bumps of these images auto-merge on a green gate (the existing
digestrule). The exception is the conformance-runner bump:conformance/versions.envis a sensitive path, so it waits for an administrator's/approve-overrideevery time.
What I verified
- Local gate, pinned toolbox, from this worktree, committed HEAD. Command:
ENGINE=<shim> DEV_KUBECONFIG=/dev/null ./infra/devcontainer/dev.sh run bash -c './ci/gate.sh'. The shim adds the.gitmount. Result: see the PR body for the rc and stage list. They are recorded there rather than here because this entry is part of the tree being gated. - Local podman builds through the CI driver, run as
MODE=verify ENGINE=podman BASE_SHA=$(git merge-base origin/main HEAD) .gitea/workflows/scripts/images.sh. It returned rc 0 and built and verified all three images:- toolbox: every tool on PATH, and the five pins matched;
- agent-runner:
PM_SELFTEST=1 pm-dispatchOK, and the stdin-fedprobe-validation.shgave "20 refusal(s), 0 failure(s)"; - conformance-runner: all seven
conformance-clonerefusals.
- The docker-CLI code path, i.e. what CI runs. I ran the same driver inside
docker:27-cli(ENGINE=docker,DOCKER_BUILDKIT=0) against podman's Docker-compatible API socket. Exit code 0; all three images built and verified (selftest OK; probe OK, 20 refusals). This covers the docker-specific calls (image inspectformats,run -iwith stdin,--tmpfs,--read-only,--user). It does not cover BuildKit or a real DinD daemon. - This PR's CI, run 325 (
images): FAILED in 6 s, because there is no Docker daemon in the job container (see BLOCKER). Gate run 324 failed only onapproval-policy(expected, sensitive paths). Every other stage passed there, includinggitea-settings. - Other driver paths, run locally:
- agent-runner alone builds the toolbox as its base;
- an empty diff builds nothing;
- publish mode without a token dies before doing anything;
infra/agent/build-image.shwith the zero placeholder dies with build-locally instructions.
- Renovate, with the pinned 44.167.0 image, locally:
renovate-config-validatorexits 0;- a
--platform=localextractdry run finds all four marker references (custom regex manager,currentValue: main,currentDigest: sha256:000…); - a
lookupdry run shows thekubernetes-manager duplicates asskipReason: disabledand the marker deps taking the "unsupported/unversioned value main → getDigest(main)" path. That lookup currently 404s because nothing is published yet.
- Renovate 39.264.0 rejects main's current
renovate.json5. Validator output reproduced above. registry-retention.shdry run against the live Gitea (read-only): zero versions per image, nothing to delete. The token exchange and the package listing both answered.- Live, read-only measurements (kubectl and the Gitea API):
- the runner pod is on k3s-prod-1, which had 7 GB nodefs available;
- DinD holds only the 1.54 GB act image; buildx is v0.20.1; the daemon is 27.5.1 on overlay2 with cgroup v2;
- Gitea
/datais 9.7G, 1% used; - the Gateway's Envoy (Cilium) has
streamIdleTimeout: 300s,maxStreamDuration: 0s, and no routetimeouton the gitea route; - Gitea's
LIMIT_SIZE_CONTAINERis unset (default −1 = unlimited,modules/setting/packages.go).
What I did NOT verify
-
Nothing has been pushed to the registry, by me or by CI. The publish path has never run:
- the registry HEAD/digest read-back;
- the immutable-tag guard;
- "already published at this commit" idempotence;
- agent-runner
FROM <toolbox>@sha256:; docker loginwith a PAT.
The first merge to main is the first test, and it needs
REGISTRY_PUSH_TOKENto exist first. -
Large-layer push through the Gateway is UNVERIFIED. Envoy has no request-body cap and no stream duration cap here. But with no route
timeoutset, Envoy's default 15 s upstream timeout applies from the end of the request to the end of the response. That covers Gitea finalising a blob: copying the upload into package storage on Longhorn. A ~250 MB compressed layer should finish well inside 15 s, but I could not measure it without pushing. If the first publish shows 504s on blob PUTs, settimeouts.requeston the gitea HTTPRoute. -
DinD fit is estimated, not measured (no build has run there, see the BLOCKER):
- memory: 2Gi is shared by the job container and every build container;
- disk: the toolbox, agent-runner, conformance-runner, Fedora base and build cache should
total roughly 3 GB on top of the 1.5 GB act image, against 7 GB free on the node, with
dind-storagehaving no sizeLimit.
-
No image has been built in this runner's DinD, because it cannot be done until the BLOCKER is resolved. Two things are therefore unmeasured until then:
- DinD memory (2Gi) and disk fit;
- BuildKit (
buildxv0.20.1 is on the daemon) as opposed to the legacy builder I tested with.
-
Renovate has still never completed a run here. Three things block it:
- #80, the
configurationFilename; RENOVATE_TOKENgainingread:package;- this branch's version pin.
renovatebot/[email protected]running a 44.x image is untested. The action only passes the tag todocker run. - #80, the
-
Pods cannot pull these images yet regardless. The kubelet pull credential is PR #61 (
pm-agent-registrydockerconfigjson,read:package), still open. Until it merges, a correct digest still ends in ImagePullBackOff (401), as before. -
registry-retention.sh --applyhas never run, and its keep logic has only run against an empty registry. -
git diffchange detection on Giteapushevents relies ongithub.event.before. A force-push or a first push falls back to building everything, which is intended but not observed.