IR-20261112: fix(sbx-lab,sbx-gits): point the sandbox npm cache at the work volume #952
No reviewers
Labels
No labels
operator-review
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Homelab/infra-core!952
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "plane/IR-20261112-npmcache"
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
One environment variable on the
sandboxcontainer of two StatefulSets:sbx-labsandboxnpm_config_cache=/home/ld/work/.npm-cachesbx-gitssandboxnpm_config_cache=/home/ld/work/.npm-cacheWhy — the failure mode this closes
Both sandbox containers run with
readOnlyRootFilesystem: trueandHOME=/home/ld, and/home/ldis on that read-only root. npm derives its cachedirectory from
HOME($HOME/.npm), so it cannot create it, and anynpm install— a pi package install included — fails on the cache path beforeit ever reaches the network. It is not a network, proxy or registry problem, and
it reproduces with no package name at all.
The work volume is writable and already carries the other build caches
(
GOCACHE,GOMODCACHEon sbx-lab; the same plusTMPDIRon sbx-gits), so thenpm cache goes next to them. No new mount, no widened permission, no new volume:
the path is inside a mount both pods already have.
Scope
core/manifests/apps/sbx-lab/statefulset.yaml— 1 env var + its comment.core/manifests/apps/sbx-gits/statefulset.yaml— 1 env var + its comment.scripts/manifests/tests/test_sbx_npm_cache.py— new offline check (below)..forgejo/workflows/ci.yaml— one step inrunner-manifest-testthat runs it.Nothing else moves: no CPU/memory, no mount, no image, no other container.
The check
test_sbx_npm_cache.pyrenders both app dirs withkubectl kustomize(offline,no cluster) and asserts on the rendered template:
sandboxcontainer declaresnpm_config_cache=/home/ld/work/.npm-cache;HOME=/home/ldonreadOnlyRootFilesystem: true(if either moves, the comment this env carries is describing a world that no
longer exists, so the check must fail rather than stay green);
failure instead of fixing it);
--self-checkpushes six deliberately broken copies through the sameassertions (env dropped, npm's own
$HOME/.npm-cachepath, HOME moved, rootfsmade writable, work mount read-only, env leaked to a sidecar) and fails if any
one of them stops being rejected.
Class determination
Dangerous class: YES — and only because of the fourth file.
core/manifests/apps/sbx-*/statefulset.yaml) and thetest (
scripts/manifests/tests/*.py) match no entry ofDEFAULT_LANDING_POLICY.dangerousPaths(working-files/.../landing-8c666fbe.ts@ 8c666fbe, list at
:2913-2962): they are not RBAC, secrets, network policy,Terraform, config, migrations or an oversight path.
.forgejo/**is on that list (:2917), and it matches.forgejo/workflows/ci.yaml(pathMatches:3203-3220,classifyChangedPaths:3274-3289). CI is an oversight path: a change to itchanges what every PR is allowed to merge through.
So
class=dangeroushere is a choice, stated rather than hidden: the two-linefix needs no second session, but a check nothing runs is not a check. If the
.forgejostep is dropped, this PR becomes non-dangerous and the test stays as ahand-run script. Flagging it so the call is explicit.
Merge effect: nothing restarts
Both StatefulSets are
updateStrategy: OnDelete. An env var is read only when acontainer is created, and
OnDeleterestarts nothing on its own, so mergingthis changes no running pod: each sandbox picks the variable up at its next
natural recreate. Nobody should bounce a pod to pick this up — sbx-lab and
sbx-gits are shared session sandboxes.
Verification
kubectl kustomize core/manifests/apps/sbx-lab→ rc 0 (16984 lines rendered);.../sbx-gits→ rc 0 (22769 lines).python3 scripts/manifests/tests/test_sbx_npm_cache.py→RESULT: PASS(both apps).python3 scripts/manifests/tests/test_sbx_npm_cache.py --self-check→self-check: PASS(12 rejections: 6 mutations × 2 apps).9c6ad6947680fd321795a85842085ab26a18435f(read from the API): run #3638[pass]—helm-validate, runner-manifest-test (12s: the job that now carries this check), shellcheck,
yaml-lint, terraform-validate, dispatcher-test, receiver-test, cockpit-test, cockpit-e2e,
record-sql-test, alert-receiver-test — and run #3639
[pass](shellcheck,terraform-validate, yaml-lint).
APPROVE - ld-reviewer - head
9c6ad69476Reviewed the PR body, the full diff, the two manifests and the workflow as they stand at the pinned head, and CI on that head. Item by item:
Scope of the diff — exactly what is claimed, nothing else. Four files, pure additions, zero deleted lines:
.forgejo/workflows/ci.yaml(+16),core/manifests/apps/sbx-lab/statefulset.yaml(+9),core/manifests/apps/sbx-gits/statefulset.yaml(+9),scripts/manifests/tests/test_sbx_npm_cache.py(+206, new). No CPU/memory requests or limits, no volume orvolumeMountadded or altered, no image, no other container, no other app.sbx-sendrastis untouched, and its absence is not a silent omission: the test states why (its HOME is a writable volume mount, so the default cache works there).The variable lands on the
sandboxcontainer only, in both apps (read from each file at the pinned sha).sbx-lab:sandboxopens at :528,npm_config_cachesits at :731-732 inside that container's own env list next to GOCACHE/GOMODCACHE (:720/:722); the pod's other container (skill-sync, :867) has no such env.sbx-gits:sandboxat :747, the variable at :802-803; neitheremu-health(:1128) noremulator(:1205) grows it. Each file carries exactly one occurrence of the name. Value is/home/ld/work/.npm-cachein both, and/home/ld/workis mounted on the sandbox with noreadOnly: true, so the cache path is genuinely writable.The CI step is a real gate, not a no-op. It is a new step in
runner-manifest-test(ci.yaml:139-153) that runs the script twice — straight, then--self-check. The straight run renders both app dirs withkubectl kustomize(a non-zero render exits FATAL, so a broken render cannot pass) and asserts on the rendered template that thesandboxcontainer declaresnpm_config_cache=/home/ld/work/.npm-cache, thatHOME=/home/ldandreadOnlyRootFilesystem: truestill hold, that the path lies under a mount that is not read-only, and that no other container carries the variable. With the env dropped, assertion B fails onNone != CACHE_PATH— that is precisely the regression it catches.--self-checkre-feeds six mutations (env dropped, npm's own$HOMEpath, HOME moved, rootfs writable, work mount read-only, env leaked to a sidecar) through the sameproblems()and fails if any one stops being rejected. Arunblock that exits non-zero fails the job, so this cannot rot into a green no-op.No secrets or unexpected content. The only two added
value:lines are paths. A scan of the diff for private keys, passwords, tokens, secrets, API keys and base64 blobs returned zero hits; the rest of the added text is explanatory comments and the test's own logic. No credential is read, widened or copied.CI on the exact head
9c6ad6947680fd321795a85842085ab26a18435f— read fromGET /repos/Homelab/infra-core/commits/<sha>/status: combined statesuccess, 11/11 contexts green — helm-validate, runner-manifest-test, receiver-test, dispatcher-test, alert-receiver-test, cockpit-test, cockpit-e2e, record-sql-test, shellcheck, yaml-lint, terraform-validate.runner-manifest-testis the job that carries the new step, so the gate ran at this head. The/statuseslist also shows the enqueue-timependingrows from 00:14:42Z; each context's latersuccesssupersedes its own placeholder, and the combined status I read is success.The dangerous-class claim is stated, not hidden. The body has an explicit "Class determination" section declaring dangerous YES and naming the single reason:
.forgejo/workflows/ci.yamlmatches.forgejo/**on the dangerous-path list, i.e. an oversight path. It also states that the two manifests and the test match no dangerous entry, and that dropping the CI step would make the PR non-dangerous. The policy constants themselves are not in this repo, so I cannot re-derive the list from here; what I can confirm is that the classification is declared up front and its reasoning matches the oversight-path rule. On that basis this is a dangerous-class change and needs two approvals from independent review sessions, not one.Beyond the checklist: the diagnosis holds —
readOnlyRootFilesystem: truewithHOME=/home/ldon that root does make$HOME/.npmimpossible, so pointing the cache at a mount the pod already has fixes it without a new volume or widened permissions.updateStrategy: OnDeletemeans merging restarts nothing, which matches the body. I found nothing that must change.model: omniroute/deepseek-flash
session: IR-20261112-npmcache-seatA-20261112T0232Z
APPROVE — ld-reviewer — head
9c6ad69476Diff is exactly the claim: 4 files, 240 additions, 0 deletions —
.forgejo/workflows/ci.yaml+16,core/manifests/apps/sbx-gits/statefulset.yaml+9,core/manifests/apps/sbx-lab/statefulset.yaml+9,scripts/manifests/tests/test_sbx_npm_cache.pynew +206. Each manifest hunk is a comment block plus onename/valueenv pair; no CPU/memory/limit, no volume or mount, no image, no other container, no whitespace churn.sbx-sendrastappears nowhere in the diff.Placement verified on the head render, not on the source alone: I ran
kubectl kustomizeon both app dirs at9c6ad69(sbx-lab 16984 lines, sbx-gits 22769 lines — matching the body) and parsed the documents. sbx-lab containers =[sandbox, skill-sync], sbx-gits =[sandbox, emu-health, emulator]. In both,npm_config_cache=/home/ld/work/.npm-cacheis on thesandboxcontainer's ownenv, together withHOME=/home/ldandreadOnlyRootFilesystem: trueon that same container. No initContainer and no sidecar carries it.The check bites. I re-implemented A–E against the same rendered documents and got PASS for both apps: one StatefulSet per app with the right name, env on
sandbox, cache path under a writable mount (/home/ld/work, mounted withoutreadOnly; the read-only mounts are elsewhere), and no other container carrying the variable. Dropping the env from the parsed template makes the assertion fail (npm_config_cache=None), so the step would go red if the variable were removed. Its dependencies are real on the k8s runner image: kubectl is pinned incore/manifests/apps/forgejo-runner/Dockerfile:72-77andpython3-yamlis installed at:12. The new step sits inside jobrunner-manifest-test, andci.yamlruns onpull_requesttolive/epic/**. Theleaked-to-another-containerself-check mutation has a real second container to hit in both apps (skill-sync / emu-health+emulator), so that mutation cannot pass vacuously.No secrets or unexpected content: no token, credential,
valueFrom, or secret reference added; the only new literals are the cache path, comments, and the offline checker.CI at the exact head
9c6ad69476: combined statesuccess, 11/11 contexts success —CI Review / helm-validate (pull_request),CI Review / runner-manifest-test (pull_request)(12s: the job carrying the new check),CI Review / receiver-test,CI Review / alert-receiver-test,CI Review / record-sql-test,CI Review / cockpit-e2e,CI Review / dispatcher-test,CI Review / cockpit-test,Lint & Validate / shellcheck,Lint & Validate / yaml-lint,Lint & Validate / terraform-validate. The two workflow runs whosecommit_shais this head (repo runs #3638 on ci.yaml, #3639 on lint.yaml) are bothsuccess; the check-runs endpoint is 404 on this Forgejo, so statuses are the equivalent read.Class: the body states
class=dangerousopenly in its own "Class determination" section, names.forgejo/**as thedangerousPathsentry and the ci.yaml step as the only reason for the class, and says so is a choice rather than hidden. Nothing is concealed or downplayed. I also confirmedupdateStrategy: type: OnDeleteon both StatefulSets in the render, so merging rolls no pod and the variable lands at the next natural recreate.No problems found; nothing to change.
model: omniroute/deepseek-flash
session: IR-20261112-npmcache-seatB-20261112T0232Z