Skip to content

feat(sandboxd): tenant managed volumes with hibernate and online growth - #369

Closed
minatoaquaMK2 wants to merge 75 commits into
cocoonstack:mainfrom
minatoaquaMK2:feat/managed-volume-hibernate
Closed

minatoaquaMK2 wants to merge 75 commits into
cocoonstack:mainfrom
minatoaquaMK2:feat/managed-volume-hibernate

Conversation

@minatoaquaMK2

Copy link
Copy Markdown
Contributor

Why

Issue #300 asks for a tenant's data disk that outlives a claim, has one writer at a time, and survives hibernate. This PR replaces #368 with our current version. A tenant can create a disk on a node, attach it to a warm claim, keep it through release and restarts, hibernate and wake with it mounted, and grow it while the VM runs.

What changed

  • A registry under managed_volume_dir records each disk (owner, size, file identity) and which VM holds it (boot ID, PID, start time). A hold is freed only after the VM and its process are gone, so a crash or a slow vm rm cannot hand the disk to a second writer.
  • New routes PUT/GET/DELETE /v1/volumes/{name} and POST /v1/volumes/{name}/grow, with Go and Python SDK methods. Hibernate of a claim with one mounted writable managed disk runs a guest hook to stop writers, unmounts, detaches and captures. Wake does the reverse.
  • We re-checked every edge case feat(sandboxd): reconcile live read-only volume attachments #339 handled (busy, bind and stacked mounts, a release racing a change, restarts mid-change, error codes, Retry-After, journal rules) against each managed path. Each gap we found is fixed with a test that fails first.

Scope

  • Covers create, claim-time attach, single writer, hibernate and wake, and online growth.
  • Leaves out placement by volume, archive, and Kubernetes.
  • Online growth needs cocoon vm disk grow and cocoon vm disk inspect --disk-id, which cocoon does not ship yet. On current cocoon, growth answers 503 volume_grow_unsupported. We can split growth into its own PR until cocoon has them.

Question for maintainers

After a restart, if one leftover VM (no claim, only catalog volumes attached) cannot be confirmed removed, startup marks managed storage uncertain. Every managed volume call then answers 503, and every claim that holds a volume is marked failed, until the next restart. Nothing retries that VM's removal, so its catalog volumes stay held even after it is gone.

We lean to B. Which do you prefer?

Tradeoffs

  • Holds live in a second file, registry.json, beside claims.json, so a hibernated claim keeps its writer lock and a crash mid-attach cannot free a disk early. claims.json keeps its flat format. We can move holds into claims.json if you prefer that.
  • The registry version goes from 1 to 3 on first hibernate or grow. We can collapse it to one version before merge.

Blast Radius

Nodes without managed_volume_dir keep their behavior, with two shared fixes. A guest mount conflict or a too-old cocoon on hibernate or wake now answers 409 or 503 instead of 500. The Python SDK now rejects a bare string or dict as a claim's volume list.

Verification

  • go test -race for sandboxd and e2e pass, except two tests that also fail on main when run as root. golangci-lint passes on linux and darwin, and ruff, pytest and mypy pass.
  • Each gap fix is a failing-test commit followed by its fix. We checked out each pair and saw red, then green.
  • Real-VM runs for this revision are still pending.

Keep tenant data disks across claim release and template replacement.
Add node-local management, SDK methods, and durable writer ownership.
Preserve registry checks and native device identities across Linux and Darwin.
Use the pinned CI toolchain to verify the full lint matrix.
minatoaquaMK2 and others added 27 commits October 21, 2026 00:35
VerifyMountedVolume compared the requested path with mountinfo
literally, so a mount under a symlinked parent read as a bind and
hibernate refused a valid claim. It now resolves the target like
ObserveVolume and the live mount paths do, and its conflicts name the
resolved guest path. The literal-root test now counts guest changes, not
the read-only realpath lookup.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…olume run

