Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
2c2e02b
pool: persist runtime volume registrations
minatoaquaMK2 Oct 25, 2026
247227e
test(pool): reproduce unrelated admission during catalog failure
minatoaquaMK2 Oct 26, 2026
debf729
fix(pool): scope catalog uncertainty to affected volumes
minatoaquaMK2 Oct 26, 2026
af6ee9a
pool: reproduce private name disclosure during catalog writes
minatoaquaMK2 Oct 26, 2026
8303c4b
pool: check volume authorization before pending admission
minatoaquaMK2 Oct 26, 2026
e51545d
pool: reproduce inventory changes during failed removal
minatoaquaMK2 Oct 26, 2026
aff9797
pool: preserve stale inventory observation after failed removal
minatoaquaMK2 Oct 26, 2026
f49765a
pool: align cleanup tests with post-removal inspection
minatoaquaMK2 Oct 26, 2026
df7c308
pool: release taken-over volume holds when the run ends
minatoaquaMK2 Oct 26, 2026
e942d31
pool: classify stale VMs by inspection for the volume fence
minatoaquaMK2 Oct 26, 2026
de605a5
pool: make the resolved volume check side-effect free
minatoaquaMK2 Oct 26, 2026
969b7ab
pool: count catalog references from holders and the journal
minatoaquaMK2 Oct 26, 2026
1cee58e
pool: hold Transition across the volume clean check again
minatoaquaMK2 Oct 26, 2026
14429ed
pool: drop duplicated volume registration checks
minatoaquaMK2 Oct 26, 2026
9068fc0
pool: compact runtime volume tests
minatoaquaMK2 Oct 26, 2026
960f7bc
pool: read one catalog snapshot per quiesce and repair pass
minatoaquaMK2 Oct 26, 2026
49b468a
pool: restore the attach-only resolution comments
minatoaquaMK2 Oct 26, 2026
39d8666
pool: name the claim store volume counts for what they count
minatoaquaMK2 Oct 26, 2026
920430d
pool: cover invalid volume names and capture after acknowledgment
minatoaquaMK2 Oct 26, 2026
70f98a9
docs: trim volume fence prose
minatoaquaMK2 Oct 26, 2026
568a81a
docs: state the volume access-list rule and operator duties once
minatoaquaMK2 Oct 26, 2026
30b65a3
pool: reject non-UTF-8 runtime volume paths
minatoaquaMK2 Oct 27, 2026
00f62c7
pool: keep the volume fence for a stale stopped VM
minatoaquaMK2 Oct 27, 2026
0df2bc5
pool: scan published claims before retiring a volume registration
minatoaquaMK2 Oct 27, 2026
7c513c9
pool: bound runtime volume registration by volume_roots
minatoaquaMK2 Oct 27, 2026
42ef827
docs: document volume_roots and the root token's path reach
minatoaquaMK2 Oct 27, 2026
de8300b
pool: skip saved registrations outside volume_roots at startup
minatoaquaMK2 Oct 27, 2026
c5fa28f
pool: group volume catalog methods
minatoaquaMK2 Oct 27, 2026
09bb47b
docs: note empty volume_roots and DELETE of a skipped registration
minatoaquaMK2 Oct 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions docs/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,8 @@ gets the intersection of the two.
| `advertise_addr` | = `listen` | internal HTTP host:port for peer traffic and preview forwarding; also used by clients when `client_advertise` is unset. Must be routable when `listen` is a wildcard; a node with `mesh` set refuses to load while it names an unspecified host |
| `client_advertise` | unset | client-facing HTTP(S) origin for this node, e.g. `https://node-a.sandbox.example.com`; published in owner, redirect, and peer responses. No path, query, fragment, or userinfo |
| `bridges` / `networks` | unset | egress-lane attachment: a list of host bridge devices, or a list of CNI conflist names. Mutually exclusive; with neither set the node serves only the no-network lane. A Linux bridge holds at most 1024 ports (kernel `BR_MAX_PORTS`), so an N-entry list raises the node's egress ceiling to N×1024 — VMs spread over the list by a stable hash of the VM name, so size it with headroom (the spread is statistical, not exact). `bridges` keeps the raw TAP-on-bridge attachment (taps in the root netns, no per-VM network namespace or CNI plugin execution); `networks` runs the CNI chain per VM. [Guarded egress](egress.md) on the egress lane (an egress-lane pool policy or any tenant policy) needs `bridges` and rejects a CNI network at load; none-lane pool policies ride the proxy on either |
| `volumes` | unset | node-local catalog of operator-managed dataset images: `[ {"name":"imagenet","path":"/srv/datasets/imagenet.img","directio":"off","tenants":["acme"]}, {"name":"scratch-db","path":"/srv/datasets/scratch.img","writable":true} ]`. Names match `^[a-z][a-z0-9_-]{0,19}$` and cannot start with `cocoon-`; paths are absolute; `directio` is `on`, `off`, or `auto` and defaults to `off` for both read-only and writable entries. `tenants` is an optional access list: empty means every authenticated scope, and a listed name the tenant set does not hold grants nothing and logs a warning at boot (the [tenant API](sandboxd-api.md#tenants-v1tenants) may add it later); root always has access. `writable` (default `false`) lets a claim request `mode: "rw"` on that entry — see [Dataset volumes](#dataset-volumes). The catalog is intentionally not part of the cluster digest |
| `volumes` | unset | node-local catalog of operator-managed dataset images: `[ {"name":"imagenet","path":"/srv/datasets/imagenet.img","directio":"off","tenants":["acme"]}, {"name":"scratch-db","path":"/srv/datasets/scratch.img","writable":true} ]`. Names match `^[a-z][a-z0-9_-]{0,19}$` and cannot start with `cocoon-`; paths are absolute; `directio` is `on`, `off`, or `auto` and defaults to `off` for both read-only and writable entries. `tenants` is an optional [access list](sandboxd-api.md#volume-access-lists); a listed name the tenant set does not hold grants nothing and logs a warning at boot (the [tenant API](sandboxd-api.md#tenants-v1tenants) may add it later). `writable` (default `false`) lets a claim request `mode: "rw"` on that entry — see [Dataset volumes](#dataset-volumes). The catalog is intentionally not part of the cluster digest |
| `volume_roots` | unset | absolute directories that bound [runtime registration](sandboxd-api.md#put-v1volumesname): the registered path, once cleaned, must equal a root or sit under one, or the PUT returns 400. The check is lexical: `..` is resolved but symlinks are not followed, so a symlink under a root can point outside it. Static `volumes` entries are not checked. After narrowing, a registration already in `runtime-volumes.json` outside the roots is skipped with a warning at startup, so it cannot be claimed, and the next registration change drops it from the file. A DELETE of a skipped name that nothing references returns 204 without rewriting the file. Keep `data_dir` outside every root. Unset or an empty list accepts any absolute path |
| `secrets` | unset | credentials the egress proxy injects by name: `[{"name": "gh", "header": "Authorization", "value_env": "GH_TOKEN"}]`. A pool or egress-class rule references the name (on a claim where both apply, only the pool rule's secret is injected); the value comes from the claim's `guest: false` env, then the node's environment, never this file. Without `value_env` the secret's name is the env to read, and only claims supply it. See [egress](egress.md#claim-env-and-secrets) |
| `egress_internal_allow` | unset | CIDR prefixes re-admitted through the egress proxy's SSRF guard, node-wide (every pool and tenant); `prefix:port,port` scopes an entry to those ports, a bare prefix admits every port. Prefer the port form. Prefixes, not a permit-private switch — the guest bridges are themselves ULA/RFC1918. See [egress](egress.md) |
| `egress_upstream` | unset | per-claim upstream proxies: `{"claim_env": "EGRESS_UPSTREAM", "allow": ["res.example.com", "10.1.0.0/16"]}`. A claim's `guest: false` entry under `claim_env` (`http://` or `socks5://`, optional `user:pass@`, or `direct`) routes its egress through that upstream, which must be on `allow` (host names, IPs, CIDR prefixes). See [upstream proxies](egress.md#upstream-proxies) |
Expand Down Expand Up @@ -323,10 +324,9 @@ Warm candidates retain their normal ranking, but a volume claim's candidate
set, promoted-template intersection, and redirect follow the fleet-wide rule
in [cluster](cluster.md#volumes-and-placement).

An empty catalog `tenants` list allows every authenticated scope; a nonempty
list limits the image to those tenants, while root always bypasses it. A
listed tenant the set no longer holds grants nothing and logs a warning at
boot.
Catalog `tenants` lists follow the
[access-list rule](sandboxd-api.md#volume-access-lists). A listed tenant the
set no longer holds grants nothing and logs a warning at boot.
`GET /v1/volumes` reports the caller-visible fleet union and holder count, plus
the answering node's current local availability and whether the entry is
`writable`, without exposing host paths or node addresses. Applied names,
Expand Down
7 changes: 7 additions & 0 deletions docs/live-volumes.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ file. Keep each name bound to the same immutable image while held. Use Cocoon
v0.7.2 or newer and set `SANDBOX_ADDR` and `SANDBOX_API_TOKEN` for the client.
A sandbox token alone cannot manage volumes.

A configured node root token can
[register backing paths](sandboxd-api.md#put-v1volumesname) without editing
static config. Registration persists only node-local catalog metadata and does
not manage disk files. Runtime `tenants` lists follow the
[access-list rule](sandboxd-api.md#volume-access-lists). Live changes remain
limited to mounted read-only entries.

Pause application reads before replacement. Close files, mmap mappings, cwd,
and bind mount references on the old disk. Replacement unmounts the old disk
before mounting the new one, so the path has a short unavailable interval.
Expand Down
87 changes: 84 additions & 3 deletions docs/sandboxd-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ All bodies are JSON. Three token kinds:
preview mint) and the tenant-scoped listings/deletes below; everything a
tenant creates is stamped with its name. Operator surfaces
(the per-id sandbox reads, `PUT /v1/sandboxes/{id}/instance-metadata`, `GET /v1/info`,
`PUT /v1/pools`, `POST/DELETE /v1/drain`, `GET /metrics`,
`GET /v1/checkpoints/{id}/blob`)
`PUT /v1/pools`, `PUT/DELETE /v1/volumes/{name}`, `POST/DELETE /v1/drain`,
`GET /metrics`, `GET /v1/checkpoints/{id}/blob`)
answer a tenant token `403` — authenticated but not authorized; an unknown
token stays `401`. On a node with a shared
[`meta_store`](deploy.md#shared-tenant-database), a tenant token the node
Expand Down Expand Up @@ -255,6 +255,86 @@ list; the field is emitted (as `true`) only for a writable entry this node
declares and omitted otherwise (peer-only rows never carry it), so a read-only
entry's response is byte-identical to v1.

## PUT /v1/volumes/{name}

Requires the configured root API token. A tenant token or a node without an
`api_token` returns 403. Sandbox, unknown, and missing tokens return 401 when
`api_token` is configured. The URL supplies the name. Unknown body fields,
duplicate fields, and trailing JSON return 400.

```json
{"path": "/srv/datasets/data.img", "directio": "off",
"writable": false, "tenants": ["acme"]}
```

- `name` matches `[a-z][a-z0-9_-]{0,19}` and cannot start with `cocoon-`.
- `path` uses static config's absolute-path validation. When the node sets
[`volume_roots`](deploy.md#configuration), the cleaned path must equal or sit
under one of them. Registration does not open the backing or check its type
or availability. A missing backing keeps its registration but cannot be
attached.
- `directio` is `on`, `off`, or `auto`, and defaults to `off`.
- `writable` defaults to `false` and controls writable claim admission.
- `tenants` is the entry's [access list](#volume-access-lists).

Success is 204. An identical normalized registration is idempotent. A different
binding, changed fields, or a static name or cleaned path collision returns 409.
Invalid fields return 400. GET keeps its existing projection without host paths
or tenant lists. Runtime entries support the existing live volume API.

Persistence uncertainty returns 503 with `Retry-After`. An accepted change may
complete through background retry or a later PUT or DELETE for any name.
A 503 can also precede acceptance. Retry the exact request after repairing
storage to establish completion. Cancellation does not undo an accepted write.
Until persistence succeeds, new admission refuses the affected name while
unrelated published names remain usable. Every catalog writer retries the
accepted change before accepting another change.

The node stores metadata in `data_dir/runtime-volumes.json` independently of
`sync_claims` and loads it before claim recovery. New records use mode 0600.
Malformed or conflicting metadata prevents startup. Registration creates,
grows, and deletes no backing disks. The catalog is node-local with one
sandboxd writer.

The operator or controller owns each backing: supply an image Cocoon supports,
keep the path and its symlink target stable while registered, coordinate
symlink and hardlink aliases, cross-node ownership, and availability, and keep
backings outside daemon cleanup directories such as `<data_dir>/readers`,
`<data_dir>/goldens`, and `<data_dir>/fork-*`.

### Volume access lists

Static and runtime entries share one `tenants` rule. An omitted or empty list
permits every authenticated scope. A nonempty list permits only claims owned
by the listed tenants, and root-owned claims can use any entry. Live additions
check the claim owner's list even when root sends the request; held names
finish repair or removal without a new grant, and a recovery-only reference
grants no fresh attachment. Names use the tenant API syntax: runtime
registration rejects an invalid name, while static config logs a warning and
skips it, so a nonempty static list with no valid names permits no tenant. A
valid name for a tenant that does not exist yet grants nothing until the
tenant is created.

## DELETE /v1/volumes/{name}

Auth and name validation match PUT. Deletes only runtime registration metadata.
Success, including an absent unreferenced name, is 204. Static names and names
referenced by a claim, an in-flight operation, cleanup, or an unacknowledged
recovery snapshot return 409. Persistence uncertainty returns 503 with
`Retry-After` and the same retry semantics as PUT. A failed DELETE can complete
later. An empty GET view does not establish that recovery references retired.

Volume claims and registrations also return 503 with `Retry-After` while the
node removes a leftover VM whose attached volumes it cannot determine,
including one cocoon reports `stale` because it only inferred the stop from a
lost PID file. Any other stopped leftover VM holds none, which assumes sandboxd
controls VM starts and disk attachments in its namespace. Volume-free claims
are unaffected.

Before downgrading to a daemon without runtime registration, drain runtime
references or supply equivalent static volume config and finish pending live
volume changes.

## GET /v1/sandboxes/{id}/volumes

Returns the persisted desired set (`volumes_want`), confirmed applied set
Expand All @@ -277,7 +357,8 @@ when the volume is in both sets, `pending` when desired but not yet confirmed,
or `detaching` when applied but no longer desired. An interrupted device call
can leave the actual guest state different from the confirmed set. A failed
PUT or PATCH adds a top-level `error` naming the refusal, and the affected
state may carry its own `error`. `converging` is true only while a request worker is running.
state may carry its own `error`. `converging` is true while a request worker
is running or a recovery-only reference awaits an acknowledged claim write.
`pending` with `converging: false` requires an explicit retry; there is no
background convergence. GET retains the last safe per-volume error for the
current daemon lifetime. After daemon restart, unfinished states report an
Expand Down
26 changes: 18 additions & 8 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,15 +94,25 @@ rather than confirming existence, and operator surfaces answer tenants 403.

The volume catalog is an operator-owned data boundary. A volume name and its
access list must mean the same thing fleet-wide, although membership is
node-local. An empty entry `tenants` list permits every authenticated scope; a
nonempty list permits only those named tenants, while the root token always
has access. Config load rejects an access-list name that is not a configured
tenant. Claim lookup returns byte-identical errors for an unknown and a
forbidden name, and catalog discovery filters before replying, so a tenant
cannot enumerate restricted entries by probing. Gossip carries names only.
node-local. Entry `tenants` lists follow the [access-list
rule](sandboxd-api.md#volume-access-lists). Claim lookup returns byte-identical
errors for an unknown and a forbidden name, and catalog discovery filters before
replying, so a tenant cannot enumerate restricted entries by probing. Gossip
carries names only.
Neither gossip, discovery, persisted claims, usage events, nor the sandbox index
exposes host image paths or access lists.

Runtime registration and unregistration require a configured root `api_token`;
these endpoints return 403 when the token is unset. New catalog records use
mode 0600. Reads and replacements use the daemon's filesystem permissions.
Registration uses static configuration's path validation plus, when set, the
[`volume_roots`](deploy.md#configuration) bound. It does not verify backing type
or pin backing identity; the operator duties this leaves are listed under
[PUT /v1/volumes/{name}](sandboxd-api.md#put-v1volumesname). Without
`volume_roots` the root token can register any absolute host path, writable if
requested, for every scope its [access list](sandboxd-api.md#volume-access-lists)
permits. Protect the root token like host root, or set `volume_roots`.

Read-only is integrity protection for the shared image, not confidentiality.
Mounting a dataset into an egress-lane sandbox gives that sandbox an export path
to every destination its tenant egress policy permits; review the volume access
Expand All @@ -116,8 +126,8 @@ immutable and publish a new name/path for new content.

Changing a running claim's volumes needs the root token or the owning tenant's
API token. A sandbox token cannot, so a guest holding its own sandbox token
cannot attach another dataset, and every addition passes the same access list
as a claim.
cannot attach another dataset, and every addition passes the same
[access list](sandboxd-api.md#volume-access-lists) as a claim.

A writable entry (`writable: true`) adds a channel the read-only model does
not have: whichever tenant is permitted to claim it `rw` changes what every
Expand Down
74 changes: 74 additions & 0 deletions e2e/volume_catalog_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package e2e

import (
"encoding/json/v2"
"net/http"
"slices"
"testing"

"github.com/cocoonstack/sandbox/sandboxd/config"
"github.com/cocoonstack/sandbox/sandboxd/tenants"
sandbox "github.com/cocoonstack/sandbox/sdk/go"
)

func TestRuntimeVolumeRegistrationThroughHTTP(t *testing.T) {
st := startTenantStack(t, "node-token", []tenants.TenantSpec{
{Name: "acme", Token: "acme-token"}, {Name: "beta", Token: "beta-token"},
}, []config.VolumeSpec{{Name: "public", Path: writeVolumeImage(t, "public.img", "shared")}})
for name, body := range map[string]map[string]any{
"home": {"path": writeVolumeImage(t, "home.img", "shared"), "writable": true},
"private": {"path": writeVolumeImage(t, "private.img", "private"), "tenants": []string{"acme"}},
} {
raw, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
if status, resp := rawJSON(t, st, http.MethodPut, "/v1/volumes/"+name, string(raw)); status != http.StatusNoContent {
t.Fatalf("register %s: status=%d body=%s", name, status, resp)
}
}
home := sandbox.Volume{Name: "home", Mount: "/mnt/home"}
for _, tc := range []struct {
token string
want []string
private bool
}{{"acme-token", []string{"home", "private", "public"}, true}, {"beta-token", []string{"home", "public"}, false}} {
client, err := sandbox.Connect(st.addr, sandbox.WithAPIToken(tc.token))
if err != nil {
t.Fatal(err)
}
volumes, err := client.Volumes(t.Context())
names := make([]string, 0, len(volumes))
for _, volume := range volumes {
names = append(names, volume.Name)
}
if err != nil || !slices.Equal(names, tc.want) {
t.Fatalf("catalog for %s: names=%v want=%v err=%v", tc.token, names, tc.want, err)
}
claim, err := client.New(t.Context(), "rt:24.04", sandbox.WithVolumes(home))
if err != nil || !slices.Equal(claim.Volumes, []sandbox.Volume{home}) {
t.Fatalf("claim with public runtime volume: %+v, %v", claim, err)
}
defer claim.Close()
if _, err = claim.SetVolumes(t.Context(), []sandbox.Volume{}); err != nil {
t.Fatal(err)
}
view, err := claim.PatchVolumes(t.Context(), sandbox.VolumePatch{Add: []sandbox.Volume{home, {Name: "private"}}})
if (err == nil) != tc.private || tc.private && !slices.Contains(view.Volumes, home) {
t.Fatalf("live attach for %s: view=%+v err=%v", tc.token, view, err)
}
}
caller := *st
caller.token = "beta-token"
var responses []string
for _, name := range []string{"private", "unknown"} {
status, body := rawJSON(t, &caller, http.MethodPost, "/v1/claim", `{"template":"rt:24.04","volumes":[{"name":"`+name+`"}]}`)
if status != http.StatusBadRequest {
t.Fatalf("claim privacy for %s: status=%d body=%s", name, status, body)
}
responses = append(responses, string(body))
}
if responses[0] != responses[1] {
t.Fatalf("forbidden and unknown responses differ: %s versus %s", responses[0], responses[1])
}
}
Loading
Loading