HOMELAB-1665: feat(agent-sandbox): pi sandbox image → Harbor #519
No reviewers
Labels
No labels
operator-review
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Homelab/infra-core!519
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "plane/HOMELAB-1665-pi-image"
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?
What
The pi sandbox image the Lab Director fleet's agents run in, built and published by Forgejo CI. Ticket: HOMELAB-1665 (LD0 epic / LD0.2).
New files only:
agent-sandbox/pi-image/DockerfileENTRYPOINT ["pi"]agent-sandbox/pi-image/README.mdagent-sandbox/pi-image/examples/pi-pod.example.yaml.forgejo/workflows/pi-image-build.yamlWhy
LD0.2's deliverable is a reproducible pi image in the registry the cluster can pull from, so LD0.3 can stand up a real Sandbox without a node-local image. Builds have to happen in CI: the agent user has no docker socket, and contract §12 §4.3 rules in-sandbox builds out ("builds-stay-in-CI").
Design choices worth a reviewer's eye:
node:24-bookworm-slim@sha256:0e0ff40c…, node 24.21.0). Re-derived withcrane digest node:24-bookworm-slim.ripgrep+fd-findpre-baked. pi'sgrep/findtools resolve binaries fromPATHfirst and only download their own copies when absent (dist/utils/tools-manager.js); in a default-deny sandbox that download path is unreachable, so first run would otherwise have no grep/find. Debian shipsfdasfdfind(pi accepts that name); the image also symlinks/usr/local/bin/fd.npm install -g --ignore-scripts— pi's own containerization guidance (docs/containerization.md).nodeuser (uid/gid 1000) rather than minting a second uid-1000 account.0.87.1/latestonly move fromlive. Any branch push (including this one) publishes only its immutable<short-sha>tag. A feature branch or PR can never republish the alias the fleet pins to — that keeps "merges never deploy" intact while still letting a PR prove the image builds.pi --versionmust equal the pin (grep -qxE), so a wrapped warning or update notice can't pass as a version match. Also assertsgit/rg/fd/node/npmon PATH, uid 1000, and that/workspace+/home/node/.pi/agentexist and are writable.scripts/container-image-size-gate.sh(1 GB policy) rather than the reusableimage-size-gate.yamlworkflow — the reusable one runs in a separate job whose runner may be a different pod, so the locally-built image wouldn't be inspectable.Naming deviation (already ruled on, documented here as required)
The ticket says
agent/pi:<pin>. There is no Harbor project namedagent, and the fleet tooling cannot create Harbor projects, so the image publishes to the existinglab-directorproject as repositorypi:That keeps it in the same project family as the historical Lab Director images (
lab-director/sandbox-base,lab-director/worker, …). Recorded on HOMELAB-1665 and in the README.Verify plan
Re-derived evidence, not pasted logs:
<short-sha>tag lands in Harbor (run + digest linked on the ticket).crane manifest harbor.aaron.reynoza.org/lab-director/pi:<sha>andcrane config …→User=node,Entrypoint=[pi],PI_CODING_AGENT_DIRpresent.live): the same run publishes0.87.1+latest;crane pull harbor.aaron.reynoza.org/lab-director/pi:0.87.1succeeds. Thelab-directorproject allows anonymous pull (Harbor audit log shows anonymous pulls), so a worker node pulls it through the existing Harbor mirror with no imagePullSecret./workspace+ agent-state.Auth convention
Uses the repo's existing
HARBOR_USERNAME/HARBOR_PASSWORDActions secrets — the same pairrunner-image-build.yamlanddocs-build.yamlalready use to push to Harbor. No new secrets, no new credentials. (Cross-checked: Harbor's audit log shows those workflows pushing asadmin, and the only push-capable robot in the registry isrobot$ci-pushwith project*scope, solab-directoris in scope either way.)Out of scope / open questions (in the README)
/workspace,/home/node/.pi/agent).8888, and the Go/Python SDKs assume it. This image is a TUI with no HTTP endpoint, so router/SDK-mediated access is a separate decision;kubectl attachworks today.PI_SKIP_VERSION_CHECKdeliberately left unset (ships pi's default behaviour) — flagged as a ruling to make when the Cilium egress policy lands.Class
C (standard pipeline) — builder does not merge; a fresh-context landing worker owns review/merge.
CI evidence on head
fbb372f(re-derived, not pasted):pi-image-build.yaml(push to branch)pi-image-build.yaml(PR)ci.yaml(PR)lint.yaml(PR)Key log lines from run 1470:
Independent re-derivation from this machine:
The pulled config digest
b00ff8818449…equals the image id the CI build reported (Successfully built b00ff8818449), so the registry artifact is the artifact CI built.Promotion gate is holding on purpose: Harbor holds exactly two artifacts (
3d31e9b,fbb372f) —0.87.1/latestare not published from a feature branch. They publish on thelivepush after merge.One CI failure occurred on the first push (run 1466) and is fixed in the second commit: the digest step used
docker inspect .RepoDigests, which is empty on this dind for a pushed tag (slice index out of range) after a successful push. It now reads the digest from the registry withcrane. Kept as a separate commit so the failure and its evidence stay in the history.Landing report — HOMELAB-1665 (LD0.2) · NOT MERGED (escalated)
Head reviewed:
fbb372f→ fixed to4a77d03. CI on4a77d03: green (all four required contexts + bothBuild Pi Sandbox Imagelegs).Independent review ran in two fresh contexts (
pi -p --no-sessionsubprocesses, separate from the implementer) with a brief built only from this diff, the ticket AC and the frozen constraints. Verdicts wereAPPROVE_WITH_CONCERNS(correctness/quality/conventions) andREQUEST_CHANGES(security/provenance) — 1 blocker, 10 concerns, 7 suggestions. Everything below was re-derived from primary sources; nothing is taken from the PR description.1 blocker — found, fixed, and verified fixed
docker logoutdestroyed the pod-shared Harbor credential. Thek8slabel is:hostmode (statefulset.yaml:DESIRED_LABELS="infra:host,k8s:host"), so steps run inside the runner container asrunnerwithHOME=/home/runnerandDOCKER_CONFIGunset./home/runner/.dockeris a pod-shared emptyDir seeded once per pod from therunner-harbor-robotsecret, and the runner daemon needs it to pre-pull private job images (that is exactly what HOMELAB-1634 added it for).docker logoutdeleted the entry pod-wide.Forensics (the mtimes match CI pushes to the second):
/home/runner/.docker/config.jsonmtimerunner-42026-09-29 04:03:37(== push run for3d31e9b){}(16 bytes)runner-72026-09-29 04:07:57(== push run forfbb372f){}(16 bytes)The seed secret is 202 bytes with
auths → harbor.aaron.reynoza.org, so the empty file is not the seed — it is the logout's residue.Fixed in
4a77d03:export DOCKER_CONFIG="$(mktemp -d)"beforedocker login, logout removed, digest readback unchanged. Verified behaviourally, not just by reading the diff: after the post-fix push run, all 8 pods' config mtimes are byte-identical to the pre-run snapshot (includingrunner-4/runner-7) — the workflow no longer writes the shared file at all.The same footgun pre-dates this PR in
runner-image-build.yaml:36anddocs-build.yaml:40and has already stripped the credential from all 8 pods (HOMELAB-1634's fix is currently ineffective). Filed separately as HOMELAB-1735 — out of this PR's territory, not caused by it.Other changes in
4a77d03(both reviewer concerns, cheap and in-territory)--build-arg PI_VERSIONfrom its ownenv:, which overrides the Dockerfile'sARG, so a Dockerfile-only bump (what the README told you to do) would build the old pi and stay green. Added a guard step that fails the build when the two values drift.docker inspect --format='{{index .RepoDigests 0}}', the exact commandfbb372fremoved because it returns nothing on this dind. Corrected, with the bump flow.Re-derived evidence for the AC
crane digest node:24-bookworm-slim== the digest pinned in the Dockerfile (sha256:0e0ff40c…) — the base pin is real and derivable.User=node,Entrypoint=["pi"],WorkingDir=/workspace,PI_CODING_AGENT_DIR=/home/node/.pi/agent, labelversion=0.87.1. Exported the image filesystem and confirmedetc/passwd→node:x:1000:1000,/workspaceand/home/node/.pi/agentowned1000/1000,usr/local/bin/pi→ the pinned@earendil-works/pi-coding-agent/dist/bundle/cli.js,rg/fdfindpresent, and nodocker/kubectlCLI (contract §12 "builds-stay-in-CI" honoured).pi --versionon 0.87.1 prints exactly0.87.1, so the workflow'sgrep -qxEline-wise assertion is real and not over-fitted.HARBOR_USERNAME/HARBOR_PASSWORDare referenced — no new secrets. Exactly 4 files added, 0 modified, 0 deleted; nothing outside the declared territory.lab-directorispublic: true; with an emptyDOCKER_CONFIG,crane digest harbor.aaron.reynoza.org/lab-director/pi:4a77d03succeeds anonymously — the path a worker node uses. The node-side pull itself lands with LD0.3's first Sandbox.3d31e9b,fbb372f,4a77d03;0.87.1/latestareNOT_FOUND, as designed.Why this is not merged — operator ruling needed (secrets scope)
The review surfaced a credential-scope question beyond the documented additive scope, which per landing rules stops the convoy rather than merging:
The trigger has no
branches:filter, soHARBOR_USERNAME/HARBOR_PASSWORD(which push asadminper the Harbor audit log) are now reachable from a push to any branch, where both peers deliberately restrict the same credential topush: branches: [live]. The robot's actual project scope is not documented anywhere in the repo, and the same credential publishesplatform/forgejo-runner— the image that runs with the SOPS age key and AWS credentials mounted. "Onlylivepromotes" is enforced by workflow source text, not by the credential. The README also states that guarantee more strongly than the mechanism delivers.Two options:
live), or mint a robot scoped tolab-director/pi. The cost is losing the pre-merge<short-sha>evidence tag.Everything else is green and the blocker is closed, so this is a one-line decision.
Concerns/suggestions posted, not blocking (carried forward for LD0.3)
README:34calls<short-sha>"immutable evidence" — it is neither immutable (no retention/immutability control) nor reproducible:4a77d03rebuilt the npm layer on a different pod, so its config/layers differ fromfbb372ffor identical source.0.87.1/latestpromotion is gated on a shell string compare and never asserted afterwards — invisible until a Sandbox runs an old pi.capabilities.drop,allowPrivilegeEscalation: false) while being the template LD0.3 is told to build from;readOnlyRootFilesystemneeds a writable path for pi's compile cache.README:22/README:108assert "default-deny sandbox" behaviour that is not true today (theagent-fleetnamespace and netpols do not exist yet), so those rationales are aspirational. Worth not letting LD0.3 inherit them as facts.--ignore-scriptsis present, integrity for the top-level package is not asserted)./home/node/.pi/agent— the path of least resistance for LD0.3 would put a live operator key inside a container with shell + curl.Merged — HOMELAB-1665 (LD0.2)
Merge commit:
c7a74c7d4a02d49bb77087ef63399b96f0811be8Merged head:
52bd784073ec0a03a2553bb72c59db0cae5aa7f4→liveMethod: merge · no force-push · branch left intact
Trigger narrowing (the orchestrator ruling), applied pre-merge
52bd784adds the only change requested by the ruling —branches: [live]on thepushtrigger:This matches the peer convention exactly (
runner-image-build.yaml,docs-build.yaml).Credential reach before the fix: with no
branches:filter, a push to any branch reachedHARBOR_USERNAME/HARBOR_PASSWORD— repo-level secrets that push asadminand that also publishplatform/forgejo-runner, the image that runs with the SOPS age key and AWS credentials mounted. "Onlylivepromotes" was enforced by workflow source text, not by the credential. Now the credential is only reachable from alivepush (or a deliberateworkflow_dispatchby a write-access actor).PRs remain credential-free — unchanged and confirmed in the file: the publish step is gated
if: github.event_name != 'pull_request', so the PR leg runs build → two smoke steps → size gate and never logs in.The 3 comments above the trigger that described the old behaviour were corrected in the same commit (they would otherwise have stated a promotion rule the trigger no longer implements). That is the only text change; no logic changed.
CI
52bd784— all 6 contexts green:CI Review / helm-validate,CI Review / receiver-test,Lint & Validate / {yaml-lint, shellcheck, terraform-validate}, andBuild Pi Sandbox Image / build-and-push (pull_request)(green, credential-free). All 4 required contexts onlivepresent and passing.pushleg fired on the feature branch — correct, and the point of the change.forgejo_ci_status/ci_failuresstill return 500 onactions/tasks(HOMELAB-1736); status was read viacommits/{sha}/statusinstead.GitOps continuation — live publish verified
The merge to
livefired the image workflow: run 1481,Build Pi Sandbox Image / build-and-push (push)— success in 1m41shttps://forgejo.aaron.reynoza.org/Homelab/infra-core/actions/runs/1481
Re-derived from the registry (not from the log), anonymously via
craneto mirror the worker-node path:0.87.1andlatestexist, are identical to the live head's sha tag, and were resolved with an emptyDOCKER_CONFIG(anonymous) — the ticket's Verify line. Config of the promoted tag:The promotion gate now reads correctly in Harbor. Tag list after merge:
3d31e9b,fbb372f,4a77d03,c7a74c7,0.87.1,latest. Note52bd784is absent — direct evidence the narrowing took effect: the pre-merge evidence tag is gone by design, as the ruling accepted.Blocker fix still holding on the live leg
The
4a77d03docker-config fix (throwawayDOCKER_CONFIG, nodocker logout) was re-checked behaviourally across this run, not just read from the diff. All 8 runner pods'/home/runner/.docker/config.jsonmtimes:Run 1481 executed 04:39–04:41 and no pod's config moved — the workflow does not write the pod-shared Harbor credential at all. (The
04:03:37/04:07:57stamps are the pre-fix3d31e9b/fbb372fruns.)Anomaly noted, NOT addressed here (out of scope)
All 8 pods' shared config is 16 bytes with zero
auths— the HOMELAB-1735 damage inrunner-image-build.yaml/docs-build.yamlis still live, so the runner daemon currently cannot pre-pull private job images. Pre-existing, not caused by this PR, and explicitly out of this PR's territory. Filed as HOMELAB-1735.Deferred to operator review
The dedicated-scoped-robot option (mint a robot scoped to
lab-director/piinstead of sharing the admin-pushing repo secret) is not implemented — deferred by the ruling. Until that lands, the credential's project scope remains undocumented in-repo and the guarantee rests on thelive-only trigger.Concerns/suggestions from the review round remain open for LD0.3 and were not re-opened or re-litigated here.