Managed expiry and release of a sleep-capable claim go through
releaseManagedSleepLocked, which drops the claim without cancelling its
live volume run. The run keeps issuing device calls against a removed
sandbox, a catalog ref the run already dropped from the hold keeps its
admission charge, and the claim's per-volume diagnostics outlive it.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…opped

releaseManagedSleepLocked, used by managed expiry and by release of a
sleep-capable claim, dropped the claim without cancelVolumeRun. The run
kept calling the device, its diagnostics stayed in volumeErrors, and a
catalog ref it had already dropped from the hold never left admission.
Cancel it under m.mu like releaseResolved and charge its holds to the
teardown.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…al write

When a live catalog removal drops the ref from the managed hold in
memory but the registry write fails, commitVolumeRun and the dirty
catalog path in stageVolumeTarget return before releasing admission,
and finishVolumeRun skips managed claims. The background retrier
persists the drop, yet the catalog stays busy until restart.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
managedLiveRemove drops refs from the hold in memory before writing the
registry, and the retrier persists that drop after a write failure. The
callers returned on that error before releasing admission, and
finishVolumeRun skips managed claims, so the catalog stayed busy until
restart. Both callers now release whatever the hold no longer
references, which is nothing when managedLiveRemove refused first.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
A live catalog change on a claim whose growth is unresolved returns
ErrFailed, telling the caller its VMM is down and to release it. Release
and Hibernate report the same state as 409 volume_grow_busy, and release
is exactly what the growth fence refuses.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
beginVolumeRun folded an unresolved growth into the not-claimed hold
check and returned ErrFailed, whose message tells the caller to release
a VMM that is running and that the growth fence will not let go. It now
returns volume_grow_busy like Release and Hibernate. checkVolumeRunLocked
keeps ErrFailed: growth admission refuses a claim with an active run, so
growth ownership mid-run only comes from a registry fault.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
A second claim of a growing managed volume gets 503
volume_storage_unavailable, an outage to retry, instead of the 409
volume_busy with holders it gets while the owner is running or asleep.
lookupManaged rejects every non-ready state before admission can name
the holder.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
lookupManaged rejected every non-ready state as a storage outage, so a
claim of a volume its owner was growing got 503 with Retry-After. The
volume is healthy and held, so answer 409 volume_busy with its holders,
the same refusal admission gives while the owner runs or sleeps.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Create, delete, and grow answer an unmapped manager error with 503
volume_storage_unavailable and a "retry" message but no Retry-After,
while managedStorageError with the same code sends one, so clients that
key retries on the header treat the same outage two ways.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
The create, delete, and grow fallbacks for an unmapped manager error
wrote 503 volume_storage_unavailable with "retry" text but no
Retry-After, unlike managedStorageError with the same code. One helper
now writes that fallback with the header.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
A managed volume whose backing was replaced settles as recovery_required,
which only an operator clears. PUT and claim still answer 503
volume_storage_unavailable with Retry-After, inviting a retry loop that
cannot succeed.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
markRecovery and the recovery_required state answered PUT and claim
with volume_storage_unavailable, so the server added Retry-After to a
condition only an operator clears. They now return 503
volume_recovery_required, which joins the codes that omit Retry-After,
and the API docs list it.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
GET reports a ready volume whose backing identity changed as
recovery_required without persisting it, but a claim that arrives before
PUT or DELETE marks the volume still gets volume_storage_unavailable
with Retry-After for the same operator-only condition.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…claim

lookupManaged answered a backing mismatch or missing backing with
volume_storage_unavailable, while GET already reports the same
observation as recovery_required. Claims now return
volume_recovery_required for it too, without persisting the state, like
GET.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Client.new and Template.new iterate volumes="work" into four one-letter
volumes and send them, while set_volumes and patch_volumes already
reject a bare string where a list is expected.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
_claim_body iterated volumes="work" into four one-letter volumes. It now
raises TypeError before building the claim, matching how set_volumes
and patch_volumes reject a bare string.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
govet's shadow check flags the PutTenant calls in the expiry and
teardown tests added earlier on this branch.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…ned backing

