Repository navigation
fix(cache): collision-free QA volumes, trusted per-repo only - #372
Merged
Merged
Conversation
This was referenced Nov 5, 2026
sudoTomas
force-pushed
the
fix/qa-volume-isolation
branch
from
November 5, 2026 03:44
635b3e6 to
7b0bd43
Compare
A per-repo QA cache volume is reused by every QA job of its repository, and a pre-built QA image becomes the base of every QA sandbox of its repository. Each therefore belongs to one deployment and one repository, is identified by labels, and is populated only from the repository's default branch as the forge reports it. Volumes are named embry0-qa-vol-<scope>-<sha256(instance id, key)> and carry the managed, instance, volume_scope and scope_key labels (and job_id for a per-job volume). Every label is checked before a volume is reused, removed (a recreate, the job's cleanup) or mounted (the warmer, the QA fan-out, each sub-task). A volume that does not match is never used: the run gets a per-job volume or its sub-tasks clone their own workspace, and a removal is refused and logged. Volumes are created explicitly before any mount, and a missing one is never mounted. A volume name carried in job state that is not this release's name for the run's repository or job is re-resolved to the job's per-job name and checked like any mount. Without an instance id no per-repo volume is used, and the run uses a per-job one. A run is trusted when the forge's default branch and its current head, read with git ls-remote --symref through sandbox_git in the bootstrap sandbox, are the run's branch and resolved head; an answer with other than one symref line and one HEAD line trusts nothing. A job's base_branch override and the base_branch preference choose the diff base only. Only a trusted run uses the per-repo volume: it is recreated empty when its recorded lockfile differs from the run's, and reused as it is, with the warm skipped, while the lockfile matches. The QA agent runs during trusted runs, so what it writes into the tree can persist into later ones until the lockfile changes; the qa.yaml reference and SECURITY.md say so. Every other run uses a per-job volume. Each round starts with no shared volume and mounts only the one it prepared. POST /qa/images/build needs admin tier for any branch other than the default branch the forge's API reports, and for any bake when the forge does not answer. A QA run selects a pre-built image only when it was baked from the default branch the forge reported for that run. The retention sweep ages a per-repo volume by the state row written for that volume. A per-repo volume under a name other than this release's is removed once it is older than the retention, whatever jobs its repository runs, unless a container mounts it. cache.turbo_remote.enabled defaults to false.
sudoTomas
force-pushed
the
fix/qa-volume-isolation
branch
from
November 5, 2026 16:47
7b0bd43 to
df565a9
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
QA cache volumes are named from a hash of the deployment's instance id and the scope key, carry ownership labels, and are checked against every one of them before they are reused, removed or mounted. A per-repo volume backs only a QA run of the forge's default branch at its current head; every other run gets a per-job volume. Baking a QA image from any other branch is admin tier, a QA run selects only an image baked from the forge's default branch, and the Turbo remote cache is off by default.
Motivation — Intent + Problem
A per-repo volume is mounted writable by every QA job of its repository, and a pre-built QA image becomes the base of every QA sandbox of its repository. Each therefore has one owner and is populated only from code the repository has merged: it belongs to one deployment and one repository, is identified by labels, is checked against them before anything uses or removes it, and is trusted only when the forge itself names the run's branch and commit as the default branch's head.
How it works
volume_name_for(scope, key, instance_id=…)returnsembry0-qa-vol-<scope>-<sha256(instance id, key)>. The warmer sandbox is named after the volume.managed,instance,volume_scope,scope_key(andjob_idper-job).label_mismatchesrequires each one to be exactly what this deployment would stamp for that scope and key, and any it would not stamp to be absent.ensure()reuses only a matching volume; otherwise a per-repo request gets a per-job volume and a per-job request raisesVolumeOwnershipError.recreate()anddestroy_for_job()read the labels before removing anything; a volume that does not match is refused and logged (qa_volume_remove_refused,qa_job_volume_remove_refused).verify()runs right before the warmer mounts a volume, before the QA fan-out hands one to the sub-tasks, and in each sub-task. A missing volume is refused, never left for the daemon to create unlabelled; volumes are created explicitly, labelled, before any mount.mountable(name, repo=…, job_id=…)accepts only this release's name for the run's repository or job. Any other name, such as one a checkpoint written by an earlier release holds, is re-resolved to the job's per-job name and checked like any mount, so it is never mounted; the sub-tasks then clone.qa_per_repo_volume_unavailable).forge_default_headrunsgit ls-remote --symref <repo URL> HEADthroughsandbox_git(new stepdefault_branch), outside the workspace, so the git-proxy answers for the repository with its own credential.parse_symref_headaccepts exactly one symref line and oneHEADline; anything else is no answer.trusted_per_repo_volumeis true only when the config asks forper-repo, the run's branch is that default branch and the run's resolved head (qa["head_sha"]) is its current head. A job'sbase_branchoverride and thebase_branchpreference choose the diff base only. No answer from the forge trusts nothing.docs/qa-yaml-reference.mdlists what carries over. The QA agent, which has a shell, runs during trusted runs, so anything it is led to write into the tree (node_modules,.git/hooks) can persist into later trusted runs until the lockfile changes. The qa.yaml reference and SECURITY.md (the data-retention row and "What does not hold") say so, and that the per-repo scope suits applications whose QA-visible content (pages, seeded data) is trusted. When the manager hands back a per-job volume instead, the warmer is given, and records, that volume's scope and key. Each round starts with no shared volume and mounts only the one it prepared.POST /qa/images/buildreads the default branch from the forge's API (get_repo/get_project) and needs admin tier for any other branch, and for any bake when the forge does not answer. Selection takes the default branch as a required argument (qa["forge_default_branch"], read from the forge by the run) and selects only a row whose recordedmetadata.branchis exactly it.qa_volume_staterow written for that volume (itsvolume_name). A per-repo volume under a name other than this release's is never mounted again, so it is removed once older than the retention from its creation, live jobs of its repository or not, unless a container mounts it.cache.turbo_remote.enableddefaults tofalse.Pattern provenance
N/A — originated in this PR.
Verification
Automated (rebased on
ca9843d5, which contains #353)-n 4 --fail-on-warnings,DOCKER_HOST=unix:///nonexistent.sock:18129 passed, 21 skipped in 425.09s (0:07:05)51 passed, 20 skipped(the Docker-backed ones skip, withDOCKER_HOSTpointed at no daemon)ruff format --check .andruff check .on the whole tree: clean.mypy embry0:Success: no issues found in 650 source files.test_sandbox_git_guard.py: 4 passed.git archiveofmainwith only the test files copied in: 84 failed, every one on anAssertionError(orpytest.fail) carrying its own message; none on an import or type error.tests/unit/cache/test_volume_manager.pyreaches the newer API through_api/_params, so it collects onmainand fails on assertions there.turbo_remoteback on by default;origin; a second symref line or a secondHEADline accepted, or the last of each taken (the five newtest_trusted_run.pycases for this also fail on the previous head,7b0bd437);recreate(),destroy_for_job()orensure()skipping the label check; extra ownership labels ignored;verify()skipping labels, accepting a missing volume, accepting any name;mountable()trusting a name from state; per-repo volumes without an instance id;mainor reading the preference;qfix-volumes and containers, aqfix-instanceid, all removed afterwards; 12/12): a firstrecreate()creates the labelled per-repo volume; reuse keeps it; a volume labelled for another key is never reused, andrecreate()refuses to remove it; a mounted volume is left in place and the run gets a per-job one; an idle one is emptied;verify()refuses a missing volume without creating it, refuses a foreign one and passes ours; an older-format name is not mounted;destroy_for_job()removes the job's own volume and refuses one named as the job's but labelled for another; with no instance id a per-repo request gets a per-job volume.qfix-container serving a bare repository over HTTP, embry0's own git configuration written bysandbox_git.configure; 6/6):forge_default_headreturns the default branch and its head; the head is trusted, an older commit of the same branch and another branch are not; no answer from the forge, and a lookup the prelude refuses (its configuration made writable), read as no answer.--no-gitover the changed files, live as shown by a canary): no leaks. Name denylist after staging: 0 matches.Deploy notes
infra/deploy.sh. No image rebuild:embry0/safety/error_codes.pyandembry0/execution/sandbox_manager.pyare baked into the sandbox images, but what changes there (an error-code mapping for an orchestrator-side exception, a docstring) does nothing inside a sandbox; the next routine rebuild picks it up.Per-repo volumes start empty under the new names: each trusted repository's first QA run after the deploy installs from scratch.
A QA job resumed from a checkpoint written before the deploy holds a volume name in the earlier format; it is re-resolved and not mounted, and that round's sub-tasks clone their own workspace.
Earlier-format volumes. Per-job ones keep their labels and go with their job (its cleanup or the reaper). Per-repo ones (
embry0-qa-vol-<repo with / as ->) are no longer used. WithQA_IMAGE_RETENTION_DAYSset, the sweep removes each once it is older than the retention, counted from its creation, unless a container mounts it. To reclaim them now, or without retention, on the sandbox daemon (with the default runtime, throughembry0-dind; withAGENT_RUNTIME=rootless, against the rootless daemon). The instance filter leaves every other stack's volumes on that daemon alone:volume rm(no--force) refuses a volume a container still mounts.Pre-built images. A QA run now selects an image only when its bake recorded the forge's default branch (
metadata.branch, recorded by every bake throughPOST /qa/images/build). A repository whose active image records another branch, or none, runs on the profile's own image until it is baked again from the default branch (operator tier). An image bake of any other branch needs an admin-tier key, and so does a bake when the forge's API does not answer for the repository.Turbo. A stored config that omits
cache.turbo_remoteno longer gets theTURBO_*variables; setcache.turbo_remote: { enabled: true }for a repository that should keep the remote cache.embry0_sandbox_git_refusals_totalgains thestep="default_branch"series, seeded at 0.Merging with fix(sandbox): a Maven cache per repository, not one shared #374: both edit
embry0/cache/qa_image_retention.py, its tests and theQA_IMAGE_RETENTION_DAYSline ofdocs/configuration.md. Keep both sides; the older-name rule here applies toper-repovolumes only, so fix(sandbox): a Maven cache per repository, not one shared #374's Maven caches keep their live-job rule.