A claim treats any ENOENT from verify as a lost backing, so a moved
managed root or disks directory answers the permanent
volume_recovery_required. A claim of a sleeping volume's lost backing
does the same, though PUT keeps it transient because wake still owns
the backing. Both should answer volume_storage_unavailable with a retry
hint.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
The e2e fake engine has no process identity, so a managed claim can
never bind there. A PUT that answers 200 ready after the directory comes
back shows the outage left neither a registry fault nor a
recovery_required volume behind.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
verify returned the raw ENOENT from a moved managed root or disks
directory, which every caller read as a lost backing file. It now
returns errBackingMismatch only for a missing or mismatched backing
file, so a directory outage stays volume_storage_unavailable and PUT
no longer faults the registry over it.

lookupManaged also released r.mu around verify and then answered
recovery_required without rechecking. It now shares markRecovery's
check: the record must be unchanged and not owned by a sleep or a
fenced growth, or the answer stays transient.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
A guest mount conflict aborts hibernate preparation and resumes the
guest. When that resume fails too, the caller only sees the conflict,
which the server maps to 409 "close guest references and retry", while
the sandbox is actually stuck aborting and needs sleep recovery.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
prepareManagedSleep joined the preparation error with the raw resume
error. With a guest mount conflict first, the server ranks the conflict
and answers "close guest references and retry", hiding that the guest
never resumed. A failed resume now joins sleepRecoveryError(), as
captureRunningManagedSleep already does, so the caller gets 409
managed_sleep_recovery_required.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…races

The teardown tests asserted an empty admission entry, but
releaseVolumes deletes an entry once its counts drop below one, so a
double release looked identical to a correct one. A second read-only
holder of the catalog now has to survive each race with readers:1, and
a writer stays refused until that reader leaves. Test-only; these pass
on the previous head.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
The claim section lacked the growing volume's 409 volume_busy and the
two managed 503 codes. Hibernate and wake lacked the guest mount 409,
the sleep recovery 409, and the 503 below Cocoon v0.7.2, which carries
no Retry-After.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
volumes={"name": "work"} iterates the mapping's keys and claims a
volume named "name". A bare mapping should raise TypeError like a bare
string, before any request.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
_claim_body rejected a bare string but iterated a bare mapping's keys,
so volumes={"name": "work"} claimed a volume named "name". It now
raises TypeError for either before sending the claim.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@minatoaquaMK2

Copy link
Copy Markdown
Contributor Author

Pushed follow-ups from a second pass over the cases #339 covered. Each behavior fix is a failing test commit followed by its fix.

  • Release no longer pops a mount stacked on top of a writable volume. UnmountVolume now takes the volume name and refuses unless the volume's own device is the mount, so the dirty marker stays when the volume was not really unmounted. This also changes release for catalog writable volumes, which had the same bug on main.
  • Hibernate and wake no longer fsync an unchanged claims.json.
  • A managed directory renamed or missing for a moment is now a retryable storage error everywhere (list, GET, claim, DELETE), not a permanent recovery_required.
  • The Python SDK rejects a bare dict in set_volumes and patch_volumes, like claims do.
  • Fixed the test helper behind the hang in TestManagedGrowthAdmissionSerializesOrdinaryReleaseAndReap (80 runs in a row pass).

Three more questions, besides the one in the description:

  1. If the final registry write of a wake fails, we roll the sleep record back and keep the guest paused, then the retry runs the resume hook again. Rolling back keeps guest access from opening before the disk agrees, and hooks are required to be idempotent. Is that acceptable?
  2. If claims.json is renamed but its directory fsync fails during a managed release, we fence all managed volumes until restart. We could instead lift the fence once the reap tick has durably rewritten the affected claims. Do you want that?
  3. An interrupted create (backing file present, no recorded inode) shows creating in GET until an operator removes the file. Should GET show it as needing recovery instead?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant