39c407f2
volumes: durable block storage a VM attaches at create
a73x 2026-09-05 17:43
Commit message
RETRO.md
| Old | New | ||
|---|---|---|---|
| @@ -29,6 +29,10 @@ One line per push to `main`: what slowed the work down. Enforced by | |||
| 29 | a tree that no longer existed. Proving the rest was comments-only needed a | 29 | a tree that no longer existed. Proving the rest was comments-only needed a |
| 30 | purpose-built token-stream comparator; go/printer reflows, so a diff that | 30 | purpose-built token-stream comparator; go/printer reflows, so a diff that |
| 31 | round-trips through it reads as changed when nothing was. | 31 | round-trips through it reads as changed when nothing was. |
| 32 | - Volumes: the plan specified a schema FK (`volumes.host_id → hosts`) that | ||
| 33 | broke host removal, and a 120s smoke cleanup window against a 5-minute reap | ||
| 34 | grace; both were only caught by review against the live contracts, not by | ||
| 35 | the task tests. | ||
| 32 | 36 | ||
| 33 | ## 2026-08-23 | 37 | ## 2026-08-23 |
| 34 | 38 | ||
docs/assumptions.md
| Old | New | ||
|---|---|---|---|
| @@ -731,3 +731,15 @@ document the first time the part handler reads it and the stripped result is | |||
| 731 | what gets written, so the later user-data-versus-vendor-data merge never sees | 731 | what gets written, so the later user-data-versus-vendor-data merge never sees |
| 732 | one and its first-wins default stands. Vendor-data was dropped from the seed | 732 | one and its first-wins default stands. Vendor-data was dropped from the seed |
| 733 | entirely rather than left as a second mechanism. | 733 | entirely rather than left as a second mechanism. |
| 734 | |||
| 735 | ### A volume is exactly as durable as one host's disk | ||
| 736 | |||
| 737 | A volume is a raw sparse file on the host that first materialized it. There is | ||
| 738 | no replication, no snapshot, and no migration; `e698610c` (VMs cannot move | ||
| 739 | between hosts) now also means volumes cannot. A host that loses its disk loses | ||
| 740 | every volume on it. Force-removing a host is the one deliberate path that takes | ||
| 741 | its volumes with it — their claims return to pending rather than staying bound | ||
| 742 | to a host that is gone. | ||
| 743 | **Falsifier**: a volume readable from any host other than the one it was | ||
| 744 | created on. | ||
| 745 | |||
docs/openapi.json
| Old | New | ||
|---|---|---|---|
| @@ -151,6 +151,12 @@ | |||
| 151 | }, | 151 | }, |
| 152 | "vcpus": { | 152 | "vcpus": { |
| 153 | "type": "integer" | 153 | "type": "integer" |
| 154 | }, | ||
| 155 | "volume_claims": { | ||
| 156 | "items": { | ||
| 157 | "type": "string" | ||
| 158 | }, | ||
| 159 | "type": "array" | ||
| 154 | } | 160 | } |
| 155 | }, | 161 | }, |
| 156 | "type": "object" | 162 | "type": "object" |
| @@ -170,6 +176,17 @@ | |||
| 170 | ], | 176 | ], |
| 171 | "type": "object" | 177 | "type": "object" |
| 172 | }, | 178 | }, |
| 179 | "CreateVolumeClaimRequest": { | ||
| 180 | "properties": { | ||
| 181 | "name": { | ||
| 182 | "type": "string" | ||
| 183 | }, | ||
| 184 | "size_gb": { | ||
| 185 | "type": "integer" | ||
| 186 | } | ||
| 187 | }, | ||
| 188 | "type": "object" | ||
| 189 | }, | ||
| 173 | "Delegation": { | 190 | "Delegation": { |
| 174 | "properties": { | 191 | "properties": { |
| 175 | "ca_fingerprint": { | 192 | "ca_fingerprint": { |
| @@ -862,6 +879,48 @@ | |||
| 862 | "vcpus" | 879 | "vcpus" |
| 863 | ], | 880 | ], |
| 864 | "type": "object" | 881 | "type": "object" |
| 882 | }, | ||
| 883 | "VolumeClaim": { | ||
| 884 | "properties": { | ||
| 885 | "created_at": { | ||
| 886 | "format": "date-time", | ||
| 887 | "type": "string" | ||
| 888 | }, | ||
| 889 | "host_id": { | ||
| 890 | "type": "string" | ||
| 891 | }, | ||
| 892 | "id": { | ||
| 893 | "type": "string" | ||
| 894 | }, | ||
| 895 | "name": { | ||
| 896 | "type": "string" | ||
| 897 | }, | ||
| 898 | "present": { | ||
| 899 | "type": [ | ||
| 900 | "boolean", | ||
| 901 | "null" | ||
| 902 | ] | ||
| 903 | }, | ||
| 904 | "size_gb": { | ||
| 905 | "type": "integer" | ||
| 906 | }, | ||
| 907 | "status": { | ||
| 908 | "type": "string" | ||
| 909 | }, | ||
| 910 | "vm_id": { | ||
| 911 | "type": "string" | ||
| 912 | } | ||
| 913 | }, | ||
| 914 | "required": [ | ||
| 915 | "created_at", | ||
| 916 | "host_id", | ||
| 917 | "id", | ||
| 918 | "name", | ||
| 919 | "size_gb", | ||
| 920 | "status", | ||
| 921 | "vm_id" | ||
| 922 | ], | ||
| 923 | "type": "object" | ||
| 865 | } | 924 | } |
| 866 | }, | 925 | }, |
| 867 | "securitySchemes": { | 926 | "securitySchemes": { |
| @@ -2198,6 +2257,156 @@ | |||
| 2198 | ], | 2257 | ], |
| 2199 | "summary": "Un-tombstone a VM still within the teardown grace window; the agent re-adopts the guest." | 2258 | "summary": "Un-tombstone a VM still within the teardown grace window; the agent re-adopts the guest." |
| 2200 | } | 2259 | } |
| 2260 | }, | ||
| 2261 | "/api/v1/volume-claims": { | ||
| 2262 | "get": { | ||
| 2263 | "responses": { | ||
| 2264 | "200": { | ||
| 2265 | "content": { | ||
| 2266 | "application/json": { | ||
| 2267 | "schema": { | ||
| 2268 | "items": { | ||
| 2269 | "$ref": "#/components/schemas/VolumeClaim" | ||
| 2270 | }, | ||
| 2271 | "type": "array" | ||
| 2272 | } | ||
| 2273 | } | ||
| 2274 | }, | ||
| 2275 | "description": "success" | ||
| 2276 | }, | ||
| 2277 | "default": { | ||
| 2278 | "content": { | ||
| 2279 | "text/plain": { | ||
| 2280 | "schema": { | ||
| 2281 | "type": "string" | ||
| 2282 | } | ||
| 2283 | } | ||
| 2284 | }, | ||
| 2285 | "description": "error (plain text)" | ||
| 2286 | } | ||
| 2287 | }, | ||
| 2288 | "security": [ | ||
| 2289 | { | ||
| 2290 | "patToken": [] | ||
| 2291 | } | ||
| 2292 | ], | ||
| 2293 | "summary": "List the tenant's claims: where each is bound, which VM holds it, and whether its host has the file." | ||
| 2294 | }, | ||
| 2295 | "post": { | ||
| 2296 | "requestBody": { | ||
| 2297 | "content": { | ||
| 2298 | "application/json": { | ||
| 2299 | "schema": { | ||
| 2300 | "$ref": "#/components/schemas/CreateVolumeClaimRequest" | ||
| 2301 | } | ||
| 2302 | } | ||
| 2303 | }, | ||
| 2304 | "required": true | ||
| 2305 | }, | ||
| 2306 | "responses": { | ||
| 2307 | "201": { | ||
| 2308 | "content": { | ||
| 2309 | "application/json": { | ||
| 2310 | "schema": { | ||
| 2311 | "$ref": "#/components/schemas/VolumeClaim" | ||
| 2312 | } | ||
| 2313 | } | ||
| 2314 | }, | ||
| 2315 | "description": "success" | ||
| 2316 | }, | ||
| 2317 | "default": { | ||
| 2318 | "content": { | ||
| 2319 | "text/plain": { | ||
| 2320 | "schema": { | ||
| 2321 | "type": "string" | ||
| 2322 | } | ||
| 2323 | } | ||
| 2324 | }, | ||
| 2325 | "description": "error (plain text)" | ||
| 2326 | } | ||
| 2327 | }, | ||
| 2328 | "security": [ | ||
| 2329 | { | ||
| 2330 | "patToken": [] | ||
| 2331 | } | ||
| 2332 | ], | ||
| 2333 | "summary": "Claim durable storage. Pending until the first VM naming it is created; that VM's host then holds the bytes, and every later VM using the claim is placed there." | ||
| 2334 | } | ||
| 2335 | }, | ||
| 2336 | "/api/v1/volume-claims/{id}": { | ||
| 2337 | "delete": { | ||
| 2338 | "parameters": [ | ||
| 2339 | { | ||
| 2340 | "in": "path", | ||
| 2341 | "name": "id", | ||
| 2342 | "required": true, | ||
| 2343 | "schema": { | ||
| 2344 | "type": "string" | ||
| 2345 | } | ||
| 2346 | } | ||
| 2347 | ], | ||
| 2348 | "responses": { | ||
| 2349 | "204": { | ||
| 2350 | "description": "success" | ||
| 2351 | }, | ||
| 2352 | "default": { | ||
| 2353 | "content": { | ||
| 2354 | "text/plain": { | ||
| 2355 | "schema": { | ||
| 2356 | "type": "string" | ||
| 2357 | } | ||
| 2358 | } | ||
| 2359 | }, | ||
| 2360 | "description": "error (plain text)" | ||
| 2361 | } | ||
| 2362 | }, | ||
| 2363 | "security": [ | ||
| 2364 | { | ||
| 2365 | "patToken": [] | ||
| 2366 | } | ||
| 2367 | ], | ||
| 2368 | "summary": "Delete a claim and the data behind it. Refused (409) while a VM holds it." | ||
| 2369 | }, | ||
| 2370 | "get": { | ||
| 2371 | "parameters": [ | ||
| 2372 | { | ||
| 2373 | "in": "path", | ||
| 2374 | "name": "id", | ||
| 2375 | "required": true, | ||
| 2376 | "schema": { | ||
| 2377 | "type": "string" | ||
| 2378 | } | ||
| 2379 | } | ||
| 2380 | ], | ||
| 2381 | "responses": { | ||
| 2382 | "200": { | ||
| 2383 | "content": { | ||
| 2384 | "application/json": { | ||
| 2385 | "schema": { | ||
| 2386 | "$ref": "#/components/schemas/VolumeClaim" | ||
| 2387 | } | ||
| 2388 | } | ||
| 2389 | }, | ||
| 2390 | "description": "success" | ||
| 2391 | }, | ||
| 2392 | "default": { | ||
| 2393 | "content": { | ||
| 2394 | "text/plain": { | ||
| 2395 | "schema": { | ||
| 2396 | "type": "string" | ||
| 2397 | } | ||
| 2398 | } | ||
| 2399 | }, | ||
| 2400 | "description": "error (plain text)" | ||
| 2401 | } | ||
| 2402 | }, | ||
| 2403 | "security": [ | ||
| 2404 | { | ||
| 2405 | "patToken": [] | ||
| 2406 | } | ||
| 2407 | ], | ||
| 2408 | "summary": "One claim." | ||
| 2409 | } | ||
| 2201 | } | 2410 | } |
| 2202 | } | 2411 | } |
| 2203 | } | 2412 | } |
docs/shape.html
| Old | New | ||
|---|---|---|---|
| @@ -236,6 +236,7 @@ | |||
| 236 | "plane": "data", | 236 | "plane": "data", |
| 237 | "synopsis": "Package reconcile implements the agent's level-triggered reconcile loop.", | 237 | "synopsis": "Package reconcile implements the agent's level-triggered reconcile loop.", |
| 238 | "imports": [ | 238 | "imports": [ |
| 239 | "internal/agent/permanent", | ||
| 239 | "internal/agent/seed", | 240 | "internal/agent/seed", |
| 240 | "internal/agent/state", | 241 | "internal/agent/state", |
| 241 | "internal/pb", | 242 | "internal/pb", |
| @@ -581,6 +582,7 @@ | |||
| 581 | "internal/server/hosttoken", | 582 | "internal/server/hosttoken", |
| 582 | "internal/server/hub", | 583 | "internal/server/hub", |
| 583 | "internal/server/registry", | 584 | "internal/server/registry", |
| 585 | "internal/server/release", | ||
| 584 | "internal/server/store", | 586 | "internal/server/store", |
| 585 | "internal/transport" | 587 | "internal/transport" |
| 586 | ] | 588 | ] |
docs/shape.json
| Old | New | ||
|---|---|---|---|
| @@ -185,6 +185,7 @@ | |||
| 185 | "plane": "data", | 185 | "plane": "data", |
| 186 | "synopsis": "Package reconcile implements the agent's level-triggered reconcile loop.", | 186 | "synopsis": "Package reconcile implements the agent's level-triggered reconcile loop.", |
| 187 | "imports": [ | 187 | "imports": [ |
| 188 | "internal/agent/permanent", | ||
| 188 | "internal/agent/seed", | 189 | "internal/agent/seed", |
| 189 | "internal/agent/state", | 190 | "internal/agent/state", |
| 190 | "internal/pb", | 191 | "internal/pb", |
| @@ -530,6 +531,7 @@ | |||
| 530 | "internal/server/hosttoken", | 531 | "internal/server/hosttoken", |
| 531 | "internal/server/hub", | 532 | "internal/server/hub", |
| 532 | "internal/server/registry", | 533 | "internal/server/registry", |
| 534 | "internal/server/release", | ||
| 533 | "internal/server/store", | 535 | "internal/server/store", |
| 534 | "internal/transport" | 536 | "internal/transport" |
| 535 | ] | 537 | ] |
docs/volumes.md
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,63 @@ | |||
| 1 | <!-- DRAFT: voice not yet passed by the author --> | ||
| 2 | # Volumes | ||
| 3 | |||
| 4 | *Durable block storage that outlives the VM it is attached to* | ||
| 5 | |||
| 6 | A guest's root disk dies with the VM. A volume is where state survives past | ||
| 7 | `DELETE /vms/{id}` — a checked-out repo, a database, a build cache. | ||
| 8 | |||
| 9 | ## Claim storage | ||
| 10 | |||
| 11 | POST /api/v1/volume-claims {"name": "...", "size_gb": ...} | ||
| 12 | |||
| 13 | Returns a claim, status `pending`. | ||
| 14 | |||
| 15 | ## Attach at create | ||
| 16 | |||
| 17 | Name the claim on `POST /api/v1/vms`: | ||
| 18 | |||
| 19 | POST /api/v1/vms {"volume_claims": ["<name or id>", ...]} | ||
| 20 | |||
| 21 | The first VM naming a pending claim binds it to that VM's host. Every later | ||
| 22 | VM using the same claim must be placed on that host — naming a different one | ||
| 23 | is refused with 409. | ||
| 24 | |||
| 25 | ## In the guest | ||
| 26 | |||
| 27 | Devices come in a fixed order: root is `/dev/vda`, the cloud-init seed is | ||
| 28 | `/dev/vdb`, the first volume is `/dev/vdc`, then the rest in the order named. | ||
| 29 | |||
| 30 | A volume is a raw block device. The guest partitions, formats and mounts it — | ||
| 31 | eitri never touches the bytes: | ||
| 32 | |||
| 33 | ```sh | ||
| 34 | sudo mkfs.ext4 /dev/vdc && sudo mount /dev/vdc /mnt | ||
| 35 | ``` | ||
| 36 | |||
| 37 | ## Lifecycle | ||
| 38 | |||
| 39 | Deleting the VM frees the claim once the VM is reaped; the data stays, and | ||
| 40 | the next VM naming the claim sees it. | ||
| 41 | |||
| 42 | GET /api/v1/volume-claims | ||
| 43 | |||
| 44 | Shows each claim's status, `host_id`, `vm_id` and `present`. | ||
| 45 | |||
| 46 | DELETE /api/v1/volume-claims/{id} | ||
| 47 | |||
| 48 | Deletes the claim and its data. Refused with 409 while a VM holds it. Size is | ||
| 49 | fixed once a claim is bound. | ||
| 50 | |||
| 51 | ## Hosts | ||
| 52 | |||
| 53 | A host cannot be removed while it holds volumes — delete their claims first. | ||
| 54 | Force-removing a host destroys its volumes and returns their claims to | ||
| 55 | pending. | ||
| 56 | |||
| 57 | Volumes need an agent at or above `v0.0.8-pre.1`. An older agent's host | ||
| 58 | refuses a volume-bearing create with an upgrade link. | ||
| 59 | |||
| 60 | ## Related | ||
| 61 | |||
| 62 | - [quickstart](quickstart.md) | ||
| 63 | - [networking](networking.md) | ||
internal/agent/cloudhv/cloudhv.go
| Old | New | ||
|---|---|---|---|
| @@ -124,13 +124,19 @@ func BootstrapDest(chBin string) string { | |||
| 124 | return filepath.Join("/usr/local/bin", chBin) | 124 | return filepath.Join("/usr/local/bin", chBin) |
| 125 | } | 125 | } |
| 126 | 126 | ||
| 127 | // disks returns the VM's block devices in attachment order: the root disk | 127 | // disks returns the VM's block devices in attachment order: the root disk, the |
| 128 | // first, then the read-only cloud-init seed. User-attached volumes append here. | 128 | // read-only cloud-init seed, then every volume in spec order — so the first |
| 129 | // volume the tenant attached is /dev/vdc, and stays /dev/vdc across reboots. | ||
| 130 | // Volumes are writable: the guest owns what is on them. | ||
| 129 | func (p *Provisioner) disks(spec state.VMSpec) []state.Disk { | 131 | func (p *Provisioner) disks(spec state.VMSpec) []state.Disk { |
| 130 | return []state.Disk{ | 132 | out := []state.Disk{ |
| 131 | {Path: p.st.DiskPath(spec.VMID)}, | 133 | {Path: p.st.DiskPath(spec.VMID)}, |
| 132 | {Path: p.st.SeedPath(spec.VMID), ReadOnly: true}, | 134 | {Path: p.st.SeedPath(spec.VMID), ReadOnly: true}, |
| 133 | } | 135 | } |
| 136 | for _, id := range spec.VolumeIDs { | ||
| 137 | out = append(out, state.Disk{Path: p.st.VolumePath(id)}) | ||
| 138 | } | ||
| 139 | return out | ||
| 134 | } | 140 | } |
| 135 | 141 | ||
| 136 | // buildArgs returns the cloud-hypervisor command-line arguments for spec. | 142 | // buildArgs returns the cloud-hypervisor command-line arguments for spec. |
internal/agent/cloudhv/cloudhv_test.go
| Old | New | ||
|---|---|---|---|
| @@ -75,10 +75,14 @@ func TestBuildArgsDeclaresRawImageType(t *testing.T) { | |||
| 75 | st, err := state.Open(t.TempDir()) | 75 | st, err := state.Open(t.TempDir()) |
| 76 | require.NoError(t, err) | 76 | require.NoError(t, err) |
| 77 | p := New(st, "ch", "fw", nil, newFakeNet()) | 77 | p := New(st, "ch", "fw", nil, newFakeNet()) |
| 78 | args := p.buildArgs(state.VMSpec{VMID: "vm1", VCPUs: 1, MemMB: 512}) | 78 | args := p.buildArgs(state.VMSpec{VMID: "vm1", VCPUs: 1, MemMB: 512, VolumeIDs: []string{"vb"}}) |
| 79 | joined := strings.Join(args, " ") | 79 | joined := strings.Join(args, " ") |
| 80 | assert.Contains(t, joined, st.DiskPath("vm1")+",image_type=raw") | 80 | assert.Contains(t, joined, st.DiskPath("vm1")+",image_type=raw") |
| 81 | assert.Contains(t, joined, st.SeedPath("vm1")+",image_type=raw,readonly=on") | 81 | assert.Contains(t, joined, st.SeedPath("vm1")+",image_type=raw,readonly=on") |
| 82 | // A volume is a raw file like the others, and writable: the sector-0 trap | ||
| 83 | // costs a volume its partition table just as surely as it costs the root disk. | ||
| 84 | assert.Contains(t, joined, "path="+st.VolumePath("vb")+",image_type=raw") | ||
| 85 | assert.NotContains(t, joined, st.VolumePath("vb")+",image_type=raw,readonly=on") | ||
| 82 | } | 86 | } |
| 83 | 87 | ||
| 84 | // TestDisksPutsRootFirstAndSeedReadOnly pins the attachment order: index 0 | 88 | // TestDisksPutsRootFirstAndSeedReadOnly pins the attachment order: index 0 |
| @@ -111,6 +115,25 @@ func TestDisksPutsRootFirstAndSeedReadOnly(t *testing.T) { | |||
| 111 | } | 115 | } |
| 112 | } | 116 | } |
| 113 | 117 | ||
| 118 | // TestDisksAppendVolumesAfterSeedInOrder pins the rest of the guest ABI: a | ||
| 119 | // volume never displaces the root disk or the seed, and the spec's order is the | ||
| 120 | // device order, so the first volume a tenant attached is /dev/vdc on every boot. | ||
| 121 | func TestDisksAppendVolumesAfterSeedInOrder(t *testing.T) { | ||
| 122 | st, err := state.Open(t.TempDir()) | ||
| 123 | require.NoError(t, err) | ||
| 124 | p := New(st, "cloud-hypervisor", "/fw/CLOUDHV.fd", nil, newFakeNet()) | ||
| 125 | |||
| 126 | disks := p.disks(state.VMSpec{VMID: "vm-1", VolumeIDs: []string{"vb", "va"}}) | ||
| 127 | |||
| 128 | require.Len(t, disks, 4) | ||
| 129 | assert.Equal(t, st.DiskPath("vm-1"), disks[0].Path) | ||
| 130 | assert.Equal(t, st.SeedPath("vm-1"), disks[1].Path) | ||
| 131 | assert.Equal(t, st.VolumePath("vb"), disks[2].Path, "spec order, not sorted: the first volume is /dev/vdc") | ||
| 132 | assert.Equal(t, st.VolumePath("va"), disks[3].Path) | ||
| 133 | assert.False(t, disks[2].ReadOnly, "a volume is the guest's to write to") | ||
| 134 | assert.False(t, disks[3].ReadOnly) | ||
| 135 | } | ||
| 136 | |||
| 114 | func TestBuildArgsUsesSerialSocket(t *testing.T) { | 137 | func TestBuildArgsUsesSerialSocket(t *testing.T) { |
| 115 | st, err := state.Open(t.TempDir()) | 138 | st, err := state.Open(t.TempDir()) |
| 116 | require.NoError(t, err) | 139 | require.NoError(t, err) |
internal/agent/reconcile/reconcile.go
| Old | New | ||
|---|---|---|---|
| @@ -22,6 +22,7 @@ import ( | |||
| 22 | "errors" | 22 | "errors" |
| 23 | "fmt" | 23 | "fmt" |
| 24 | "log/slog" | 24 | "log/slog" |
| 25 | "slices" | ||
| 25 | "strings" | 26 | "strings" |
| 26 | "sync" | 27 | "sync" |
| 27 | "time" | 28 | "time" |
| @@ -267,6 +268,13 @@ func (e *Engine) Step(ctx context.Context, snap *pb.Snapshot) *pb.Report { | |||
| 267 | return e.refuseSnapshot(snap, floor) | 268 | return e.refuseSnapshot(snap, floor) |
| 268 | } | 269 | } |
| 269 | 270 | ||
| 271 | // ── 3. Volumes ─────────────────────────────────────────────────────────── | ||
| 272 | // Synchronously, and BEFORE dispatch: a VM's volumes must be files by the | ||
| 273 | // time its backend is handed them, so a VM and its volumes arriving in the | ||
| 274 | // same snapshot boot on the first try rather than the second. Cheap enough | ||
| 275 | // to stay on the heartbeat's path — a sparse truncate, no copy. | ||
| 276 | volumes := e.reconcileVolumes(snap) | ||
| 277 | |||
| 270 | // One record scan serves the whole tick: dispatch slices it into assignments | 278 | // One record scan serves the whole tick: dispatch slices it into assignments |
| 271 | // and aggregate acks destroys against it. Reading it twice cost a second | 279 | // and aggregate acks destroys against it. Reading it twice cost a second |
| 272 | // full state-dir scan on the one blocking path in Step, microseconds after | 280 | // full state-dir scan on the one blocking path in Step, microseconds after |
| @@ -278,7 +286,7 @@ func (e *Engine) Step(ctx context.Context, snap *pb.Snapshot) *pb.Report { | |||
| 278 | // ctx would cancel them the instant the report went out. VMTimeout bounds | 286 | // ctx would cancel them the instant the report went out. VMTimeout bounds |
| 279 | // each pass instead (see reconcileOne). | 287 | // each pass instead (see reconcileOne). |
| 280 | e.dispatch(snap, recs) //nolint:contextcheck // a pass deliberately outlives its Step | 288 | e.dispatch(snap, recs) //nolint:contextcheck // a pass deliberately outlives its Step |
| 281 | return e.aggregate(snap.Epoch, recs) | 289 | return e.aggregate(snap.Epoch, recs, volumes) |
| 282 | } | 290 | } |
| 283 | 291 | ||
| 284 | // dispatch hands every VM its slice of this snapshot and reaps the workers for | 292 | // dispatch hands every VM its slice of this snapshot and reaps the workers for |
| @@ -312,8 +320,13 @@ func (e *Engine) dispatch(snap *pb.Snapshot, recs map[string]state.Record) { | |||
| 312 | // | 320 | // |
| 313 | // recs is the record view the destroy ack is computed against, with the caller | 321 | // recs is the record view the destroy ack is computed against, with the caller |
| 314 | // choosing how fresh it is (see ackDestroyed). | 322 | // choosing how fresh it is (see ackDestroyed). |
| 315 | func (e *Engine) aggregate(epoch uint64, recs map[string]state.Record) *pb.Report { | 323 | // |
| 316 | rep := &pb.Report{LastSeenEpoch: epoch} | 324 | // volumes is this tick's volume convergence, threaded in for the same reason |
| 325 | // epoch is: the work was done in Step, and re-deriving it here would converge | ||
| 326 | // the volumes a second time per report. Nil is a caller that converged none — | ||
| 327 | // the paths that report on state without acting on it. | ||
| 328 | func (e *Engine) aggregate(epoch uint64, recs map[string]state.Record, volumes []*pb.VolumeStatus) *pb.Report { | ||
| 329 | rep := &pb.Report{LastSeenEpoch: epoch, Volumes: volumes} | ||
| 317 | m := e.manager() | 330 | m := e.manager() |
| 318 | m.collect(rep) | 331 | m.collect(rep) |
| 319 | e.ackDestroyed(rep, m.tombstones(), recs) | 332 | e.ackDestroyed(rep, m.tombstones(), recs) |
| @@ -333,6 +346,15 @@ func (e *Engine) aggregate(epoch uint64, recs map[string]state.Record) *pb.Repor | |||
| 333 | // Tombstoned VMs are skipped: they are already deleted, nothing will act on | 346 | // Tombstoned VMs are skipped: they are already deleted, nothing will act on |
| 334 | // them again, and hanging an upgrade-the-agent error on a VM on its way out | 347 | // them again, and hanging an upgrade-the-agent error on a VM on its way out |
| 335 | // would leave a spurious failure the operator cannot clear. | 348 | // would leave a spurious failure the operator cannot clear. |
| 349 | // | ||
| 350 | // VOLUMES: this report deliberately carries none, and that is safe only | ||
| 351 | // because of who reaches it. The control plane reaps a tombstoned volume when | ||
| 352 | // a VOLUMES-CAPABLE agent's report omits it, and an agent refusing a snapshot | ||
| 353 | // is by definition below that snapshot's floor — which, today, any snapshot | ||
| 354 | // carrying volumes sets to the release that introduced them. If a later | ||
| 355 | // feature ever raises a floor above a version that already reports volumes, | ||
| 356 | // this refusal must sweep and report them (see Engine.reportVolumes) or the | ||
| 357 | // refusal will read upward as "every tombstoned volume on this host is gone". | ||
| 336 | func (e *Engine) refuseSnapshot(snap *pb.Snapshot, floor string) *pb.Report { | 358 | func (e *Engine) refuseSnapshot(snap *pb.Snapshot, floor string) *pb.Report { |
| 337 | rep := &pb.Report{LastSeenEpoch: snap.Epoch} | 359 | rep := &pb.Report{LastSeenEpoch: snap.Epoch} |
| 338 | reason := fmt.Sprintf("agent %s is below this snapshot's floor %s; upgrade the agent", e.AgentVersion, floor) | 360 | reason := fmt.Sprintf("agent %s is below this snapshot's floor %s; upgrade the agent", e.AgentVersion, floor) |
| @@ -346,10 +368,17 @@ func (e *Engine) refuseSnapshot(snap *pb.Snapshot, floor string) *pb.Report { | |||
| 346 | } | 368 | } |
| 347 | 369 | ||
| 348 | // fenceReport is the read-only report returned for a stale snapshot: current | 370 | // fenceReport is the read-only report returned for a stale snapshot: current |
| 349 | // actual state, derived entirely from persisted records, with no mutation and | 371 | // actual state, derived entirely from persisted records and a stat of the |
| 350 | // no dispatch. | 372 | // volumes directory, with no mutation and no dispatch. |
| 373 | // | ||
| 374 | // The volume sweep is a report, not a convergence: no file is made, no | ||
| 375 | // tombstone marker written, nothing reclaimed. It is here because a report | ||
| 376 | // that named no volumes would be read upward as this host having none — and | ||
| 377 | // the control plane reaps a tombstoned volume on exactly that silence. The | ||
| 378 | // server refuses to reap off a fenced report as well; the two halves are | ||
| 379 | // independent, and each is worth having on its own. | ||
| 351 | func (e *Engine) fenceReport(currentEpoch uint64) *pb.Report { | 380 | func (e *Engine) fenceReport(currentEpoch uint64) *pb.Report { |
| 352 | rep := &pb.Report{FenceViolation: true, LastSeenEpoch: currentEpoch} | 381 | rep := &pb.Report{FenceViolation: true, LastSeenEpoch: currentEpoch, Volumes: e.reportVolumes(nil)} |
| 353 | recs, err := e.St.LoadVMs() | 382 | recs, err := e.St.LoadVMs() |
| 354 | if err != nil { | 383 | if err != nil { |
| 355 | return rep | 384 | return rep |
| @@ -653,8 +682,9 @@ func (e *Engine) create(ctx context.Context, d *pb.VMSpec, rec state.Record, ok | |||
| 653 | 682 | ||
| 654 | spec := specFromWire(d) | 683 | spec := specFromWire(d) |
| 655 | 684 | ||
| 656 | // VMSpec is fully comparable (all fields are strings/ints/bool), so == is safe. | 685 | // VMSpec stopped being ==-comparable when it grew a volume list; Equal is |
| 657 | if ok && spec != rec.Spec { | 686 | // what == was, order of the volumes included. |
| 687 | if ok && !spec.Equal(rec.Spec) { | ||
| 658 | rec.CreateAttempts = 0 | 688 | rec.CreateAttempts = 0 |
| 659 | rec.LastError = "" | 689 | rec.LastError = "" |
| 660 | } | 690 | } |
| @@ -784,6 +814,11 @@ func (e *Engine) create(ctx context.Context, d *pb.VMSpec, rec state.Record, ok | |||
| 784 | 814 | ||
| 785 | // Boot if desired running. | 815 | // Boot if desired running. |
| 786 | if d.PowerState == "running" { | 816 | if d.PowerState == "running" { |
| 817 | if err := e.volumesMaterialized(rec.Spec); err != nil { | ||
| 818 | e.failCreate(ctx, rec, err, res) | ||
| 819 | return | ||
| 820 | } | ||
| 821 | |||
| 787 | say("booting") | 822 | say("booting") |
| 788 | if err := e.Prov.Boot(ctx, d.VmId, rec.Spec); err != nil { | 823 | if err := e.Prov.Boot(ctx, d.VmId, rec.Spec); err != nil { |
| 789 | e.failCreate(ctx, rec, err, res) | 824 | e.failCreate(ctx, rec, err, res) |
| @@ -805,12 +840,14 @@ func (e *Engine) create(ctx context.Context, d *pb.VMSpec, rec state.Record, ok | |||
| 805 | res.report(d.VmId, recAddrs(rec), powerState, "ready", "") | 840 | res.report(d.VmId, recAddrs(rec), powerState, "ready", "") |
| 806 | } | 841 | } |
| 807 | 842 | ||
| 808 | // permanent reports whether err (anywhere in its chain) carries the | 843 | // isPermanent reports whether err (anywhere in its chain) carries the |
| 809 | // consumer-owned permanence marker — the provisioner's way of saying no | 844 | // consumer-owned permanence marker — the provisioner's way of saying no |
| 810 | // retry can ever succeed (e.g. the disk-shrink guard). Consumer-side | 845 | // retry can ever succeed (e.g. the disk-shrink guard). Consumer-side |
| 811 | // interface per the R5 convention: reconcile declares it; the producers | 846 | // interface per the R5 convention: reconcile declares it; the producers |
| 812 | // (cloudhv, vfkit, imagecache, netenv) mint one via internal/agent/permanent. | 847 | // (cloudhv, vfkit, imagecache, netenv) mint one via internal/agent/permanent, |
| 813 | func permanent(err error) bool { | 848 | // as does this package's own volumesMaterialized, which knows a missing volume |
| 849 | // is not something this host can heal. | ||
| 850 | func isPermanent(err error) bool { | ||
| 814 | var p interface{ Permanent() bool } | 851 | var p interface{ Permanent() bool } |
| 815 | return errors.As(err, &p) && p.Permanent() | 852 | return errors.As(err, &p) && p.Permanent() |
| 816 | } | 853 | } |
| @@ -822,7 +859,7 @@ func permanent(err error) bool { | |||
| 822 | func (e *Engine) failCreate(ctx context.Context, rec state.Record, err error, res *vmResult) { | 859 | func (e *Engine) failCreate(ctx context.Context, rec state.Record, err error, res *vmResult) { |
| 823 | if ctx.Err() != nil { | 860 | if ctx.Err() != nil { |
| 824 | rec.CreateAttempts-- // refund: this VM's pass died mid-operation | 861 | rec.CreateAttempts-- // refund: this VM's pass died mid-operation |
| 825 | } else if permanent(err) { | 862 | } else if isPermanent(err) { |
| 826 | rec.CreateAttempts = e.MaxCreateAttempts // terminal now; retry cannot succeed | 863 | rec.CreateAttempts = e.MaxCreateAttempts // terminal now; retry cannot succeed |
| 827 | } | 864 | } |
| 828 | rec.LastError = err.Error() | 865 | rec.LastError = err.Error() |
| @@ -871,7 +908,11 @@ func (e *Engine) converge(ctx context.Context, d *pb.VMSpec, rec state.Record, r | |||
| 871 | if d.PowerState == "running" { | 908 | if d.PowerState == "running" { |
| 872 | // Restart: the backend re-attaches the VM to the host network as | 909 | // Restart: the backend re-attaches the VM to the host network as |
| 873 | // part of Boot, which is what rebuilds a tap that did not survive | 910 | // part of Boot, which is what rebuilds a tap that did not survive |
| 874 | // the host reboot. | 911 | // the host reboot. Its volumes have to survive too. |
| 912 | if err := e.volumesMaterialized(rec.Spec); err != nil { | ||
| 913 | e.failConverge(rec, err, res) | ||
| 914 | return | ||
| 915 | } | ||
| 875 | if err := e.Prov.Boot(ctx, d.VmId, rec.Spec); err != nil { | 916 | if err := e.Prov.Boot(ctx, d.VmId, rec.Spec); err != nil { |
| 876 | e.failConverge(rec, err, res) | 917 | e.failConverge(rec, err, res) |
| 877 | return | 918 | return |
| @@ -895,6 +936,10 @@ func (e *Engine) converge(ctx context.Context, d *pb.VMSpec, rec state.Record, r | |||
| 895 | // Not lost: drive power state. | 936 | // Not lost: drive power state. |
| 896 | if d.PowerState == "running" && !running { | 937 | if d.PowerState == "running" && !running { |
| 897 | // Start the VM. Boot re-attaches the network, idempotently. | 938 | // Start the VM. Boot re-attaches the network, idempotently. |
| 939 | if err := e.volumesMaterialized(rec.Spec); err != nil { | ||
| 940 | e.failConverge(rec, err, res) | ||
| 941 | return | ||
| 942 | } | ||
| 898 | if err := e.Prov.Boot(ctx, d.VmId, rec.Spec); err != nil { | 943 | if err := e.Prov.Boot(ctx, d.VmId, rec.Spec); err != nil { |
| 899 | e.failConverge(rec, err, res) | 944 | e.failConverge(rec, err, res) |
| 900 | return | 945 | return |
| @@ -1053,8 +1098,11 @@ func specFromWire(d *pb.VMSpec) state.VMSpec { | |||
| 1053 | CloudInit: d.CloudInit, | 1098 | CloudInit: d.CloudInit, |
| 1054 | SSHAuthorizedKey: d.SshAuthorizedKey, | 1099 | SSHAuthorizedKey: d.SshAuthorizedKey, |
| 1055 | Network: d.Network, | 1100 | Network: d.Network, |
| 1056 | VCPUs: d.Vcpus, | 1101 | // Cloned: the record outlives the snapshot it came from, and a worker |
| 1057 | MemMB: d.MemMb, | 1102 | // holds its assignment well past the Step that delivered it. |
| 1058 | DiskGB: d.DiskGb, | 1103 | VolumeIDs: slices.Clone(d.VolumeIds), |
| 1104 | VCPUs: d.Vcpus, | ||
| 1105 | MemMB: d.MemMb, | ||
| 1106 | DiskGB: d.DiskGb, | ||
| 1059 | } | 1107 | } |
| 1060 | } | 1108 | } |
internal/agent/reconcile/reconcile_test.go
| Old | New | ||
|---|---|---|---|
| @@ -29,17 +29,29 @@ import ( | |||
| 29 | // addressing is only observable through the seam, so the fake has to own it for | 29 | // addressing is only observable through the seam, so the fake has to own it for |
| 30 | // these tests to mean anything. | 30 | // these tests to mean anything. |
| 31 | type fakeProv struct { | 31 | type fakeProv struct { |
| 32 | mu sync.Mutex | 32 | // st is the same state store the engine writes through, so the fake can |
| 33 | running map[string]bool | 33 | // see the host's disk exactly as a real backend does at Boot. |
| 34 | prepCalls int // total PrepareRootDisk invocations, including failed ones | 34 | st *state.Store |
| 35 | prepared []string | 35 | |
| 36 | booted []string | 36 | mu sync.Mutex |
| 37 | shutdown []string | 37 | running map[string]bool |
| 38 | destroyed []string | 38 | prepCalls int // total PrepareRootDisk invocations, including failed ones |
| 39 | prepErr error | 39 | prepared []string |
| 40 | bootErr error // one-shot: consumed and cleared on first Boot call | 40 | booted []string |
| 41 | destroyErr error // sticky: every Destroy fails until it is cleared | 41 | // bootedSpec is the spec each VM was last booted with. Attachment is part |
| 42 | preflightErr error // sticky: the backend refuses this host outright | 42 | // of Boot on both real backends, so what a guest ends up holding is only |
| 43 | // observable here. | ||
| 44 | bootedSpec map[string]state.VMSpec | ||
| 45 | // volumesAtBoot records, per VM, whether every volume its spec named was | ||
| 46 | // already a file when Boot was called. The ordering that makes a volume | ||
| 47 | // usable is not visible from the spec alone. | ||
| 48 | volumesAtBoot map[string]bool | ||
| 49 | shutdown []string | ||
| 50 | destroyed []string | ||
| 51 | prepErr error | ||
| 52 | bootErr error // one-shot: consumed and cleared on first Boot call | ||
| 53 | destroyErr error // sticky: every Destroy fails until it is cleared | ||
| 54 | preflightErr error // sticky: the backend refuses this host outright | ||
| 43 | 55 | ||
| 44 | cidr string | 56 | cidr string |
| 45 | addrs map[string]string // vmID -> ip (sticky, mirrors the DHCP table) | 57 | addrs map[string]string // vmID -> ip (sticky, mirrors the DHCP table) |
| @@ -58,12 +70,15 @@ type fakeProv struct { | |||
| 58 | lateAddress bool | 70 | lateAddress bool |
| 59 | } | 71 | } |
| 60 | 72 | ||
| 61 | func newFakeProv() *fakeProv { | 73 | func newFakeProv(st *state.Store) *fakeProv { |
| 62 | return &fakeProv{ | 74 | return &fakeProv{ |
| 63 | running: map[string]bool{}, | 75 | st: st, |
| 64 | addrs: map[string]string{}, | 76 | running: map[string]bool{}, |
| 65 | netAddrs: map[string]string{}, | 77 | addrs: map[string]string{}, |
| 66 | cidr: "10.77.1.0/24", | 78 | netAddrs: map[string]string{}, |
| 79 | bootedSpec: map[string]state.VMSpec{}, | ||
| 80 | volumesAtBoot: map[string]bool{}, | ||
| 81 | cidr: "10.77.1.0/24", | ||
| 67 | } | 82 | } |
| 68 | } | 83 | } |
| 69 | 84 | ||
| @@ -84,9 +99,11 @@ func (f *fakeProv) PrepareRootDisk(_ context.Context, s state.VMSpec, _ string) | |||
| 84 | return nil | 99 | return nil |
| 85 | } | 100 | } |
| 86 | 101 | ||
| 87 | func (f *fakeProv) Boot(_ context.Context, id string, _ state.VMSpec) error { | 102 | func (f *fakeProv) Boot(_ context.Context, id string, spec state.VMSpec) error { |
| 88 | f.mu.Lock() | 103 | f.mu.Lock() |
| 89 | defer f.mu.Unlock() | 104 | defer f.mu.Unlock() |
| 105 | f.bootedSpec[id] = spec | ||
| 106 | f.volumesAtBoot[id] = f.volumesOnDisk(spec) | ||
| 90 | if f.bootErr != nil { | 107 | if f.bootErr != nil { |
| 91 | err := f.bootErr | 108 | err := f.bootErr |
| 92 | f.bootErr = nil // one-shot: clear after first use | 109 | f.bootErr = nil // one-shot: clear after first use |
| @@ -102,6 +119,19 @@ func (f *fakeProv) Boot(_ context.Context, id string, _ state.VMSpec) error { | |||
| 102 | return nil | 119 | return nil |
| 103 | } | 120 | } |
| 104 | 121 | ||
| 122 | // volumesOnDisk reports whether every volume this spec names is a file already. | ||
| 123 | // A real backend hands each path to the hypervisor as a block device, so a | ||
| 124 | // missing one is a guest booting without the disk its owner is about to write | ||
| 125 | // to — which is what makes "materialise before dispatch" a testable claim. | ||
| 126 | func (f *fakeProv) volumesOnDisk(spec state.VMSpec) bool { | ||
| 127 | for _, id := range spec.VolumeIDs { | ||
| 128 | if _, err := os.Stat(f.st.VolumePath(id)); err != nil { | ||
| 129 | return false | ||
| 130 | } | ||
| 131 | } | ||
| 132 | return true | ||
| 133 | } | ||
| 134 | |||
| 105 | // attachLocked gives id a sticky address, mirroring netenv: a VM that already | 135 | // attachLocked gives id a sticky address, mirroring netenv: a VM that already |
| 106 | // holds one keeps it, otherwise one is allocated over the set already handed | 136 | // holds one keeps it, otherwise one is allocated over the set already handed |
| 107 | // out. Serialized by the fake's own lock, exactly as the real backend's DHCP | 137 | // out. Serialized by the fake's own lock, exactly as the real backend's DHCP |
| @@ -205,7 +235,7 @@ func setup(t *testing.T) *fixture { | |||
| 205 | t.Helper() | 235 | t.Helper() |
| 206 | st, err := state.Open(t.TempDir()) | 236 | st, err := state.Open(t.TempDir()) |
| 207 | require.NoError(t, err) | 237 | require.NoError(t, err) |
| 208 | f := &fixture{st: st, prov: newFakeProv(), now: time.Unix(1_700_000_000, 0), boot: "boot-1"} | 238 | f := &fixture{st: st, prov: newFakeProv(st), now: time.Unix(1_700_000_000, 0), boot: "boot-1"} |
| 209 | f.eng = f.newEngine() | 239 | f.eng = f.newEngine() |
| 210 | t.Cleanup(f.eng.Stop) | 240 | t.Cleanup(f.eng.Stop) |
| 211 | return f | 241 | return f |
| @@ -253,7 +283,12 @@ func (f *fixture) step(s *pb.Snapshot) *pb.Report { | |||
| 253 | return rep | 283 | return rep |
| 254 | } | 284 | } |
| 255 | f.eng.manager().waitIdle() | 285 | f.eng.manager().waitIdle() |
| 256 | return f.aggregateNow(s.Epoch) | 286 | settled := f.aggregateNow(s.Epoch) |
| 287 | // Volumes converge once per tick, inside Step and before dispatch. Carry | ||
| 288 | // that tick's rows onto the settled report rather than reconciling them a | ||
| 289 | // second time, which is not what a tick does. | ||
| 290 | settled.Volumes = rep.Volumes | ||
| 291 | return settled | ||
| 257 | } | 292 | } |
| 258 | 293 | ||
| 259 | // aggregateNow re-reads records and builds the report for epoch, the way Step | 294 | // aggregateNow re-reads records and builds the report for epoch, the way Step |
| @@ -261,7 +296,7 @@ func (f *fixture) step(s *pb.Snapshot) *pb.Report { | |||
| 261 | // the just-finished passes have already changed. | 296 | // the just-finished passes have already changed. |
| 262 | func (f *fixture) aggregateNow(epoch uint64) *pb.Report { | 297 | func (f *fixture) aggregateNow(epoch uint64) *pb.Report { |
| 263 | recs, _ := f.st.LoadVMs() | 298 | recs, _ := f.st.LoadVMs() |
| 264 | return f.eng.aggregate(epoch, recs) | 299 | return f.eng.aggregate(epoch, recs, nil) |
| 265 | } | 300 | } |
| 266 | 301 | ||
| 267 | func snap(epoch uint64, vms ...*pb.VMSpec) *pb.Snapshot { | 302 | func snap(epoch uint64, vms ...*pb.VMSpec) *pb.Snapshot { |
internal/agent/reconcile/volumes.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,231 @@ | |||
| 1 | package reconcile | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "errors" | ||
| 5 | "fmt" | ||
| 6 | "log/slog" | ||
| 7 | "os" | ||
| 8 | "path/filepath" | ||
| 9 | |||
| 10 | "github.com/a73x/eitri/internal/agent/permanent" | ||
| 11 | "github.com/a73x/eitri/internal/agent/state" | ||
| 12 | "github.com/a73x/eitri/internal/pb" | ||
| 13 | ) | ||
| 14 | |||
| 15 | // reconcileVolumes drives the volumes directory toward the snapshot and | ||
| 16 | // reports every volume id found there. It runs BEFORE VM dispatch so a VM's | ||
| 17 | // disks exist when its backend lists them, and synchronously, because a sparse | ||
| 18 | // truncate costs nothing — there is no download and no copy to keep off the | ||
| 19 | // heartbeat's path. | ||
| 20 | // | ||
| 21 | // The rule that matters: a file the snapshot does not name is reported and | ||
| 22 | // KEPT. A VM that vanishes from a snapshot is eventually reclaimed; a volume | ||
| 23 | // never is, by automation. Only an explicit tombstone deletes data, because | ||
| 24 | // the file is the only copy of whatever a guest wrote to it. | ||
| 25 | // | ||
| 26 | // Every volume the snapshot names gets a row, present or not: the control | ||
| 27 | // plane reads an omitted volume as gone, so silence about one it asked for | ||
| 28 | // would reap the row out from under a file that is still here. | ||
| 29 | func (e *Engine) reconcileVolumes(snap *pb.Snapshot) []*pb.VolumeStatus { | ||
| 30 | var out []*pb.VolumeStatus | ||
| 31 | seen := make(map[string]bool, len(snap.GetVolumes())) | ||
| 32 | for _, spec := range snap.GetVolumes() { | ||
| 33 | id := spec.GetVolumeId() | ||
| 34 | if !validVolumeID(id) { | ||
| 35 | // Refused before the id is joined into a path. Reported absent, so | ||
| 36 | // the control plane hears an answer for every volume it named. | ||
| 37 | slog.Warn("reconcile: refusing a volume id that is not one", "volume", id) | ||
| 38 | out = append(out, &pb.VolumeStatus{VolumeId: id, Present: false}) | ||
| 39 | continue | ||
| 40 | } | ||
| 41 | seen[id] = true | ||
| 42 | out = append(out, e.convergeVolume(id, spec)) | ||
| 43 | } | ||
| 44 | return append(out, e.reportVolumes(seen)...) // orphans: reported, kept | ||
| 45 | } | ||
| 46 | |||
| 47 | // reportVolumes lists what is in the volumes directory, skipping the ids in | ||
| 48 | // skip, and TOUCHES NOTHING: it stats, it does not create, mark or delete. The | ||
| 49 | // fence path reports on state without acting on it, and calls this on its own. | ||
| 50 | // | ||
| 51 | // Directory names are not run through validVolumeID: they came from this | ||
| 52 | // agent's own writes, a filesystem cannot hold a separator in one, and nothing | ||
| 53 | // here does more than stat them. An orphan with a strange name is still an | ||
| 54 | // orphan the operator should be told about. | ||
| 55 | func (e *Engine) reportVolumes(skip map[string]bool) []*pb.VolumeStatus { | ||
| 56 | entries, err := os.ReadDir(e.St.VolumesDir()) | ||
| 57 | if err != nil { | ||
| 58 | // Not fatal: a caller that converged volumes has already reported them. | ||
| 59 | // What is lost is the orphan report, so say so — an operator chasing a | ||
| 60 | // volume that is in neither the fleet nor the host has nothing else to | ||
| 61 | // go on. | ||
| 62 | slog.Warn("reconcile: cannot list the volumes directory", "err", err) | ||
| 63 | return nil | ||
| 64 | } | ||
| 65 | var out []*pb.VolumeStatus | ||
| 66 | for _, ent := range entries { | ||
| 67 | if !ent.IsDir() || skip[ent.Name()] { | ||
| 68 | continue | ||
| 69 | } | ||
| 70 | if st := e.statVolume(ent.Name()); st != nil { | ||
| 71 | out = append(out, st) | ||
| 72 | } | ||
| 73 | } | ||
| 74 | return out | ||
| 75 | } | ||
| 76 | |||
| 77 | // volumesMaterialized refuses a boot whose volumes are not all files on this | ||
| 78 | // host. It guards EVERY call to Prov.Boot — the create, the restart after a | ||
| 79 | // host reboot, and a plain power-on — because a guest handed a device that is | ||
| 80 | // not there finds a filesystem missing and has no way to report that upward: | ||
| 81 | // the VM comes up "ready" with a mount point full of nothing. | ||
| 82 | // | ||
| 83 | // Permanent: nothing on this host heals a volume the control plane never placed | ||
| 84 | // here, so spending three ticks finding that out helps nobody. Re-placing it | ||
| 85 | // edits the spec, which hands the VM a fresh budget. | ||
| 86 | func (e *Engine) volumesMaterialized(spec state.VMSpec) error { | ||
| 87 | for _, id := range spec.VolumeIDs { | ||
| 88 | if _, err := os.Stat(e.St.VolumePath(id)); err != nil { | ||
| 89 | return permanent.Errorf("volume %s not materialized on this host", id) | ||
| 90 | } | ||
| 91 | } | ||
| 92 | return nil | ||
| 93 | } | ||
| 94 | |||
| 95 | // validVolumeID reports whether id is what the control plane mints for a | ||
| 96 | // volume: random.Hex(16), so 32 lowercase hex characters. It is checked before | ||
| 97 | // the id is joined into a path, because a volume path is the one this package | ||
| 98 | // hands to os.RemoveAll — an id carrying a separator or a ".." would let a | ||
| 99 | // malformed snapshot aim a reclaim at a directory that is not a volume, a VM's | ||
| 100 | // own among them. Empty, wrong length and wrong alphabet all fail here. | ||
| 101 | func validVolumeID(id string) bool { | ||
| 102 | if len(id) != 32 { | ||
| 103 | return false | ||
| 104 | } | ||
| 105 | for _, c := range id { | ||
| 106 | if (c < '0' || c > '9') && (c < 'a' || c > 'f') { | ||
| 107 | return false | ||
| 108 | } | ||
| 109 | } | ||
| 110 | return true | ||
| 111 | } | ||
| 112 | |||
| 113 | // statVolume reports a volume as found on disk, nil ONLY when there is no file. | ||
| 114 | // Size is what the file claims, which is the size the guest's filesystem was | ||
| 115 | // made against. | ||
| 116 | // | ||
| 117 | // An error that is not "does not exist" reports the volume PRESENT, with | ||
| 118 | // whatever size could be read. Absence is what sends convergeVolume off to | ||
| 119 | // create the file, and creating ends in a rename over whatever is at that path | ||
| 120 | // — so a permission error, an I/O error or a bad symlink read as "missing" | ||
| 121 | // would destroy the volume it failed to look at. Only ErrNotExist is evidence | ||
| 122 | // of absence; everything else is evidence of nothing. | ||
| 123 | func (e *Engine) statVolume(id string) *pb.VolumeStatus { | ||
| 124 | fi, err := os.Stat(e.St.VolumePath(id)) | ||
| 125 | switch { | ||
| 126 | case errors.Is(err, os.ErrNotExist): | ||
| 127 | return nil | ||
| 128 | case err != nil: | ||
| 129 | slog.Warn("reconcile: cannot stat volume; assuming it is there", "volume", id, "err", err) | ||
| 130 | return &pb.VolumeStatus{VolumeId: id, Present: true} | ||
| 131 | } | ||
| 132 | return &pb.VolumeStatus{VolumeId: id, Present: true, SizeGb: fi.Size() >> 30} | ||
| 133 | } | ||
| 134 | |||
| 135 | // convergeVolume drives ONE volume toward its spec and reports what is on disk | ||
| 136 | // afterwards. | ||
| 137 | func (e *Engine) convergeVolume(id string, spec *pb.VolumeSpec) *pb.VolumeStatus { | ||
| 138 | if spec.GetTombstoned() { | ||
| 139 | return e.reclaimVolume(id) | ||
| 140 | } | ||
| 141 | if st := e.statVolume(id); st != nil { | ||
| 142 | // NEVER resized, in either direction: a guest filesystem sits on this | ||
| 143 | // file, so growing it is the control plane's job to do through the | ||
| 144 | // guest and shrinking it destroys data. Reported as found. | ||
| 145 | return st | ||
| 146 | } | ||
| 147 | if err := createSparse(e.St.VolumePath(id), spec.GetSizeGb()); err != nil { | ||
| 148 | // Reported absent rather than skipped. The next tick retries; until one | ||
| 149 | // succeeds the control plane can see that this host owes a file. | ||
| 150 | slog.Warn("reconcile: create volume", "volume", id, "err", err) | ||
| 151 | return &pb.VolumeStatus{VolumeId: id, Present: false} | ||
| 152 | } | ||
| 153 | return e.statVolume(id) | ||
| 154 | } | ||
| 155 | |||
| 156 | // reclaimVolume deletes a tombstoned volume's directory once TombstoneGrace has | ||
| 157 | // passed since the marker was first written, and reports what is left. | ||
| 158 | // | ||
| 159 | // The grace is deliberately measured from a marker on disk rather than from the | ||
| 160 | // tick that noticed: the tombstone is restated in every snapshot, so timing it | ||
| 161 | // from the snapshot would restart the clock forever. The marker's mtime is a | ||
| 162 | // wall-clock fact and Engine.Now is the loop's clock — the same clock in | ||
| 163 | // production, and injectable here for the same reason the quarantine grace is. | ||
| 164 | func (e *Engine) reclaimVolume(id string) *pb.VolumeStatus { | ||
| 165 | st := e.statVolume(id) | ||
| 166 | if st == nil { | ||
| 167 | // Nothing to reclaim — already deleted, or never materialised on this | ||
| 168 | // host. Say so at once so the control plane can reap the row, and leave | ||
| 169 | // no directory behind for a file that does not exist. | ||
| 170 | return &pb.VolumeStatus{VolumeId: id, Present: false} | ||
| 171 | } | ||
| 172 | |||
| 173 | marker := e.St.VolumeTombstonePath(id) | ||
| 174 | fi, err := os.Stat(marker) | ||
| 175 | if errors.Is(err, os.ErrNotExist) { | ||
| 176 | if err = os.WriteFile(marker, nil, 0o600); err == nil { | ||
| 177 | fi, err = os.Stat(marker) | ||
| 178 | } | ||
| 179 | } | ||
| 180 | if err != nil { | ||
| 181 | // A clock this host cannot read is not permission to delete. Keep the | ||
| 182 | // file and retry next tick. | ||
| 183 | slog.Warn("reconcile: volume tombstone marker", "volume", id, "err", err) | ||
| 184 | return st | ||
| 185 | } | ||
| 186 | if e.Now().Sub(fi.ModTime()) < e.TombstoneGrace { | ||
| 187 | return st // still in grace: the delete is undoable until it is not | ||
| 188 | } | ||
| 189 | |||
| 190 | // Grace expired: the whole directory goes, marker included. A failure keeps | ||
| 191 | // the file — the level-triggered loop retries, and the control plane must | ||
| 192 | // not reap the row while the bytes are still here. | ||
| 193 | if err := os.RemoveAll(e.St.VolumeDir(id)); err != nil { | ||
| 194 | slog.Warn("reconcile: reclaim volume", "volume", id, "err", err) | ||
| 195 | return st | ||
| 196 | } | ||
| 197 | return &pb.VolumeStatus{VolumeId: id, Present: false} | ||
| 198 | } | ||
| 199 | |||
| 200 | // createSparse makes a sizeGB sparse file at path via .partial + rename, the | ||
| 201 | // shape PrepareRootDisk uses, so a crash mid-create leaves no torn file at the | ||
| 202 | // final path — and the file at the final path is therefore always the full | ||
| 203 | // size a guest was promised. | ||
| 204 | func createSparse(path string, sizeGB int64) error { | ||
| 205 | // Bounds, not policy: the control plane validates what a tenant may ask | ||
| 206 | // for. This refuses what cannot be a volume at all, so a zero or a garbage | ||
| 207 | // size fails here instead of producing an empty block device a guest would | ||
| 208 | // mount. | ||
| 209 | if sizeGB < 1 || sizeGB > 1<<20 { | ||
| 210 | return fmt.Errorf("size_gb %d out of range", sizeGB) | ||
| 211 | } | ||
| 212 | if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { | ||
| 213 | return err | ||
| 214 | } | ||
| 215 | tmp := path + ".partial" | ||
| 216 | _ = os.Remove(tmp) // a previous attempt's torn temporary, if any | ||
| 217 | f, err := os.OpenFile(tmp, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) | ||
| 218 | if err != nil { | ||
| 219 | return err | ||
| 220 | } | ||
| 221 | if err := f.Truncate(sizeGB << 30); err != nil { | ||
| 222 | _ = f.Close() | ||
| 223 | _ = os.Remove(tmp) | ||
| 224 | return err | ||
| 225 | } | ||
| 226 | if err := f.Close(); err != nil { | ||
| 227 | _ = os.Remove(tmp) | ||
| 228 | return err | ||
| 229 | } | ||
| 230 | return os.Rename(tmp, path) | ||
| 231 | } | ||
internal/agent/reconcile/volumes_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,590 @@ | |||
| 1 | package reconcile | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "crypto/sha256" | ||
| 5 | "encoding/hex" | ||
| 6 | "os" | ||
| 7 | "path/filepath" | ||
| 8 | "syscall" | ||
| 9 | "testing" | ||
| 10 | "time" | ||
| 11 | |||
| 12 | "github.com/a73x/eitri/internal/pb" | ||
| 13 | "github.com/stretchr/testify/assert" | ||
| 14 | "github.com/stretchr/testify/require" | ||
| 15 | ) | ||
| 16 | |||
| 17 | // volSnap is snap's twin for the other half of desired state: the FULL set of | ||
| 18 | // volumes this host is meant to hold. | ||
| 19 | func volSnap(epoch uint64, vols ...*pb.VolumeSpec) *pb.Snapshot { | ||
| 20 | return &pb.Snapshot{Epoch: epoch, Volumes: vols} | ||
| 21 | } | ||
| 22 | |||
| 23 | // volID turns a short name a test can read into an id the agent will accept: | ||
| 24 | // the 32 hex characters random.Hex(16) mints. Derived from the name so it is | ||
| 25 | // stable across a test's ticks, and so two names never collide. | ||
| 26 | func volID(name string) string { | ||
| 27 | sum := sha256.Sum256([]byte(name)) | ||
| 28 | return hex.EncodeToString(sum[:16]) | ||
| 29 | } | ||
| 30 | |||
| 31 | func volSpec(name string, sizeGB int64) *pb.VolumeSpec { | ||
| 32 | return &pb.VolumeSpec{VolumeId: volID(name), SizeGb: sizeGB} | ||
| 33 | } | ||
| 34 | |||
| 35 | func deadVol(name string, sizeGB int64) *pb.VolumeSpec { | ||
| 36 | v := volSpec(name, sizeGB) | ||
| 37 | v.Tombstoned = true | ||
| 38 | return v | ||
| 39 | } | ||
| 40 | |||
| 41 | func withVolumes(names ...string) func(*pb.VMSpec) { | ||
| 42 | ids := make([]string, len(names)) | ||
| 43 | for i, n := range names { | ||
| 44 | ids[i] = volID(n) | ||
| 45 | } | ||
| 46 | return func(v *pb.VMSpec) { v.VolumeIds = ids } | ||
| 47 | } | ||
| 48 | |||
| 49 | func findVolume(rep *pb.Report, name string) *pb.VolumeStatus { | ||
| 50 | for _, v := range rep.Volumes { | ||
| 51 | if v.VolumeId == volID(name) { | ||
| 52 | return v | ||
| 53 | } | ||
| 54 | } | ||
| 55 | return nil | ||
| 56 | } | ||
| 57 | |||
| 58 | // The fixture's volume paths, by the short name the test used. | ||
| 59 | func (f *fixture) volDir(name string) string { return f.st.VolumeDir(volID(name)) } | ||
| 60 | func (f *fixture) volPath(name string) string { return f.st.VolumePath(volID(name)) } | ||
| 61 | func (f *fixture) volMarker(name string) string { return f.st.VolumeTombstonePath(volID(name)) } | ||
| 62 | |||
| 63 | // ageMarker backdates a volume's tombstone marker so the reclaim grace is | ||
| 64 | // measured against the fixture's clock rather than the wall clock the file | ||
| 65 | // system stamped it with. | ||
| 66 | func (f *fixture) ageMarker(t *testing.T, name string, age time.Duration) { | ||
| 67 | t.Helper() | ||
| 68 | at := f.now.Add(-age) | ||
| 69 | require.NoError(t, os.Chtimes(f.volMarker(name), at, at)) | ||
| 70 | } | ||
| 71 | |||
| 72 | func TestVolumeIsCreatedSparseAndReported(t *testing.T) { | ||
| 73 | f := setup(t) | ||
| 74 | rep := f.step(volSnap(1, volSpec("v1", 2))) | ||
| 75 | |||
| 76 | require.Len(t, rep.Volumes, 1) | ||
| 77 | assert.Equal(t, volID("v1"), rep.Volumes[0].VolumeId) | ||
| 78 | assert.True(t, rep.Volumes[0].Present) | ||
| 79 | assert.EqualValues(t, 2, rep.Volumes[0].SizeGb) | ||
| 80 | |||
| 81 | fi, err := os.Stat(f.volPath("v1")) | ||
| 82 | require.NoError(t, err) | ||
| 83 | assert.EqualValues(t, 2<<30, fi.Size()) | ||
| 84 | if sparseFS(t, f.st.VolumesDir()) { | ||
| 85 | assert.Less(t, allocatedBytes(t, f.volPath("v1")), int64(1<<20), | ||
| 86 | "a 2 GiB volume must not cost 2 GiB the moment it is asked for") | ||
| 87 | } | ||
| 88 | |||
| 89 | _, err = os.Stat(f.volPath("v1") + ".partial") | ||
| 90 | assert.True(t, os.IsNotExist(err), "the temporary is renamed into place, never left beside it") | ||
| 91 | } | ||
| 92 | |||
| 93 | // TestVolumeIsNeverResized: the file is the guest's block device and a | ||
| 94 | // filesystem sits on it, so the agent reports what it found rather than | ||
| 95 | // growing what the control plane now asks for. | ||
| 96 | func TestVolumeIsNeverResized(t *testing.T) { | ||
| 97 | f := setup(t) | ||
| 98 | f.step(volSnap(1, volSpec("v1", 2))) | ||
| 99 | rep := f.step(volSnap(2, volSpec("v1", 5))) | ||
| 100 | |||
| 101 | fi, err := os.Stat(f.volPath("v1")) | ||
| 102 | require.NoError(t, err) | ||
| 103 | assert.EqualValues(t, 2<<30, fi.Size(), "a guest filesystem sits on it") | ||
| 104 | require.Len(t, rep.Volumes, 1) | ||
| 105 | assert.EqualValues(t, 2, rep.Volumes[0].SizeGb, "reported as found, not as asked") | ||
| 106 | } | ||
| 107 | |||
| 108 | // TestVolumeConvergenceIsIdempotent: a repeated snapshot must not re-create a | ||
| 109 | // file that is already there, which is what would silently discard a guest's | ||
| 110 | // data on every tick. | ||
| 111 | func TestVolumeConvergenceIsIdempotent(t *testing.T) { | ||
| 112 | f := setup(t) | ||
| 113 | f.step(volSnap(1, volSpec("v1", 1))) | ||
| 114 | require.NoError(t, os.WriteFile(f.volPath("v1"), []byte("a guest wrote this"), 0o600)) | ||
| 115 | |||
| 116 | rep := f.step(volSnap(2, volSpec("v1", 1))) | ||
| 117 | require.Len(t, rep.Volumes, 1) | ||
| 118 | assert.True(t, rep.Volumes[0].Present) | ||
| 119 | data, err := os.ReadFile(f.volPath("v1")) | ||
| 120 | require.NoError(t, err) | ||
| 121 | assert.Equal(t, "a guest wrote this", string(data), "an existing volume is left exactly as it is") | ||
| 122 | } | ||
| 123 | |||
| 124 | func TestTombstonedVolumeIsDeletedAfterGrace(t *testing.T) { | ||
| 125 | f := setup(t) | ||
| 126 | f.step(volSnap(1, volSpec("v1", 1))) | ||
| 127 | |||
| 128 | rep := f.step(volSnap(2, deadVol("v1", 1))) | ||
| 129 | require.Len(t, rep.Volumes, 1) | ||
| 130 | assert.True(t, rep.Volumes[0].Present, "still within grace") | ||
| 131 | _, err := os.Stat(f.volPath("v1")) | ||
| 132 | assert.NoError(t, err) | ||
| 133 | _, err = os.Stat(f.volMarker("v1")) | ||
| 134 | require.NoError(t, err, "the marker that starts the grace is written on the first tombstoned tick") | ||
| 135 | |||
| 136 | f.ageMarker(t, "v1", 6*time.Minute) // past the fixture's 5m TombstoneGrace | ||
| 137 | rep = f.step(volSnap(3, deadVol("v1", 1))) | ||
| 138 | require.Len(t, rep.Volumes, 1) | ||
| 139 | assert.False(t, rep.Volumes[0].Present, "reported gone so the control plane can reap the row") | ||
| 140 | _, err = os.Stat(f.volDir("v1")) | ||
| 141 | assert.True(t, os.IsNotExist(err), "the whole directory goes, marker included") | ||
| 142 | } | ||
| 143 | |||
| 144 | // TestTombstoneGraceIsMeasuredFromTheFirstMarker: the marker is written once | ||
| 145 | // and never refreshed, so a volume tombstoned minutes ago is not given a fresh | ||
| 146 | // grace by every tick that re-states the tombstone. | ||
| 147 | func TestTombstoneGraceIsMeasuredFromTheFirstMarker(t *testing.T) { | ||
| 148 | f := setup(t) | ||
| 149 | f.step(volSnap(1, volSpec("v1", 1))) | ||
| 150 | f.step(volSnap(2, deadVol("v1", 1))) | ||
| 151 | f.ageMarker(t, "v1", 4*time.Minute) | ||
| 152 | |||
| 153 | rep := f.step(volSnap(3, deadVol("v1", 1))) | ||
| 154 | assert.True(t, rep.Volumes[0].Present, "4 minutes into a 5 minute grace") | ||
| 155 | |||
| 156 | f.now = f.now.Add(2 * time.Minute) // the clock moves, the marker does not | ||
| 157 | rep = f.step(volSnap(4, deadVol("v1", 1))) | ||
| 158 | assert.False(t, rep.Volumes[0].Present, "the grace ran from the first marker, not from this tick") | ||
| 159 | } | ||
| 160 | |||
| 161 | // TestTombstonedVolumeThisHostNeverHadIsReportedGone: nothing to reclaim, so | ||
| 162 | // nothing is written — and the control plane hears "gone" at once instead of | ||
| 163 | // waiting out a grace on a file that does not exist. | ||
| 164 | func TestTombstonedVolumeThisHostNeverHadIsReportedGone(t *testing.T) { | ||
| 165 | f := setup(t) | ||
| 166 | rep := f.step(volSnap(1, deadVol("ghost", 1))) | ||
| 167 | require.Len(t, rep.Volumes, 1) | ||
| 168 | assert.Equal(t, volID("ghost"), rep.Volumes[0].VolumeId) | ||
| 169 | assert.False(t, rep.Volumes[0].Present) | ||
| 170 | _, err := os.Stat(f.volDir("ghost")) | ||
| 171 | assert.True(t, os.IsNotExist(err), "no directory is made for a volume there is nothing to reclaim of") | ||
| 172 | } | ||
| 173 | |||
| 174 | // The rule that matters: a file the snapshot does not name is reported and | ||
| 175 | // kept. One server bug must not delete user data. | ||
| 176 | func TestUnknownVolumeIsReportedNeverDeleted(t *testing.T) { | ||
| 177 | f := setup(t) | ||
| 178 | f.step(volSnap(1, volSpec("v1", 1))) | ||
| 179 | |||
| 180 | rep := f.step(volSnap(2)) | ||
| 181 | require.Len(t, rep.Volumes, 1) | ||
| 182 | assert.Equal(t, volID("v1"), rep.Volumes[0].VolumeId) | ||
| 183 | assert.True(t, rep.Volumes[0].Present) | ||
| 184 | assert.EqualValues(t, 1, rep.Volumes[0].SizeGb) | ||
| 185 | _, err := os.Stat(f.volPath("v1")) | ||
| 186 | assert.NoError(t, err, "an orphan is evidence of a bug somewhere, never a licence to delete") | ||
| 187 | } | ||
| 188 | |||
| 189 | // TestEverySnapshotVolumeGetsAStatusRow pins the contract the server's reap | ||
| 190 | // rests on: it reads an omitted volume as gone, so a volume the agent could | ||
| 191 | // not make must say so in words rather than by silence. | ||
| 192 | func TestEverySnapshotVolumeGetsAStatusRow(t *testing.T) { | ||
| 193 | f := setup(t) | ||
| 194 | rep := f.step(volSnap(1, volSpec("ok", 1), volSpec("nonsense", 0))) | ||
| 195 | |||
| 196 | require.Len(t, rep.Volumes, 2) | ||
| 197 | assert.True(t, findVolume(rep, "ok").GetPresent()) | ||
| 198 | bad := findVolume(rep, "nonsense") | ||
| 199 | require.NotNil(t, bad, "a volume that could not be created still gets a row") | ||
| 200 | assert.False(t, bad.GetPresent()) | ||
| 201 | } | ||
| 202 | |||
| 203 | // TestVolumeConvergenceSurvivesAnUnusableVolumesDirectory: the state directory | ||
| 204 | // can be wrong in ways nothing here can fix — a file where the volumes | ||
| 205 | // directory belongs. Every volume the snapshot named still gets a row, and the | ||
| 206 | // row errs toward present: this host cannot see the file, which is not the | ||
| 207 | // same as knowing it is gone. | ||
| 208 | func TestVolumeConvergenceSurvivesAnUnusableVolumesDirectory(t *testing.T) { | ||
| 209 | f := setup(t) | ||
| 210 | require.NoError(t, os.RemoveAll(f.st.VolumesDir())) | ||
| 211 | require.NoError(t, os.WriteFile(f.st.VolumesDir(), []byte("not a directory"), 0o600)) | ||
| 212 | |||
| 213 | rep := f.step(volSnap(1, volSpec("v1", 1))) | ||
| 214 | require.Len(t, rep.Volumes, 1) | ||
| 215 | assert.Equal(t, volID("v1"), rep.Volumes[0].VolumeId) | ||
| 216 | assert.True(t, rep.Volumes[0].Present, "a volume this host cannot look at is not a volume it may report gone") | ||
| 217 | |||
| 218 | data, err := os.ReadFile(f.st.VolumesDir()) | ||
| 219 | require.NoError(t, err) | ||
| 220 | assert.Equal(t, "not a directory", string(data), "and nothing was written over it") | ||
| 221 | } | ||
| 222 | |||
| 223 | // TestUnreadableVolumeIsNotRecreatedOverTheTopOfItself is the rule statVolume | ||
| 224 | // exists for: create ends in a rename over whatever is at disk.raw, so a stat | ||
| 225 | // that fails for any reason other than "no such file" must NOT be read as an | ||
| 226 | // absent volume. The guest's data is on the other side of that rename. | ||
| 227 | func TestUnreadableVolumeIsNotRecreatedOverTheTopOfItself(t *testing.T) { | ||
| 228 | requireNonRoot(t) | ||
| 229 | f := setup(t) | ||
| 230 | f.step(volSnap(1, volSpec("v1", 1))) | ||
| 231 | require.NoError(t, os.WriteFile(f.volPath("v1"), []byte("a guest wrote this"), 0o600)) | ||
| 232 | chmodForTest(t, f.volDir("v1"), 0o000) // the file cannot even be looked at | ||
| 233 | |||
| 234 | rep := f.step(volSnap(2, volSpec("v1", 1))) | ||
| 235 | require.Len(t, rep.Volumes, 1) | ||
| 236 | assert.True(t, rep.Volumes[0].Present, "unreadable is not absent") | ||
| 237 | |||
| 238 | require.NoError(t, os.Chmod(f.volDir("v1"), 0o700)) | ||
| 239 | _, err := os.Stat(f.volPath("v1") + ".partial") | ||
| 240 | assert.True(t, os.IsNotExist(err), "no create was even attempted") | ||
| 241 | data, err := os.ReadFile(f.volPath("v1")) | ||
| 242 | require.NoError(t, err) | ||
| 243 | assert.Equal(t, "a guest wrote this", string(data), "and the guest's bytes are untouched") | ||
| 244 | } | ||
| 245 | |||
| 246 | // TestTornTemporaryThatCannotBeReplacedIsReported: .partial is where a killed | ||
| 247 | // create leaves its mess. One that cannot be cleared makes the volume absent, | ||
| 248 | // which is what the report then says. | ||
| 249 | func TestTornTemporaryThatCannotBeReplacedIsReported(t *testing.T) { | ||
| 250 | f := setup(t) | ||
| 251 | tmp := f.volPath("v1") + ".partial" | ||
| 252 | require.NoError(t, os.MkdirAll(filepath.Join(tmp, "occupied"), 0o700)) | ||
| 253 | |||
| 254 | rep := f.step(volSnap(1, volSpec("v1", 1))) | ||
| 255 | require.Len(t, rep.Volumes, 1) | ||
| 256 | assert.False(t, rep.Volumes[0].Present) | ||
| 257 | _, err := os.Stat(f.volPath("v1")) | ||
| 258 | assert.True(t, os.IsNotExist(err), "nothing is renamed into place from a temporary that was never written") | ||
| 259 | } | ||
| 260 | |||
| 261 | // TestTombstonedVolumeIsKeptWhenTheMarkerCannotBeWritten: the grace is a file, | ||
| 262 | // and a grace this host cannot start is not permission to delete. | ||
| 263 | func TestTombstonedVolumeIsKeptWhenTheMarkerCannotBeWritten(t *testing.T) { | ||
| 264 | requireNonRoot(t) | ||
| 265 | f := setup(t) | ||
| 266 | f.step(volSnap(1, volSpec("v1", 1))) | ||
| 267 | chmodForTest(t, f.volDir("v1"), 0o500) | ||
| 268 | |||
| 269 | rep := f.step(volSnap(2, deadVol("v1", 1))) | ||
| 270 | require.Len(t, rep.Volumes, 1) | ||
| 271 | assert.True(t, rep.Volumes[0].Present) | ||
| 272 | _, err := os.Stat(f.volPath("v1")) | ||
| 273 | assert.NoError(t, err) | ||
| 274 | } | ||
| 275 | |||
| 276 | // TestTombstonedVolumeIsKeptWhenItCannotBeRemoved: the row must outlive the | ||
| 277 | // bytes, never the other way round — reporting a volume gone while its file is | ||
| 278 | // still here would have the control plane forget a disk this host still holds. | ||
| 279 | func TestTombstonedVolumeIsKeptWhenItCannotBeRemoved(t *testing.T) { | ||
| 280 | requireNonRoot(t) | ||
| 281 | f := setup(t) | ||
| 282 | f.step(volSnap(1, volSpec("v1", 1))) | ||
| 283 | f.step(volSnap(2, deadVol("v1", 1))) | ||
| 284 | f.ageMarker(t, "v1", 6*time.Minute) | ||
| 285 | chmodForTest(t, f.volDir("v1"), 0o500) | ||
| 286 | |||
| 287 | rep := f.step(volSnap(3, deadVol("v1", 1))) | ||
| 288 | require.Len(t, rep.Volumes, 1) | ||
| 289 | assert.True(t, rep.Volumes[0].Present, "still present, so still reported present") | ||
| 290 | _, err := os.Stat(f.volPath("v1")) | ||
| 291 | assert.NoError(t, err) | ||
| 292 | } | ||
| 293 | |||
| 294 | // TestPartialVolumeIsRecreated: a create killed mid-truncate leaves a | ||
| 295 | // .partial, never a short file at the real path, so the next tick simply | ||
| 296 | // makes the volume. | ||
| 297 | func TestPartialVolumeIsRecreated(t *testing.T) { | ||
| 298 | f := setup(t) | ||
| 299 | require.NoError(t, os.MkdirAll(f.volDir("v1"), 0o700)) | ||
| 300 | require.NoError(t, os.WriteFile(f.volPath("v1")+".partial", []byte("torn"), 0o600)) | ||
| 301 | |||
| 302 | rep := f.step(volSnap(1, volSpec("v1", 1))) | ||
| 303 | fi, err := os.Stat(f.volPath("v1")) | ||
| 304 | require.NoError(t, err) | ||
| 305 | assert.EqualValues(t, 1<<30, fi.Size()) | ||
| 306 | assert.True(t, rep.Volumes[0].Present) | ||
| 307 | _, err = os.Stat(f.volPath("v1") + ".partial") | ||
| 308 | assert.True(t, os.IsNotExist(err), "the torn temporary is replaced, not stepped around") | ||
| 309 | } | ||
| 310 | |||
| 311 | // Volumes are materialised before the VM that needs them is dispatched, in | ||
| 312 | // the same Step, so a VM and its volume arriving together boot first try. | ||
| 313 | // | ||
| 314 | // The first half is the deterministic one: Step converges volumes on its own | ||
| 315 | // goroutine, so the file and its row exist the moment Step returns, with no | ||
| 316 | // worker having had to run. The second half is what the ordering buys — the | ||
| 317 | // backend found the device already there. | ||
| 318 | func TestVolumesAreMaterialisedBeforeVMsDispatch(t *testing.T) { | ||
| 319 | f := setup(t) | ||
| 320 | s := volSnap(1, volSpec("v1", 1)) | ||
| 321 | s.Vms = []*pb.VMSpec{vm("vm1", withVolumes("v1"))} | ||
| 322 | |||
| 323 | rep := f.eng.Step(t.Context(), s) | ||
| 324 | _, err := os.Stat(f.volPath("v1")) | ||
| 325 | require.NoError(t, err, "the volume is a file by the time Step returns, not when a worker gets to it") | ||
| 326 | require.Len(t, rep.Volumes, 1) | ||
| 327 | assert.True(t, rep.Volumes[0].Present) | ||
| 328 | |||
| 329 | f.eng.manager().waitIdle() | ||
| 330 | require.Equal(t, "ready", findVM(f.aggregateNow(1), "vm1").GetPhase()) | ||
| 331 | assert.Equal(t, []string{volID("v1")}, f.prov.bootedSpec["vm1"].VolumeIDs, "the backend is told which files to attach") | ||
| 332 | assert.True(t, f.prov.volumesAtBoot["vm1"], "and the file was on disk before Boot was called") | ||
| 333 | } | ||
| 334 | |||
| 335 | // A VM whose volume is absent fails legibly rather than booting bare. | ||
| 336 | func TestVMWithMissingVolumeFailsLegibly(t *testing.T) { | ||
| 337 | f := setup(t) | ||
| 338 | rep := f.step(snap(1, vm("vm1", withVolumes("ghost")))) | ||
| 339 | |||
| 340 | row := findVM(rep, "vm1") | ||
| 341 | require.NotNil(t, row) | ||
| 342 | assert.Equal(t, "failed", row.Phase, "permanent: nothing on this host heals a volume that was never placed") | ||
| 343 | assert.Contains(t, row.LastError, "volume "+volID("ghost")+" not materialized on this host") | ||
| 344 | assert.Empty(t, f.prov.booted, "never booted") | ||
| 345 | |||
| 346 | f.step(snap(2, vm("vm1", withVolumes("ghost")))) | ||
| 347 | assert.Empty(t, f.prov.booted, "and no later tick boots it either") | ||
| 348 | } | ||
| 349 | |||
| 350 | // A guest that already exists is held to the same rule as a new one: powering | ||
| 351 | // it back on without the volume it was created with would hand it a filesystem | ||
| 352 | // that is simply not there, and a guest cannot report that upward. | ||
| 353 | // | ||
| 354 | // The volume is removed while the snapshot no longer names it, which is exactly | ||
| 355 | // what a host holds after the control plane moved the volume elsewhere: nothing | ||
| 356 | // recreates the file, and the VM still asks for it. | ||
| 357 | func TestPoweringOnWithoutItsVolumeFailsRatherThanBootsBare(t *testing.T) { | ||
| 358 | f := setup(t) | ||
| 359 | s := volSnap(1, volSpec("v1", 1)) | ||
| 360 | s.Vms = []*pb.VMSpec{vm("vm1", withVolumes("v1"))} | ||
| 361 | f.step(s) | ||
| 362 | require.Equal(t, []string{"vm1"}, f.prov.booted) | ||
| 363 | |||
| 364 | s2 := volSnap(2, volSpec("v1", 1)) | ||
| 365 | s2.Vms = []*pb.VMSpec{vm("vm1", withVolumes("v1"), stopped)} | ||
| 366 | f.step(s2) | ||
| 367 | require.False(t, f.prov.Running("vm1"), "stopped first, so the next tick is a power-on and not a create") | ||
| 368 | |||
| 369 | require.NoError(t, os.RemoveAll(f.volDir("v1"))) | ||
| 370 | |||
| 371 | rep := f.step(snap(3, vm("vm1", withVolumes("v1")))) | ||
| 372 | |||
| 373 | row := findVM(rep, "vm1") | ||
| 374 | require.NotNil(t, row) | ||
| 375 | assert.Equal(t, "failed", row.Phase) | ||
| 376 | assert.Contains(t, row.LastError, "volume "+volID("v1")+" not materialized on this host") | ||
| 377 | assert.Equal(t, []string{"vm1"}, f.prov.booted, "booted once, at create — never a second time without its device") | ||
| 378 | } | ||
| 379 | |||
| 380 | // The same rule on the other converge path: a host reboot loses every guest, | ||
| 381 | // and the restart that brings them back is a Boot like any other. | ||
| 382 | func TestAHostRebootDoesNotBringAGuestBackWithoutItsVolume(t *testing.T) { | ||
| 383 | f := setup(t) | ||
| 384 | s := volSnap(1, volSpec("v1", 1)) | ||
| 385 | s.Vms = []*pb.VMSpec{vm("vm1", withVolumes("v1"))} | ||
| 386 | f.step(s) | ||
| 387 | require.Equal(t, []string{"vm1"}, f.prov.booted) | ||
| 388 | |||
| 389 | require.NoError(t, os.RemoveAll(f.volDir("v1"))) | ||
| 390 | f.prov.booted = nil | ||
| 391 | f.boot = "boot-2" // the host rebooted: the guest is lost and would be booted again | ||
| 392 | f.prov.running["vm1"] = false | ||
| 393 | |||
| 394 | rep := f.step(snap(2, vm("vm1", withVolumes("v1")))) | ||
| 395 | |||
| 396 | row := findVM(rep, "vm1") | ||
| 397 | require.NotNil(t, row) | ||
| 398 | assert.Equal(t, "failed", row.Phase) | ||
| 399 | assert.Contains(t, row.LastError, "volume "+volID("v1")+" not materialized on this host") | ||
| 400 | assert.Empty(t, f.prov.booted, "a lost guest comes back with its devices or not at all") | ||
| 401 | } | ||
| 402 | |||
| 403 | // TestVolumeIDsSurviveAnAgentRestart: the ids are desired state on disk, so a | ||
| 404 | // restarted agent re-attaches the same devices in the same order without | ||
| 405 | // waiting for a snapshot to tell it again. | ||
| 406 | func TestVolumeIDsSurviveAnAgentRestart(t *testing.T) { | ||
| 407 | f := setup(t) | ||
| 408 | s := volSnap(1, volSpec("va", 1), volSpec("vb", 1)) | ||
| 409 | s.Vms = []*pb.VMSpec{vm("vm1", withVolumes("vb", "va"))} | ||
| 410 | f.step(s) | ||
| 411 | |||
| 412 | recs, err := f.st.LoadVMs() | ||
| 413 | require.NoError(t, err) | ||
| 414 | assert.Equal(t, []string{volID("vb"), volID("va")}, recs["vm1"].Spec.VolumeIDs, "attachment order is persisted") | ||
| 415 | |||
| 416 | f.restart(t) | ||
| 417 | f.prov.booted = nil | ||
| 418 | f.boot = "boot-2" // the host rebooted with it, so the guest is lost and comes back | ||
| 419 | f.prov.running["vm1"] = false | ||
| 420 | s2 := volSnap(2, volSpec("va", 1), volSpec("vb", 1)) | ||
| 421 | s2.Vms = []*pb.VMSpec{vm("vm1", withVolumes("vb", "va"))} | ||
| 422 | f.step(s2) | ||
| 423 | |||
| 424 | require.Equal(t, []string{"vm1"}, f.prov.booted) | ||
| 425 | assert.Equal(t, []string{volID("vb"), volID("va")}, f.prov.bootedSpec["vm1"].VolumeIDs) | ||
| 426 | } | ||
| 427 | |||
| 428 | // TestEditingTheVolumeListResetsCreateAttempts is Equal doing the job == did: | ||
| 429 | // binding a volume is an edit, so a VM that spent its retry budget under the | ||
| 430 | // old spec gets a fresh one. | ||
| 431 | func TestEditingTheVolumeListResetsCreateAttempts(t *testing.T) { | ||
| 432 | f := setup(t) | ||
| 433 | f.prov.prepErr = assert.AnError | ||
| 434 | for range 3 { | ||
| 435 | f.step(snap(1, vm("vm1"))) | ||
| 436 | } | ||
| 437 | f.prov.prepErr = nil | ||
| 438 | |||
| 439 | s := volSnap(2, volSpec("v1", 1)) | ||
| 440 | s.Vms = []*pb.VMSpec{vm("vm1", withVolumes("v1"))} | ||
| 441 | rep := f.step(s) | ||
| 442 | assert.Equal(t, []string{"vm1"}, f.prov.prepared, "a newly bound volume is a spec edit") | ||
| 443 | assert.Equal(t, "ready", findVM(rep, "vm1").GetPhase()) | ||
| 444 | } | ||
| 445 | |||
| 446 | // TestFencedSnapshotReportsVolumesWithoutTouchingThem: the fence path acts on | ||
| 447 | // nothing, volumes included — but it must still SAY what is on this host. The | ||
| 448 | // control plane reaps a tombstoned volume on a report that omits it, so a | ||
| 449 | // fenced report naming none would read as a host that has none. | ||
| 450 | func TestFencedSnapshotReportsVolumesWithoutTouchingThem(t *testing.T) { | ||
| 451 | f := setup(t) | ||
| 452 | f.step(volSnap(5, volSpec("v1", 2), volSpec("v2", 1))) | ||
| 453 | f.step(volSnap(6, volSpec("v1", 2), deadVol("v2", 1))) // v2 is on its way out | ||
| 454 | require.NoError(t, os.WriteFile(f.volPath("v1"), []byte("a guest wrote this"), 0o600)) | ||
| 455 | |||
| 456 | rep := f.step(volSnap(3, volSpec("v3", 1))) | ||
| 457 | |||
| 458 | require.True(t, rep.FenceViolation) | ||
| 459 | require.Len(t, rep.Volumes, 2, "both volumes on disk are reported, the tombstoned one included") | ||
| 460 | assert.True(t, findVolume(rep, "v1").GetPresent()) | ||
| 461 | assert.True(t, findVolume(rep, "v2").GetPresent()) | ||
| 462 | assert.Nil(t, findVolume(rep, "v3"), "a stale snapshot's volume is not conjured into the report") | ||
| 463 | |||
| 464 | _, err := os.Stat(f.volPath("v3")) | ||
| 465 | assert.True(t, os.IsNotExist(err), "a stale snapshot must not materialise a file") | ||
| 466 | data, err := os.ReadFile(f.volPath("v1")) | ||
| 467 | require.NoError(t, err) | ||
| 468 | assert.Equal(t, "a guest wrote this", string(data), "nor touch one that is here") | ||
| 469 | |||
| 470 | f.now = f.now.Add(time.Hour) // long past the grace v2's marker started | ||
| 471 | rep = f.step(volSnap(3, volSpec("v3", 1))) | ||
| 472 | require.True(t, rep.FenceViolation) | ||
| 473 | assert.True(t, findVolume(rep, "v2").GetPresent(), "and the fence path reclaims nothing, whatever the clock says") | ||
| 474 | } | ||
| 475 | |||
| 476 | // TestAVolumeIDIsNotAPath: volume paths are the ones this package hands to | ||
| 477 | // os.RemoveAll, so an id is checked before it is joined into one. Without that | ||
| 478 | // check a single malformed snapshot aims a reclaim at a live VM's directory. | ||
| 479 | func TestAVolumeIDIsNotAPath(t *testing.T) { | ||
| 480 | f := setup(t) | ||
| 481 | f.step(snap(1, vm("vm1"))) | ||
| 482 | require.NoError(t, os.WriteFile(f.st.DiskPath("vm1"), []byte("a running guest's root disk"), 0o600)) | ||
| 483 | |||
| 484 | traversal := "../vms/vm1" | ||
| 485 | rep := f.step(volSnap(2, &pb.VolumeSpec{VolumeId: traversal, SizeGb: 1, Tombstoned: true})) | ||
| 486 | |||
| 487 | require.Len(t, rep.Volumes, 1) | ||
| 488 | assert.Equal(t, traversal, rep.Volumes[0].VolumeId, "answered for, so the control plane is not left waiting") | ||
| 489 | assert.False(t, rep.Volumes[0].Present) | ||
| 490 | |||
| 491 | _, err := os.Stat(filepath.Join(f.st.VMDir("vm1"), "tombstoned")) | ||
| 492 | assert.True(t, os.IsNotExist(err), "no reclaim clock is started over a VM's own directory") | ||
| 493 | data, err := os.ReadFile(f.st.DiskPath("vm1")) | ||
| 494 | require.NoError(t, err) | ||
| 495 | assert.Equal(t, "a running guest's root disk", string(data), "and the guest's root disk is still there") | ||
| 496 | } | ||
| 497 | |||
| 498 | // TestVolumeIDsThatAreNotIDsAreRefused covers the rest of the alphabet: what | ||
| 499 | // the control plane mints is random.Hex(16), and nothing else is joined into a | ||
| 500 | // path at all. | ||
| 501 | func TestVolumeIDsThatAreNotIDsAreRefused(t *testing.T) { | ||
| 502 | f := setup(t) | ||
| 503 | bad := []string{ | ||
| 504 | "", | ||
| 505 | "v1", | ||
| 506 | "../../etc/passwd", | ||
| 507 | "deadbeefdeadbeefdeadbeefdeadbee", // 31: one short | ||
| 508 | "deadbeefdeadbeefdeadbeefdeadbeeff", // 33: one long | ||
| 509 | "DEADBEEFDEADBEEFDEADBEEFDEADBEEF", // hex, but not the case Hex writes | ||
| 510 | "deadbeef-deadbeef-deadbeef-dead", | ||
| 511 | } | ||
| 512 | var specs []*pb.VolumeSpec | ||
| 513 | for _, id := range bad { | ||
| 514 | specs = append(specs, &pb.VolumeSpec{VolumeId: id, SizeGb: 1}) | ||
| 515 | } | ||
| 516 | |||
| 517 | rep := f.step(volSnap(1, specs...)) | ||
| 518 | |||
| 519 | require.Len(t, rep.Volumes, len(bad), "every id gets an answer, however wrong it was") | ||
| 520 | for i, v := range rep.Volumes { | ||
| 521 | assert.Equal(t, bad[i], v.VolumeId) | ||
| 522 | assert.False(t, v.Present, "%q must not be treated as a volume", bad[i]) | ||
| 523 | } | ||
| 524 | entries, err := os.ReadDir(f.st.VolumesDir()) | ||
| 525 | require.NoError(t, err) | ||
| 526 | assert.Empty(t, entries, "nothing was created for any of them") | ||
| 527 | } | ||
| 528 | |||
| 529 | // TestSnapshotBelowTheFloorTouchesNoVolume: an agent that cannot fully read a | ||
| 530 | // snapshot acts on no part of it. The server reaps a tombstoned volume on an | ||
| 531 | // omitted row only for an agent that reports volumes at all, and this one has | ||
| 532 | // just said it is too old to. | ||
| 533 | func TestSnapshotBelowTheFloorTouchesNoVolume(t *testing.T) { | ||
| 534 | f := setup(t) | ||
| 535 | f.eng.AgentVersion = "v0.0.6" | ||
| 536 | s := volSnap(1, volSpec("v1", 1)) | ||
| 537 | s.MinAgentVersion = "v0.0.7" | ||
| 538 | s.Vms = []*pb.VMSpec{vm("vm1", withVolumes("v1"))} | ||
| 539 | |||
| 540 | rep := f.eng.Step(t.Context(), s) | ||
| 541 | assert.Empty(t, rep.Volumes) | ||
| 542 | assert.Equal(t, "failed", findVM(rep, "vm1").GetPhase()) | ||
| 543 | _, err := os.Stat(f.volPath("v1")) | ||
| 544 | assert.True(t, os.IsNotExist(err)) | ||
| 545 | } | ||
| 546 | |||
| 547 | // requireNonRoot skips a test that makes a directory unwritable to prove what | ||
| 548 | // the agent does when it cannot write. Root is not refused by permission bits, | ||
| 549 | // so under root the test would prove the opposite of what it says. | ||
| 550 | func requireNonRoot(t *testing.T) { | ||
| 551 | t.Helper() | ||
| 552 | if os.Geteuid() == 0 { | ||
| 553 | t.Skip("running as root: permission bits refuse nothing") | ||
| 554 | } | ||
| 555 | } | ||
| 556 | |||
| 557 | // chmodForTest makes a directory unwritable and puts it back afterwards, so | ||
| 558 | // t.TempDir's own cleanup can still remove it. | ||
| 559 | func chmodForTest(t *testing.T, dir string, mode os.FileMode) { | ||
| 560 | t.Helper() | ||
| 561 | require.NoError(t, os.Chmod(dir, mode)) | ||
| 562 | t.Cleanup(func() { _ = os.Chmod(dir, 0o700) }) | ||
| 563 | } | ||
| 564 | |||
| 565 | // allocatedBytes reports how much disk a file actually occupies, as opposed to | ||
| 566 | // the size it claims. st_blocks is in 512-byte units by POSIX definition, | ||
| 567 | // whatever the filesystem's own block size is. | ||
| 568 | func allocatedBytes(t *testing.T, path string) int64 { | ||
| 569 | t.Helper() | ||
| 570 | fi, err := os.Stat(path) | ||
| 571 | require.NoError(t, err) | ||
| 572 | st, ok := fi.Sys().(*syscall.Stat_t) | ||
| 573 | require.True(t, ok, "no syscall.Stat_t for %s", path) | ||
| 574 | return st.Blocks * 512 | ||
| 575 | } | ||
| 576 | |||
| 577 | // sparseFS reports whether dir's filesystem holds holes at all. Without the | ||
| 578 | // probe, asserting sparseness asserts something about the machine the test ran | ||
| 579 | // on rather than about the code. | ||
| 580 | func sparseFS(t *testing.T, dir string) bool { | ||
| 581 | t.Helper() | ||
| 582 | p := filepath.Join(dir, "sparse-probe") | ||
| 583 | f, err := os.Create(p) | ||
| 584 | require.NoError(t, err) | ||
| 585 | require.NoError(t, f.Truncate(8<<20)) | ||
| 586 | require.NoError(t, f.Close()) | ||
| 587 | alloc := allocatedBytes(t, p) | ||
| 588 | require.NoError(t, os.Remove(p)) | ||
| 589 | return alloc <= 1<<20 | ||
| 590 | } | ||
internal/agent/state/state.go
| Old | New | ||
|---|---|---|---|
| @@ -9,6 +9,7 @@ import ( | |||
| 9 | "fmt" | 9 | "fmt" |
| 10 | "os" | 10 | "os" |
| 11 | "path/filepath" | 11 | "path/filepath" |
| 12 | "reflect" | ||
| 12 | "strconv" | 13 | "strconv" |
| 13 | "strings" | 14 | "strings" |
| 14 | "time" | 15 | "time" |
| @@ -24,10 +25,35 @@ type VMSpec struct { | |||
| 24 | // record: it is desired state the control plane sent, and it must survive | 25 | // record: it is desired state the control plane sent, and it must survive |
| 25 | // an agent restart so the replay can rebuild the VM's in-memory discovery | 26 | // an agent restart so the replay can rebuild the VM's in-memory discovery |
| 26 | // state. | 27 | // state. |
| 27 | Network string | 28 | Network string |
| 29 | // VolumeIDs are the bound volumes to attach after root and seed, in this | ||
| 30 | // order — the first is /dev/vdc. Desired state that must survive an agent | ||
| 31 | // restart, like Network: a restarted agent re-attaches the same devices in | ||
| 32 | // the same order without waiting to be told again. | ||
| 33 | VolumeIDs []string | ||
| 28 | VCPUs, MemMB, DiskGB int64 | 34 | VCPUs, MemMB, DiskGB int64 |
| 29 | } | 35 | } |
| 30 | 36 | ||
| 37 | // Equal is what == was before VolumeIDs made the struct uncomparable. Order is | ||
| 38 | // part of the answer: the first volume is /dev/vdc, so a reordered list is a | ||
| 39 | // different guest and reconcile treats it as an edit. | ||
| 40 | // | ||
| 41 | // DeepEqual rather than a hand-written field list, because the caller that | ||
| 42 | // matters is the one deciding whether a user edited a VM (reconcile.create, | ||
| 43 | // which hands an edited spec a fresh retry budget). A field added to this | ||
| 44 | // struct and forgotten in a field list would make edits to it invisible, and | ||
| 45 | // nothing would fail to say so. An empty list and no list are the same guest, | ||
| 46 | // so both normalise to nil first — s and o are copies, so this is local. | ||
| 47 | func (s VMSpec) Equal(o VMSpec) bool { | ||
| 48 | if len(s.VolumeIDs) == 0 { | ||
| 49 | s.VolumeIDs = nil | ||
| 50 | } | ||
| 51 | if len(o.VolumeIDs) == 0 { | ||
| 52 | o.VolumeIDs = nil | ||
| 53 | } | ||
| 54 | return reflect.DeepEqual(s, o) | ||
| 55 | } | ||
| 56 | |||
| 31 | // Disk is one block device attached to a VM, in attachment order. Index 0 is | 57 | // Disk is one block device attached to a VM, in attachment order. Index 0 is |
| 32 | // the root disk (/dev/vda) — both cloud-hypervisor and vfkit order by argument | 58 | // the root disk (/dev/vda) — both cloud-hypervisor and vfkit order by argument |
| 33 | // position and treat the first as root. | 59 | // position and treat the first as root. |
| @@ -73,7 +99,7 @@ type Store struct{ dir string } | |||
| 73 | // Open initialises the state directory, creating required subdirectories if | 99 | // Open initialises the state directory, creating required subdirectories if |
| 74 | // they do not already exist. Returns a ready-to-use *Store. | 100 | // they do not already exist. Returns a ready-to-use *Store. |
| 75 | func Open(dir string) (*Store, error) { | 101 | func Open(dir string) (*Store, error) { |
| 76 | for _, sub := range []string{dir, filepath.Join(dir, "vms"), filepath.Join(dir, "images")} { | 102 | for _, sub := range []string{dir, filepath.Join(dir, "vms"), filepath.Join(dir, "images"), filepath.Join(dir, "volumes")} { |
| 77 | if err := os.MkdirAll(sub, 0o700); err != nil { | 103 | if err := os.MkdirAll(sub, 0o700); err != nil { |
| 78 | return nil, err | 104 | return nil, err |
| 79 | } | 105 | } |
| @@ -84,6 +110,26 @@ func Open(dir string) (*Store, error) { | |||
| 84 | // ImagesDir returns the path where downloaded images are cached. | 110 | // ImagesDir returns the path where downloaded images are cached. |
| 85 | func (s *Store) ImagesDir() string { return filepath.Join(s.dir, "images") } | 111 | func (s *Store) ImagesDir() string { return filepath.Join(s.dir, "images") } |
| 86 | 112 | ||
| 113 | // VolumesDir holds one directory per volume, BESIDE vms/ and never inside a | ||
| 114 | // VM's: a VM's directory is removed on destroy, a volume's is not. That | ||
| 115 | // separation is the durability. | ||
| 116 | func (s *Store) VolumesDir() string { return filepath.Join(s.dir, "volumes") } | ||
| 117 | |||
| 118 | // VolumeDir returns the per-volume directory for the given volume id. | ||
| 119 | func (s *Store) VolumeDir(id string) string { return filepath.Join(s.VolumesDir(), id) } | ||
| 120 | |||
| 121 | // VolumePath returns the path of the volume's backing file — the block device | ||
| 122 | // a guest is handed. | ||
| 123 | func (s *Store) VolumePath(id string) string { return filepath.Join(s.VolumeDir(id), "disk.raw") } | ||
| 124 | |||
| 125 | // VolumeTombstonePath is the marker whose mtime starts the reclaim grace. It | ||
| 126 | // is written once, on the first tick that sees the tombstone, and never | ||
| 127 | // refreshed: the grace runs from the deletion, not from the latest snapshot | ||
| 128 | // that restates it. | ||
| 129 | func (s *Store) VolumeTombstonePath(id string) string { | ||
| 130 | return filepath.Join(s.VolumeDir(id), "tombstoned") | ||
| 131 | } | ||
| 132 | |||
| 87 | // VMDir returns the per-VM directory for the given vmID. | 133 | // VMDir returns the per-VM directory for the given vmID. |
| 88 | func (s *Store) VMDir(vmID string) string { return filepath.Join(s.dir, "vms", vmID) } | 134 | func (s *Store) VMDir(vmID string) string { return filepath.Join(s.dir, "vms", vmID) } |
| 89 | 135 | ||
internal/agent/state/state_test.go
| Old | New | ||
|---|---|---|---|
| @@ -3,6 +3,7 @@ package state | |||
| 3 | import ( | 3 | import ( |
| 4 | "os" | 4 | "os" |
| 5 | "path/filepath" | 5 | "path/filepath" |
| 6 | "reflect" | ||
| 6 | "strconv" | 7 | "strconv" |
| 7 | "strings" | 8 | "strings" |
| 8 | "testing" | 9 | "testing" |
| @@ -203,3 +204,93 @@ func TestSaveIdentityRefusesAnUnusableTrustRoot(t *testing.T) { | |||
| 203 | require.True(t, ok) | 204 | require.True(t, ok) |
| 204 | assert.Equal(t, good, id.ServerCertSHA256) | 205 | assert.Equal(t, good, id.ServerCertSHA256) |
| 205 | } | 206 | } |
| 207 | |||
| 208 | // TestVMSpecEqual pins the replacement for ==: VolumeIDs made the struct | ||
| 209 | // uncomparable, and reconcile decides whether a user edited a VM from this | ||
| 210 | // answer. Order is part of the spec — the first volume is /dev/vdc — so a | ||
| 211 | // reorder is an edit. | ||
| 212 | func TestVMSpecEqual(t *testing.T) { | ||
| 213 | base := VMSpec{VMID: "vm1", Name: "a", ImageURL: "u", ImageSHA256: "s", | ||
| 214 | Network: "lan", VCPUs: 2, MemMB: 2048, DiskGB: 10, VolumeIDs: []string{"va", "vb"}} | ||
| 215 | |||
| 216 | same := base | ||
| 217 | same.VolumeIDs = []string{"va", "vb"} | ||
| 218 | assert.True(t, base.Equal(same), "an identical spec compares equal through a fresh slice") | ||
| 219 | |||
| 220 | reordered := base | ||
| 221 | reordered.VolumeIDs = []string{"vb", "va"} | ||
| 222 | assert.False(t, base.Equal(reordered), "attachment order is part of the spec") | ||
| 223 | |||
| 224 | fewer := base | ||
| 225 | fewer.VolumeIDs = []string{"va"} | ||
| 226 | assert.False(t, base.Equal(fewer)) | ||
| 227 | |||
| 228 | none := base | ||
| 229 | none.VolumeIDs = nil | ||
| 230 | assert.False(t, base.Equal(none)) | ||
| 231 | assert.True(t, VMSpec{VMID: "vm1"}.Equal(VMSpec{VMID: "vm1"}), "no volumes either side is still equal") | ||
| 232 | |||
| 233 | // Every field, found by reflection rather than listed by hand: a field | ||
| 234 | // added to VMSpec and missed by Equal would make edits to it invisible to | ||
| 235 | // the retry budget, and a hand-written list here would miss it in exactly | ||
| 236 | // the same way. | ||
| 237 | rt := reflect.TypeOf(base) | ||
| 238 | for i := range rt.NumField() { | ||
| 239 | other := base | ||
| 240 | other.VolumeIDs = []string{"va", "vb"} | ||
| 241 | fv := reflect.ValueOf(&other).Elem().Field(i) | ||
| 242 | switch fv.Kind() { | ||
| 243 | case reflect.String: | ||
| 244 | fv.SetString("edited") | ||
| 245 | case reflect.Int64: | ||
| 246 | fv.SetInt(99) | ||
| 247 | case reflect.Slice: | ||
| 248 | fv.Set(reflect.ValueOf([]string{"edited"})) | ||
| 249 | default: | ||
| 250 | t.Fatalf("field %s has kind %s: teach this test how to edit it, then check Equal notices", | ||
| 251 | rt.Field(i).Name, fv.Kind()) | ||
| 252 | } | ||
| 253 | assert.False(t, base.Equal(other), "an edit to %s must not compare equal", rt.Field(i).Name) | ||
| 254 | } | ||
| 255 | } | ||
| 256 | |||
| 257 | // TestVolumePathsSitBesideTheVMs pins the layout the durability rests on: a | ||
| 258 | // volume's directory is not inside any VM's, so destroying a VM cannot take a | ||
| 259 | // volume with it. | ||
| 260 | func TestVolumePathsSitBesideTheVMs(t *testing.T) { | ||
| 261 | s := open(t) | ||
| 262 | assert.Equal(t, filepath.Join(s.dir, "volumes"), s.VolumesDir()) | ||
| 263 | assert.Equal(t, filepath.Join(s.VolumesDir(), "v1"), s.VolumeDir("v1")) | ||
| 264 | assert.Equal(t, filepath.Join(s.VolumeDir("v1"), "disk.raw"), s.VolumePath("v1")) | ||
| 265 | assert.Equal(t, filepath.Join(s.VolumeDir("v1"), "tombstoned"), s.VolumeTombstonePath("v1")) | ||
| 266 | |||
| 267 | require.NoError(t, s.SaveVM(Record{Spec: VMSpec{VMID: "vm1", VolumeIDs: []string{"v1"}}})) | ||
| 268 | require.NoError(t, os.MkdirAll(s.VolumeDir("v1"), 0o700)) | ||
| 269 | require.NoError(t, os.WriteFile(s.VolumePath("v1"), []byte("data"), 0o600)) | ||
| 270 | require.NoError(t, s.DeleteVM("vm1")) | ||
| 271 | _, err := os.Stat(s.VolumePath("v1")) | ||
| 272 | assert.NoError(t, err, "destroying a VM must not take its volumes with it") | ||
| 273 | } | ||
| 274 | |||
| 275 | // TestOpenCreatesVolumesDir: the orphan scan reads this directory every tick, | ||
| 276 | // so a fresh agent must not have to create it first. | ||
| 277 | func TestOpenCreatesVolumesDir(t *testing.T) { | ||
| 278 | s := open(t) | ||
| 279 | fi, err := os.Stat(s.VolumesDir()) | ||
| 280 | require.NoError(t, err) | ||
| 281 | assert.True(t, fi.IsDir()) | ||
| 282 | } | ||
| 283 | |||
| 284 | // TestVolumeIDsSurviveAReopen: the bound volumes are desired state, so they | ||
| 285 | // must be on disk like Network — a restarted agent still knows which files to | ||
| 286 | // attach. | ||
| 287 | func TestVolumeIDsSurviveAReopen(t *testing.T) { | ||
| 288 | dir := t.TempDir() | ||
| 289 | s, _ := Open(dir) | ||
| 290 | require.NoError(t, s.SaveVM(Record{Spec: VMSpec{VMID: "vm1", VolumeIDs: []string{"vb", "va"}}})) | ||
| 291 | |||
| 292 | s2, _ := Open(dir) | ||
| 293 | got, _, err := s2.Get("vm1") | ||
| 294 | require.NoError(t, err) | ||
| 295 | assert.Equal(t, []string{"vb", "va"}, got.Spec.VolumeIDs, "in attachment order") | ||
| 296 | } | ||
internal/agent/vfkit/vfkit.go
| Old | New | ||
|---|---|---|---|
| @@ -163,20 +163,26 @@ func (p *Provisioner) FailureReason(vmID string) string { return hyperlog.Reason | |||
| 163 | // can hand ConsoleSource the same path Boot writes. | 163 | // can hand ConsoleSource the same path Boot writes. |
| 164 | func (p *Provisioner) SocketPath(vmID string) string { return p.sockPath(vmID) } | 164 | func (p *Provisioner) SocketPath(vmID string) string { return p.sockPath(vmID) } |
| 165 | 165 | ||
| 166 | // disks returns the VM's block devices in attachment order: the root disk | 166 | // disks returns the VM's block devices in attachment order: the root disk, the |
| 167 | // first, then the cloud-init seed. Both files must already exist when vfkit | 167 | // cloud-init seed, then every volume in spec order — so the first volume the |
| 168 | // starts — it opens them while parsing its arguments, before any VM is built — | 168 | // tenant attached is /dev/vdc, and stays /dev/vdc across reboots. Every one of |
| 169 | // which they do: reconcile prepares the disk and writes the seed before Boot. | 169 | // these files must already exist when vfkit starts — it opens them while |
| 170 | // parsing its arguments, before any VM is built — which they do: reconcile | ||
| 171 | // prepares the disk, writes the seed and materialises the volumes before Boot. | ||
| 170 | // | 172 | // |
| 171 | // vfkit's virtio-blk has no read-only option | 173 | // vfkit's virtio-blk has no read-only option |
| 172 | // — unlike cloud-hypervisor's — so state.Disk.ReadOnly is dropped here rather | 174 | // — unlike cloud-hypervisor's — so state.Disk.ReadOnly is dropped here rather |
| 173 | // than honored. Nothing rests on it: the seed is a per-VM file, and cloud-init | 175 | // than honored. Nothing rests on it: the seed is a per-VM file, and cloud-init |
| 174 | // mounts it read-only from inside the guest regardless. | 176 | // mounts it read-only from inside the guest regardless. |
| 175 | func (p *Provisioner) disks(spec state.VMSpec) []state.Disk { | 177 | func (p *Provisioner) disks(spec state.VMSpec) []state.Disk { |
| 176 | return []state.Disk{ | 178 | out := []state.Disk{ |
| 177 | {Path: p.st.DiskPath(spec.VMID)}, | 179 | {Path: p.st.DiskPath(spec.VMID)}, |
| 178 | {Path: p.st.SeedPath(spec.VMID), ReadOnly: true}, | 180 | {Path: p.st.SeedPath(spec.VMID), ReadOnly: true}, |
| 179 | } | 181 | } |
| 182 | for _, id := range spec.VolumeIDs { | ||
| 183 | out = append(out, state.Disk{Path: p.st.VolumePath(id)}) | ||
| 184 | } | ||
| 185 | return out | ||
| 180 | } | 186 | } |
| 181 | 187 | ||
| 182 | func (p *Provisioner) buildArgs(spec state.VMSpec, createVarStore bool) []string { | 188 | func (p *Provisioner) buildArgs(spec state.VMSpec, createVarStore bool) []string { |
internal/agent/vfkit/vfkit_test.go
| Old | New | ||
|---|---|---|---|
| @@ -80,6 +80,32 @@ func TestBuildArgsPutsTheRootDiskFirst(t *testing.T) { | |||
| 80 | assert.Contains(t, blk[1], "seed.iso") | 80 | assert.Contains(t, blk[1], "seed.iso") |
| 81 | } | 81 | } |
| 82 | 82 | ||
| 83 | // TestDisksAppendVolumesAfterSeedInOrder pins the rest of the guest ABI: a | ||
| 84 | // volume never displaces the root disk or the seed, and the spec's order is the | ||
| 85 | // device order, so the first volume a tenant attached is /dev/vdc on every boot. | ||
| 86 | func TestDisksAppendVolumesAfterSeedInOrder(t *testing.T) { | ||
| 87 | p := newTestProv(t, nil) | ||
| 88 | st := p.st | ||
| 89 | |||
| 90 | spec := testSpec() | ||
| 91 | spec.VolumeIDs = []string{"vb", "va"} | ||
| 92 | disks := p.disks(spec) | ||
| 93 | |||
| 94 | require.Len(t, disks, 4) | ||
| 95 | assert.Equal(t, st.DiskPath("vm-1"), disks[0].Path) | ||
| 96 | assert.Equal(t, st.SeedPath("vm-1"), disks[1].Path) | ||
| 97 | assert.Equal(t, st.VolumePath("vb"), disks[2].Path, "spec order, not sorted: the first volume is /dev/vdc") | ||
| 98 | assert.Equal(t, st.VolumePath("va"), disks[3].Path) | ||
| 99 | assert.False(t, disks[2].ReadOnly, "a volume is the guest's to write to") | ||
| 100 | assert.False(t, disks[3].ReadOnly) | ||
| 101 | |||
| 102 | args := p.buildArgs(spec, false) | ||
| 103 | joined := strings.Join(args, " ") | ||
| 104 | assert.Contains(t, joined, "virtio-blk,path="+st.VolumePath("vb")) | ||
| 105 | assert.Less(t, strings.Index(joined, st.SeedPath("vm-1")), strings.Index(joined, st.VolumePath("vb")), | ||
| 106 | "vfkit maps block devices by argument position, so the volumes come last") | ||
| 107 | } | ||
| 108 | |||
| 83 | func TestBuildArgsCreatesTheVariableStoreOnlyWhenThereIsNone(t *testing.T) { | 109 | func TestBuildArgsCreatesTheVariableStoreOnlyWhenThereIsNone(t *testing.T) { |
| 84 | p := newTestProv(t, nil) | 110 | p := newTestProv(t, nil) |
| 85 | 111 | ||
internal/pb/sync.pb.go
| Old | New | ||
|---|---|---|---|
| @@ -832,7 +832,8 @@ type Report struct { | |||
| 832 | // answer leaves the fleet's record alone rather than erasing it. In the | 832 | // answer leaves the fleet's record alone rather than erasing it. In the |
| 833 | // report rather than Hello for the same reason guest_cidr is: it can change | 833 | // report rather than Hello for the same reason guest_cidr is: it can change |
| 834 | // while an agent stays connected. | 834 | // while an agent stays connected. |
| 835 | HostUplinkAddr string `protobuf:"bytes,10,opt,name=host_uplink_addr,json=hostUplinkAddr,proto3" json:"host_uplink_addr,omitempty"` | 835 | HostUplinkAddr string `protobuf:"bytes,10,opt,name=host_uplink_addr,json=hostUplinkAddr,proto3" json:"host_uplink_addr,omitempty"` |
| 836 | Volumes []*VolumeStatus `protobuf:"bytes,11,rep,name=volumes,proto3" json:"volumes,omitempty"` | ||
| 836 | unknownFields protoimpl.UnknownFields | 837 | unknownFields protoimpl.UnknownFields |
| 837 | sizeCache protoimpl.SizeCache | 838 | sizeCache protoimpl.SizeCache |
| 838 | } | 839 | } |
| @@ -937,6 +938,13 @@ func (x *Report) GetHostUplinkAddr() string { | |||
| 937 | return "" | 938 | return "" |
| 938 | } | 939 | } |
| 939 | 940 | ||
| 941 | func (x *Report) GetVolumes() []*VolumeStatus { | ||
| 942 | if x != nil { | ||
| 943 | return x.Volumes | ||
| 944 | } | ||
| 945 | return nil | ||
| 946 | } | ||
| 947 | |||
| 940 | // VMSpec is the half of a VM the control plane owns. See VMStatus. | 948 | // VMSpec is the half of a VM the control plane owns. See VMStatus. |
| 941 | type VMSpec struct { | 949 | type VMSpec struct { |
| 942 | state protoimpl.MessageState `protogen:"open.v1"` | 950 | state protoimpl.MessageState `protogen:"open.v1"` |
| @@ -977,7 +985,11 @@ type VMSpec struct { | |||
| 977 | // this field know). A name is only ever placed on a host that advertised | 985 | // this field know). A name is only ever placed on a host that advertised |
| 978 | // it (see Hello.host_networks); an agent that has the name but no longer | 986 | // it (see Hello.host_networks); an agent that has the name but no longer |
| 979 | // the configuration fails the VM legibly rather than silently NAT-ing it. | 987 | // the configuration fails the VM legibly rather than silently NAT-ing it. |
| 980 | Network string `protobuf:"bytes,20,opt,name=network,proto3" json:"network,omitempty"` | 988 | Network string `protobuf:"bytes,20,opt,name=network,proto3" json:"network,omitempty"` |
| 989 | // volume_ids are the bound volumes to attach after root and seed, in this | ||
| 990 | // order — the first is /dev/vdc. Every id names a VolumeSpec in the same | ||
| 991 | // snapshot. | ||
| 992 | VolumeIds []string `protobuf:"bytes,21,rep,name=volume_ids,json=volumeIds,proto3" json:"volume_ids,omitempty"` | ||
| 981 | unknownFields protoimpl.UnknownFields | 993 | unknownFields protoimpl.UnknownFields |
| 982 | sizeCache protoimpl.SizeCache | 994 | sizeCache protoimpl.SizeCache |
| 983 | } | 995 | } |
| @@ -1124,6 +1136,13 @@ func (x *VMSpec) GetNetwork() string { | |||
| 1124 | return "" | 1136 | return "" |
| 1125 | } | 1137 | } |
| 1126 | 1138 | ||
| 1139 | func (x *VMSpec) GetVolumeIds() []string { | ||
| 1140 | if x != nil { | ||
| 1141 | return x.VolumeIds | ||
| 1142 | } | ||
| 1143 | return nil | ||
| 1144 | } | ||
| 1145 | |||
| 1127 | // Snapshot is the FULL spec for one host; the agent converges toward it. | 1146 | // Snapshot is the FULL spec for one host; the agent converges toward it. |
| 1128 | type Snapshot struct { | 1147 | type Snapshot struct { |
| 1129 | state protoimpl.MessageState `protogen:"open.v1"` | 1148 | state protoimpl.MessageState `protogen:"open.v1"` |
| @@ -1131,7 +1150,7 @@ type Snapshot struct { | |||
| 1131 | Vms []*VMSpec `protobuf:"bytes,2,rep,name=vms,proto3" json:"vms,omitempty"` // FULL set for this host, including tombstoned | 1150 | Vms []*VMSpec `protobuf:"bytes,2,rep,name=vms,proto3" json:"vms,omitempty"` // FULL set for this host, including tombstoned |
| 1132 | AgentUpgrade *AgentUpgrade `protobuf:"bytes,3,opt,name=agent_upgrade,json=agentUpgrade,proto3" json:"agent_upgrade,omitempty"` // optional operator-initiated agent self-upgrade | 1151 | AgentUpgrade *AgentUpgrade `protobuf:"bytes,3,opt,name=agent_upgrade,json=agentUpgrade,proto3" json:"agent_upgrade,omitempty"` // optional operator-initiated agent self-upgrade |
| 1133 | Exposures []*ExposureSpec `protobuf:"bytes,4,rep,name=exposures,proto3" json:"exposures,omitempty"` // FULL set for this host | 1152 | Exposures []*ExposureSpec `protobuf:"bytes,4,rep,name=exposures,proto3" json:"exposures,omitempty"` // FULL set for this host |
| 1134 | // 5 is taken by volumes in the next change. | 1153 | Volumes []*VolumeSpec `protobuf:"bytes,5,rep,name=volumes,proto3" json:"volumes,omitempty"` // FULL set for this host |
| 1135 | // min_agent_version is the lowest agent release that understands every | 1154 | // min_agent_version is the lowest agent release that understands every |
| 1136 | // field in this snapshot. An agent below it fails every VM here with a | 1155 | // field in this snapshot. An agent below it fails every VM here with a |
| 1137 | // legible reason rather than materialising a spec it only half-reads. | 1156 | // legible reason rather than materialising a spec it only half-reads. |
| @@ -1199,6 +1218,13 @@ func (x *Snapshot) GetExposures() []*ExposureSpec { | |||
| 1199 | return nil | 1218 | return nil |
| 1200 | } | 1219 | } |
| 1201 | 1220 | ||
| 1221 | func (x *Snapshot) GetVolumes() []*VolumeSpec { | ||
| 1222 | if x != nil { | ||
| 1223 | return x.Volumes | ||
| 1224 | } | ||
| 1225 | return nil | ||
| 1226 | } | ||
| 1227 | |||
| 1202 | func (x *Snapshot) GetMinAgentVersion() string { | 1228 | func (x *Snapshot) GetMinAgentVersion() string { |
| 1203 | if x != nil { | 1229 | if x != nil { |
| 1204 | return x.MinAgentVersion | 1230 | return x.MinAgentVersion |
| @@ -1718,6 +1744,132 @@ func (x *ExposureSessions) GetDropped() int64 { | |||
| 1718 | return 0 | 1744 | return 0 |
| 1719 | } | 1745 | } |
| 1720 | 1746 | ||
| 1747 | // VolumeSpec is one volume the control plane has placed on this host. The | ||
| 1748 | // agent keeps a sparse raw file for it at volumes/<volume_id>/disk.raw, beside | ||
| 1749 | // vms/ and never inside a VM's directory: a VM's directory dies with the VM, | ||
| 1750 | // a volume's does not. Snapshot.volumes is the FULL set for the host. | ||
| 1751 | type VolumeSpec struct { | ||
| 1752 | state protoimpl.MessageState `protogen:"open.v1"` | ||
| 1753 | VolumeId string `protobuf:"bytes,1,opt,name=volume_id,json=volumeId,proto3" json:"volume_id,omitempty"` | ||
| 1754 | SizeGb int64 `protobuf:"varint,2,opt,name=size_gb,json=sizeGb,proto3" json:"size_gb,omitempty"` | ||
| 1755 | Tombstoned bool `protobuf:"varint,3,opt,name=tombstoned,proto3" json:"tombstoned,omitempty"` // delete the file after grace; set only once no VM references it | ||
| 1756 | unknownFields protoimpl.UnknownFields | ||
| 1757 | sizeCache protoimpl.SizeCache | ||
| 1758 | } | ||
| 1759 | |||
| 1760 | func (x *VolumeSpec) Reset() { | ||
| 1761 | *x = VolumeSpec{} | ||
| 1762 | mi := &file_proto_eitri_v1_sync_proto_msgTypes[19] | ||
| 1763 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | ||
| 1764 | ms.StoreMessageInfo(mi) | ||
| 1765 | } | ||
| 1766 | |||
| 1767 | func (x *VolumeSpec) String() string { | ||
| 1768 | return protoimpl.X.MessageStringOf(x) | ||
| 1769 | } | ||
| 1770 | |||
| 1771 | func (*VolumeSpec) ProtoMessage() {} | ||
| 1772 | |||
| 1773 | func (x *VolumeSpec) ProtoReflect() protoreflect.Message { | ||
| 1774 | mi := &file_proto_eitri_v1_sync_proto_msgTypes[19] | ||
| 1775 | if x != nil { | ||
| 1776 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | ||
| 1777 | if ms.LoadMessageInfo() == nil { | ||
| 1778 | ms.StoreMessageInfo(mi) | ||
| 1779 | } | ||
| 1780 | return ms | ||
| 1781 | } | ||
| 1782 | return mi.MessageOf(x) | ||
| 1783 | } | ||
| 1784 | |||
| 1785 | // Deprecated: Use VolumeSpec.ProtoReflect.Descriptor instead. | ||
| 1786 | func (*VolumeSpec) Descriptor() ([]byte, []int) { | ||
| 1787 | return file_proto_eitri_v1_sync_proto_rawDescGZIP(), []int{19} | ||
| 1788 | } | ||
| 1789 | |||
| 1790 | func (x *VolumeSpec) GetVolumeId() string { | ||
| 1791 | if x != nil { | ||
| 1792 | return x.VolumeId | ||
| 1793 | } | ||
| 1794 | return "" | ||
| 1795 | } | ||
| 1796 | |||
| 1797 | func (x *VolumeSpec) GetSizeGb() int64 { | ||
| 1798 | if x != nil { | ||
| 1799 | return x.SizeGb | ||
| 1800 | } | ||
| 1801 | return 0 | ||
| 1802 | } | ||
| 1803 | |||
| 1804 | func (x *VolumeSpec) GetTombstoned() bool { | ||
| 1805 | if x != nil { | ||
| 1806 | return x.Tombstoned | ||
| 1807 | } | ||
| 1808 | return false | ||
| 1809 | } | ||
| 1810 | |||
| 1811 | // VolumeStatus is what the host found on its disk for one volume id — | ||
| 1812 | // including ids the snapshot did not name, which are reported and kept. | ||
| 1813 | type VolumeStatus struct { | ||
| 1814 | state protoimpl.MessageState `protogen:"open.v1"` | ||
| 1815 | VolumeId string `protobuf:"bytes,1,opt,name=volume_id,json=volumeId,proto3" json:"volume_id,omitempty"` | ||
| 1816 | Present bool `protobuf:"varint,2,opt,name=present,proto3" json:"present,omitempty"` | ||
| 1817 | SizeGb int64 `protobuf:"varint,3,opt,name=size_gb,json=sizeGb,proto3" json:"size_gb,omitempty"` // as found, not as asked: a guest filesystem sits on it, so it is never resized | ||
| 1818 | unknownFields protoimpl.UnknownFields | ||
| 1819 | sizeCache protoimpl.SizeCache | ||
| 1820 | } | ||
| 1821 | |||
| 1822 | func (x *VolumeStatus) Reset() { | ||
| 1823 | *x = VolumeStatus{} | ||
| 1824 | mi := &file_proto_eitri_v1_sync_proto_msgTypes[20] | ||
| 1825 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | ||
| 1826 | ms.StoreMessageInfo(mi) | ||
| 1827 | } | ||
| 1828 | |||
| 1829 | func (x *VolumeStatus) String() string { | ||
| 1830 | return protoimpl.X.MessageStringOf(x) | ||
| 1831 | } | ||
| 1832 | |||
| 1833 | func (*VolumeStatus) ProtoMessage() {} | ||
| 1834 | |||
| 1835 | func (x *VolumeStatus) ProtoReflect() protoreflect.Message { | ||
| 1836 | mi := &file_proto_eitri_v1_sync_proto_msgTypes[20] | ||
| 1837 | if x != nil { | ||
| 1838 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | ||
| 1839 | if ms.LoadMessageInfo() == nil { | ||
| 1840 | ms.StoreMessageInfo(mi) | ||
| 1841 | } | ||
| 1842 | return ms | ||
| 1843 | } | ||
| 1844 | return mi.MessageOf(x) | ||
| 1845 | } | ||
| 1846 | |||
| 1847 | // Deprecated: Use VolumeStatus.ProtoReflect.Descriptor instead. | ||
| 1848 | func (*VolumeStatus) Descriptor() ([]byte, []int) { | ||
| 1849 | return file_proto_eitri_v1_sync_proto_rawDescGZIP(), []int{20} | ||
| 1850 | } | ||
| 1851 | |||
| 1852 | func (x *VolumeStatus) GetVolumeId() string { | ||
| 1853 | if x != nil { | ||
| 1854 | return x.VolumeId | ||
| 1855 | } | ||
| 1856 | return "" | ||
| 1857 | } | ||
| 1858 | |||
| 1859 | func (x *VolumeStatus) GetPresent() bool { | ||
| 1860 | if x != nil { | ||
| 1861 | return x.Present | ||
| 1862 | } | ||
| 1863 | return false | ||
| 1864 | } | ||
| 1865 | |||
| 1866 | func (x *VolumeStatus) GetSizeGb() int64 { | ||
| 1867 | if x != nil { | ||
| 1868 | return x.SizeGb | ||
| 1869 | } | ||
| 1870 | return 0 | ||
| 1871 | } | ||
| 1872 | |||
| 1721 | var File_proto_eitri_v1_sync_proto protoreflect.FileDescriptor | 1873 | var File_proto_eitri_v1_sync_proto protoreflect.FileDescriptor |
| 1722 | 1874 | ||
| 1723 | const file_proto_eitri_v1_sync_proto_rawDesc = "" + | 1875 | const file_proto_eitri_v1_sync_proto_rawDesc = "" + |
| @@ -1790,7 +1942,7 @@ const file_proto_eitri_v1_sync_proto_rawDesc = "" + | |||
| 1790 | "\x04name\x18\x02 \x01(\tR\x04name\x12\x1f\n" + | 1942 | "\x04name\x18\x02 \x01(\tR\x04name\x12\x1f\n" + |
| 1791 | "\vvmspec_json\x18\x03 \x01(\fR\n" + | 1943 | "\vvmspec_json\x18\x03 \x01(\fR\n" + |
| 1792 | "vmspecJson\x12&\n" + | 1944 | "vmspecJson\x12&\n" + |
| 1793 | "\x0fdestroy_at_unix\x18\x04 \x01(\x03R\rdestroyAtUnix\"\xba\x03\n" + | 1945 | "\x0fdestroy_at_unix\x18\x04 \x01(\x03R\rdestroyAtUnix\"\xec\x03\n" + |
| 1794 | "\x06Report\x12$\n" + | 1946 | "\x06Report\x12$\n" + |
| 1795 | "\x03vms\x18\x01 \x03(\v2\x12.eitri.v1.VMStatusR\x03vms\x12\x1c\n" + | 1947 | "\x03vms\x18\x01 \x03(\v2\x12.eitri.v1.VMStatusR\x03vms\x12\x1c\n" + |
| 1796 | "\tdestroyed\x18\x02 \x03(\tR\tdestroyed\x129\n" + | 1948 | "\tdestroyed\x18\x02 \x03(\tR\tdestroyed\x129\n" + |
| @@ -1803,7 +1955,8 @@ const file_proto_eitri_v1_sync_proto_rawDesc = "" + | |||
| 1803 | "guest_cidr\x18\b \x01(\tR\tguestCidr\x126\n" + | 1955 | "guest_cidr\x18\b \x01(\tR\tguestCidr\x126\n" + |
| 1804 | "\texposures\x18\t \x03(\v2\x18.eitri.v1.ExposureStatusR\texposures\x12(\n" + | 1956 | "\texposures\x18\t \x03(\v2\x18.eitri.v1.ExposureStatusR\texposures\x12(\n" + |
| 1805 | "\x10host_uplink_addr\x18\n" + | 1957 | "\x10host_uplink_addr\x18\n" + |
| 1806 | " \x01(\tR\x0ehostUplinkAddr\"\xb9\x04\n" + | 1958 | " \x01(\tR\x0ehostUplinkAddr\x120\n" + |
| 1959 | "\avolumes\x18\v \x03(\v2\x16.eitri.v1.VolumeStatusR\avolumes\"\xd8\x04\n" + | ||
| 1807 | "\x06VMSpec\x12\x13\n" + | 1960 | "\x06VMSpec\x12\x13\n" + |
| 1808 | "\x05vm_id\x18\x01 \x01(\tR\x04vmId\x12\x12\n" + | 1961 | "\x05vm_id\x18\x01 \x01(\tR\x04vmId\x12\x12\n" + |
| 1809 | "\x04name\x18\x02 \x01(\tR\x04name\x12\x1b\n" + | 1962 | "\x04name\x18\x02 \x01(\tR\x04name\x12\x1b\n" + |
| @@ -1827,12 +1980,15 @@ const file_proto_eitri_v1_sync_proto_rawDesc = "" + | |||
| 1827 | "\rssh_host_cert\x18\x11 \x01(\tR\vsshHostCert\x12<\n" + | 1980 | "\rssh_host_cert\x18\x11 \x01(\tR\vsshHostCert\x12<\n" + |
| 1828 | "\x1bssh_user_ca_authorized_keys\x18\x12 \x03(\tR\x17sshUserCaAuthorizedKeys\x12,\n" + | 1981 | "\x1bssh_user_ca_authorized_keys\x18\x12 \x03(\tR\x17sshUserCaAuthorizedKeys\x12,\n" + |
| 1829 | "\x12host_cert_required\x18\x13 \x01(\bR\x10hostCertRequired\x12\x18\n" + | 1982 | "\x12host_cert_required\x18\x13 \x01(\bR\x10hostCertRequired\x12\x18\n" + |
| 1830 | "\anetwork\x18\x14 \x01(\tR\anetworkJ\x04\b\r\x10\x0eJ\x04\b\x0e\x10\x0fJ\x04\b\x0f\x10\x10J\x04\b\x10\x10\x11R\x10ssh_host_key_pem\"\xe3\x01\n" + | 1983 | "\anetwork\x18\x14 \x01(\tR\anetwork\x12\x1d\n" + |
| 1984 | "\n" + | ||
| 1985 | "volume_ids\x18\x15 \x03(\tR\tvolumeIdsJ\x04\b\r\x10\x0eJ\x04\b\x0e\x10\x0fJ\x04\b\x0f\x10\x10J\x04\b\x10\x10\x11R\x10ssh_host_key_pem\"\x93\x02\n" + | ||
| 1831 | "\bSnapshot\x12\x14\n" + | 1986 | "\bSnapshot\x12\x14\n" + |
| 1832 | "\x05epoch\x18\x01 \x01(\x04R\x05epoch\x12\"\n" + | 1987 | "\x05epoch\x18\x01 \x01(\x04R\x05epoch\x12\"\n" + |
| 1833 | "\x03vms\x18\x02 \x03(\v2\x10.eitri.v1.VMSpecR\x03vms\x12;\n" + | 1988 | "\x03vms\x18\x02 \x03(\v2\x10.eitri.v1.VMSpecR\x03vms\x12;\n" + |
| 1834 | "\ragent_upgrade\x18\x03 \x01(\v2\x16.eitri.v1.AgentUpgradeR\fagentUpgrade\x124\n" + | 1989 | "\ragent_upgrade\x18\x03 \x01(\v2\x16.eitri.v1.AgentUpgradeR\fagentUpgrade\x124\n" + |
| 1835 | "\texposures\x18\x04 \x03(\v2\x16.eitri.v1.ExposureSpecR\texposures\x12*\n" + | 1990 | "\texposures\x18\x04 \x03(\v2\x16.eitri.v1.ExposureSpecR\texposures\x12.\n" + |
| 1991 | "\avolumes\x18\x05 \x03(\v2\x14.eitri.v1.VolumeSpecR\avolumes\x12*\n" + | ||
| 1836 | "\x11min_agent_version\x18\x06 \x01(\tR\x0fminAgentVersion\"R\n" + | 1992 | "\x11min_agent_version\x18\x06 \x01(\tR\x0fminAgentVersion\"R\n" + |
| 1837 | "\fAgentUpgrade\x12\x18\n" + | 1993 | "\fAgentUpgrade\x12\x18\n" + |
| 1838 | "\aversion\x18\x01 \x01(\tR\aversion\x12\x10\n" + | 1994 | "\aversion\x18\x01 \x01(\tR\aversion\x12\x10\n" + |
| @@ -1864,7 +2020,18 @@ const file_proto_eitri_v1_sync_proto_rawDesc = "" + | |||
| 1864 | "\x10ExposureSessions\x12\x16\n" + | 2020 | "\x10ExposureSessions\x12\x16\n" + |
| 1865 | "\x06active\x18\x01 \x01(\x03R\x06active\x12\x18\n" + | 2021 | "\x06active\x18\x01 \x01(\x03R\x06active\x12\x18\n" + |
| 1866 | "\arefused\x18\x02 \x01(\x03R\arefused\x12\x18\n" + | 2022 | "\arefused\x18\x02 \x01(\x03R\arefused\x12\x18\n" + |
| 1867 | "\adropped\x18\x03 \x01(\x03R\adroppedB&Z$github.com/a73x/eitri/internal/pb;pbb\x06proto3" | 2023 | "\adropped\x18\x03 \x01(\x03R\adropped\"b\n" + |
| 2024 | "\n" + | ||
| 2025 | "VolumeSpec\x12\x1b\n" + | ||
| 2026 | "\tvolume_id\x18\x01 \x01(\tR\bvolumeId\x12\x17\n" + | ||
| 2027 | "\asize_gb\x18\x02 \x01(\x03R\x06sizeGb\x12\x1e\n" + | ||
| 2028 | "\n" + | ||
| 2029 | "tombstoned\x18\x03 \x01(\bR\n" + | ||
| 2030 | "tombstoned\"^\n" + | ||
| 2031 | "\fVolumeStatus\x12\x1b\n" + | ||
| 2032 | "\tvolume_id\x18\x01 \x01(\tR\bvolumeId\x12\x18\n" + | ||
| 2033 | "\apresent\x18\x02 \x01(\bR\apresent\x12\x17\n" + | ||
| 2034 | "\asize_gb\x18\x03 \x01(\x03R\x06sizeGbB&Z$github.com/a73x/eitri/internal/pb;pbb\x06proto3" | ||
| 1868 | 2035 | ||
| 1869 | var ( | 2036 | var ( |
| 1870 | file_proto_eitri_v1_sync_proto_rawDescOnce sync.Once | 2037 | file_proto_eitri_v1_sync_proto_rawDescOnce sync.Once |
| @@ -1878,7 +2045,7 @@ func file_proto_eitri_v1_sync_proto_rawDescGZIP() []byte { | |||
| 1878 | return file_proto_eitri_v1_sync_proto_rawDescData | 2045 | return file_proto_eitri_v1_sync_proto_rawDescData |
| 1879 | } | 2046 | } |
| 1880 | 2047 | ||
| 1881 | var file_proto_eitri_v1_sync_proto_msgTypes = make([]protoimpl.MessageInfo, 19) | 2048 | var file_proto_eitri_v1_sync_proto_msgTypes = make([]protoimpl.MessageInfo, 21) |
| 1882 | var file_proto_eitri_v1_sync_proto_goTypes = []any{ | 2049 | var file_proto_eitri_v1_sync_proto_goTypes = []any{ |
| 1883 | (*AgentMessage)(nil), // 0: eitri.v1.AgentMessage | 2050 | (*AgentMessage)(nil), // 0: eitri.v1.AgentMessage |
| 1884 | (*ServerMessage)(nil), // 1: eitri.v1.ServerMessage | 2051 | (*ServerMessage)(nil), // 1: eitri.v1.ServerMessage |
| @@ -1899,6 +2066,8 @@ var file_proto_eitri_v1_sync_proto_goTypes = []any{ | |||
| 1899 | (*ExposureSpec)(nil), // 16: eitri.v1.ExposureSpec | 2066 | (*ExposureSpec)(nil), // 16: eitri.v1.ExposureSpec |
| 1900 | (*ExposureStatus)(nil), // 17: eitri.v1.ExposureStatus | 2067 | (*ExposureStatus)(nil), // 17: eitri.v1.ExposureStatus |
| 1901 | (*ExposureSessions)(nil), // 18: eitri.v1.ExposureSessions | 2068 | (*ExposureSessions)(nil), // 18: eitri.v1.ExposureSessions |
| 2069 | (*VolumeSpec)(nil), // 19: eitri.v1.VolumeSpec | ||
| 2070 | (*VolumeStatus)(nil), // 20: eitri.v1.VolumeStatus | ||
| 1902 | } | 2071 | } |
| 1903 | var file_proto_eitri_v1_sync_proto_depIdxs = []int32{ | 2072 | var file_proto_eitri_v1_sync_proto_depIdxs = []int32{ |
| 1904 | 2, // 0: eitri.v1.AgentMessage.hello:type_name -> eitri.v1.Hello | 2073 | 2, // 0: eitri.v1.AgentMessage.hello:type_name -> eitri.v1.Hello |
| @@ -1915,15 +2084,17 @@ var file_proto_eitri_v1_sync_proto_depIdxs = []int32{ | |||
| 1915 | 3, // 11: eitri.v1.Report.capacity:type_name -> eitri.v1.Capacity | 2084 | 3, // 11: eitri.v1.Report.capacity:type_name -> eitri.v1.Capacity |
| 1916 | 5, // 12: eitri.v1.Report.metrics:type_name -> eitri.v1.HostMetrics | 2085 | 5, // 12: eitri.v1.Report.metrics:type_name -> eitri.v1.HostMetrics |
| 1917 | 17, // 13: eitri.v1.Report.exposures:type_name -> eitri.v1.ExposureStatus | 2086 | 17, // 13: eitri.v1.Report.exposures:type_name -> eitri.v1.ExposureStatus |
| 1918 | 9, // 14: eitri.v1.Snapshot.vms:type_name -> eitri.v1.VMSpec | 2087 | 20, // 14: eitri.v1.Report.volumes:type_name -> eitri.v1.VolumeStatus |
| 1919 | 11, // 15: eitri.v1.Snapshot.agent_upgrade:type_name -> eitri.v1.AgentUpgrade | 2088 | 9, // 15: eitri.v1.Snapshot.vms:type_name -> eitri.v1.VMSpec |
| 1920 | 16, // 16: eitri.v1.Snapshot.exposures:type_name -> eitri.v1.ExposureSpec | 2089 | 11, // 16: eitri.v1.Snapshot.agent_upgrade:type_name -> eitri.v1.AgentUpgrade |
| 1921 | 18, // 17: eitri.v1.ExposureStatus.sessions:type_name -> eitri.v1.ExposureSessions | 2090 | 16, // 17: eitri.v1.Snapshot.exposures:type_name -> eitri.v1.ExposureSpec |
| 1922 | 18, // [18:18] is the sub-list for method output_type | 2091 | 19, // 18: eitri.v1.Snapshot.volumes:type_name -> eitri.v1.VolumeSpec |
| 1923 | 18, // [18:18] is the sub-list for method input_type | 2092 | 18, // 19: eitri.v1.ExposureStatus.sessions:type_name -> eitri.v1.ExposureSessions |
| 1924 | 18, // [18:18] is the sub-list for extension type_name | 2093 | 20, // [20:20] is the sub-list for method output_type |
| 1925 | 18, // [18:18] is the sub-list for extension extendee | 2094 | 20, // [20:20] is the sub-list for method input_type |
| 1926 | 0, // [0:18] is the sub-list for field type_name | 2095 | 20, // [20:20] is the sub-list for extension type_name |
| 2096 | 20, // [20:20] is the sub-list for extension extendee | ||
| 2097 | 0, // [0:20] is the sub-list for field type_name | ||
| 1927 | } | 2098 | } |
| 1928 | 2099 | ||
| 1929 | func init() { file_proto_eitri_v1_sync_proto_init() } | 2100 | func init() { file_proto_eitri_v1_sync_proto_init() } |
| @@ -1948,7 +2119,7 @@ func file_proto_eitri_v1_sync_proto_init() { | |||
| 1948 | GoPackagePath: reflect.TypeOf(x{}).PkgPath(), | 2119 | GoPackagePath: reflect.TypeOf(x{}).PkgPath(), |
| 1949 | RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_eitri_v1_sync_proto_rawDesc), len(file_proto_eitri_v1_sync_proto_rawDesc)), | 2120 | RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_eitri_v1_sync_proto_rawDesc), len(file_proto_eitri_v1_sync_proto_rawDesc)), |
| 1950 | NumEnums: 0, | 2121 | NumEnums: 0, |
| 1951 | NumMessages: 19, | 2122 | NumMessages: 21, |
| 1952 | NumExtensions: 0, | 2123 | NumExtensions: 0, |
| 1953 | NumServices: 0, | 2124 | NumServices: 0, |
| 1954 | }, | 2125 | }, |
internal/server/api/api.go
| Old | New | ||
|---|---|---|---|
| @@ -29,6 +29,13 @@ import ( | |||
| 29 | "github.com/a73x/eitri/internal/version" | 29 | "github.com/a73x/eitri/internal/version" |
| 30 | ) | 30 | ) |
| 31 | 31 | ||
| 32 | // volumesFeature is the agent floor a volume-bearing create is judged against. | ||
| 33 | // It is a var, not the constant its siblings use, because the floor is a | ||
| 34 | // RELEASE TAG: until volumes ship there is no agent above it, so every test | ||
| 35 | // that exercises the admission has to name a floor its fixtures can clear. | ||
| 36 | // Nothing but a test writes it. | ||
| 37 | var volumesFeature = release.Volumes | ||
| 38 | |||
| 32 | // DefaultImage is the image applied to one-click VM creates. | 39 | // DefaultImage is the image applied to one-click VM creates. |
| 33 | type DefaultImage struct { | 40 | type DefaultImage struct { |
| 34 | URL string | 41 | URL string |
| @@ -89,6 +96,13 @@ type API struct { | |||
| 89 | // wires it, which happens only when the jump gate is on; nil ⇒ the | 96 | // wires it, which happens only when the jump gate is on; nil ⇒ the |
| 90 | // delegation routes answer 503. | 97 | // delegation routes answer 503. |
| 91 | delegations *delegation.Keyring | 98 | delegations *delegation.Keyring |
| 99 | // volumeStuck remembers, per decommissioning host, the volume count the | ||
| 100 | // last "stuck on volumes" line reported. The sweep runs every two seconds | ||
| 101 | // and a volume never drains itself, so logging the refusal every pass would | ||
| 102 | // bury the log; logging only when the number CHANGES says it once when the | ||
| 103 | // decommission stalls and once more each time the operator deletes a claim. | ||
| 104 | // Touched only from StartBackground's goroutine. | ||
| 105 | volumeStuck map[string]int | ||
| 92 | } | 106 | } |
| 93 | 107 | ||
| 94 | // URL renders an API path as a full URL a caller can actually dial, using the | 108 | // URL renders an API path as a full URL a caller can actually dial, using the |
| @@ -252,27 +266,65 @@ func (a *API) sweepAbandonedVMs(now time.Time) bool { | |||
| 252 | 266 | ||
| 253 | // sweepDecommissioned finalizes any decommissioning host with no VM rows left | 267 | // sweepDecommissioned finalizes any decommissioning host with no VM rows left |
| 254 | // (fully drained). Returns true if it removed at least one host. | 268 | // (fully drained). Returns true if it removed at least one host. |
| 269 | // | ||
| 270 | // One refusal is not like the others. An undrained VM is a decommission still | ||
| 271 | // in progress and silence is correct — the next pass, two seconds later, is the | ||
| 272 | // retry. A held volume is a decommission that will NEVER finish on its own: a | ||
| 273 | // volume outlives its guests by design, so no amount of waiting releases it and | ||
| 274 | // the host sits in `decommissioning` until an operator deletes the claims. That | ||
| 275 | // one gets said out loud, and the map keeps it from being said 30 times a | ||
| 276 | // minute forever. | ||
| 255 | func (a *API) sweepDecommissioned() bool { | 277 | func (a *API) sweepDecommissioned() bool { |
| 256 | hosts, err := a.st.ListHosts() | 278 | hosts, err := a.st.ListHosts() |
| 257 | if err != nil { | 279 | if err != nil { |
| 258 | return false | 280 | return false |
| 259 | } | 281 | } |
| 260 | removed := false | 282 | removed := false |
| 283 | // Rebuilt every sweep, so a host that leaves (removed, or forced away) | ||
| 284 | // takes its entry with it instead of leaking one per decommission. | ||
| 285 | stuck := map[string]int{} | ||
| 261 | for _, h := range hosts { | 286 | for _, h := range hosts { |
| 262 | if h.Status != "decommissioning" { | 287 | if h.Status != "decommissioning" { |
| 263 | continue | 288 | continue |
| 264 | } | 289 | } |
| 265 | // RemoveHost re-counts VM rows in-transaction and refuses while any | 290 | // RemoveHost re-counts VM rows in-transaction and refuses while any |
| 266 | // remain, so a pre-check here would only save a wasted call on hosts | 291 | // remain, so a pre-check here would only save a wasted call on hosts |
| 267 | // that aren't yet drained — every RemoveHost error (drained or not) is | 292 | // that aren't yet drained — an undrained VM is handled by simply |
| 268 | // already handled by simply skipping to the next host. | 293 | // skipping to the next host. |
| 269 | if a.st.RemoveHost(h.ID) == nil { | 294 | err := a.st.RemoveHost(h.ID) |
| 295 | switch { | ||
| 296 | case err == nil: | ||
| 270 | removed = true | 297 | removed = true |
| 298 | case errors.Is(err, store.ErrHostHoldsVolumes): | ||
| 299 | n, ids := a.volumesOnHost(h.ID) | ||
| 300 | prev, seen := a.volumeStuck[h.ID] | ||
| 301 | stuck[h.ID] = n | ||
| 302 | if !seen || prev != n { | ||
| 303 | slog.Warn("decommission is stuck: host still holds volumes", | ||
| 304 | "host", h.ID, "volumes", n, "volume_ids", ids, | ||
| 305 | "remedy", "delete the volume claims placed on this host") | ||
| 306 | } | ||
| 271 | } | 307 | } |
| 272 | } | 308 | } |
| 309 | a.volumeStuck = stuck | ||
| 273 | return removed | 310 | return removed |
| 274 | } | 311 | } |
| 275 | 312 | ||
| 313 | // volumesOnHost reports how many volume rows a host still holds and names them, | ||
| 314 | // for the stuck-decommission line. A read failure reports nothing rather than | ||
| 315 | // inventing a number: the count in the log would be the only lie in it. | ||
| 316 | func (a *API) volumesOnHost(hostID string) (int, string) { | ||
| 317 | vols, err := a.st.ListVolumesForHost(hostID) | ||
| 318 | if err != nil { | ||
| 319 | return 0, "" | ||
| 320 | } | ||
| 321 | ids := make([]string, 0, len(vols)) | ||
| 322 | for _, v := range vols { | ||
| 323 | ids = append(ids, v.ID) | ||
| 324 | } | ||
| 325 | return len(vols), strings.Join(ids, ",") | ||
| 326 | } | ||
| 327 | |||
| 276 | // userAuth resolves a request to a tenant principal: PAT bearer first, then | 328 | // userAuth resolves a request to a tenant principal: PAT bearer first, then |
| 277 | // the eitri_session console cookie. There are no other credentials and no | 329 | // the eitri_session console cookie. There are no other credentials and no |
| 278 | // per-route exceptions (spec §3); a request with neither gets 401 and the SPA | 330 | // per-route exceptions (spec §3); a request with neither gets 401 and the SPA |
| @@ -872,6 +924,28 @@ func (a *API) handleCreateVM(w http.ResponseWriter, r *http.Request) { | |||
| 872 | return | 924 | return |
| 873 | } | 925 | } |
| 874 | 926 | ||
| 927 | // Volumes, the fifth refusal of this shape and the first that judges an | ||
| 928 | // OFFLINE host (see refuseBelowFloor): an old agent ignores volume_ids | ||
| 929 | // and boots the guest bare, so the data the tenant meant for the volume | ||
| 930 | // lands on the root disk — the one thing they did not ask for. | ||
| 931 | // | ||
| 932 | // It runs before the capacity block because it feeds it: a pending claim's | ||
| 933 | // bytes are disk this create commits, and the host has to have room for | ||
| 934 | // both. The claims are read against the HOST's tenant, which the authz | ||
| 935 | // gate above has already proven is the caller's own. | ||
| 936 | claimIDs, claimGB, msg, code, err := a.resolveClaims(host.Tenant, req.VolumeClaims) | ||
| 937 | if err != nil { | ||
| 938 | http.Error(w, "internal error", http.StatusInternalServerError) | ||
| 939 | return | ||
| 940 | } | ||
| 941 | if msg != "" { | ||
| 942 | http.Error(w, msg, code) | ||
| 943 | return | ||
| 944 | } | ||
| 945 | if len(claimIDs) > 0 && a.refuseBelowFloor(w, req.HostID, volumesFeature, true) { | ||
| 946 | return | ||
| 947 | } | ||
| 948 | |||
| 875 | // Capacity precondition, the third refusal of this same shape: the request | 949 | // Capacity precondition, the third refusal of this same shape: the request |
| 876 | // is fine, and the host has been told not to serve it. A host whose operator | 950 | // is fine, and the host has been told not to serve it. A host whose operator |
| 877 | // capped it is already holding as much as it may hold cannot boot one more | 951 | // capped it is already holding as much as it may hold cannot boot one more |
| @@ -898,7 +972,10 @@ func (a *API) handleCreateVM(w http.ResponseWriter, r *http.Request) { | |||
| 898 | http.Error(w, "internal error", http.StatusInternalServerError) | 972 | http.Error(w, "internal error", http.StatusInternalServerError) |
| 899 | return | 973 | return |
| 900 | } | 974 | } |
| 901 | want := store.Alloc{VCPUs: req.VCPUs, MemMB: req.MemMB, DiskGB: req.DiskGB} | 975 | // claimGB is the PENDING claims only: a bound claim's volume already |
| 976 | // sits in held (CommittedOnHost counts live volumes), so adding it | ||
| 977 | // here would charge the host twice for disk this VM is re-using. | ||
| 978 | want := store.Alloc{VCPUs: req.VCPUs, MemMB: req.MemMB, DiskGB: req.DiskGB + claimGB} | ||
| 902 | if msg := overCapacityRefusal(host.Name, req.HostID, want, held, hostState.Report); msg != "" { | 979 | if msg := overCapacityRefusal(host.Name, req.HostID, want, held, hostState.Report); msg != "" { |
| 903 | http.Error(w, msg, http.StatusConflict) | 980 | http.Error(w, msg, http.StatusConflict) |
| 904 | return | 981 | return |
| @@ -980,6 +1057,11 @@ func (a *API) handleCreateVM(w http.ResponseWriter, r *http.Request) { | |||
| 980 | DiskGB: req.DiskGB, | 1057 | DiskGB: req.DiskGB, |
| 981 | PowerState: req.PowerState, | 1058 | PowerState: req.PowerState, |
| 982 | Network: req.Network, | 1059 | Network: req.Network, |
| 1060 | |||
| 1061 | // Resolved to ids, in the order the caller named them, which is the | ||
| 1062 | // order the guest will see the devices in. CreateVM binds them inside | ||
| 1063 | // its own transaction — placement and binding are one commit. | ||
| 1064 | VolumeClaimIDs: claimIDs, | ||
| 983 | } | 1065 | } |
| 984 | 1066 | ||
| 985 | // The row carries no host key. A guest's host key is generated by the host | 1067 | // The row carries no host key. A guest's host key is generated by the host |
| @@ -988,6 +1070,14 @@ func (a *API) handleCreateVM(w http.ResponseWriter, r *http.Request) { | |||
| 988 | // (see syncsvc.signAndRecordHostCert). Creating a VM therefore involves no | 1070 | // (see syncsvc.signAndRecordHostCert). Creating a VM therefore involves no |
| 989 | // key material at all, which is why there is nothing here to guard. | 1071 | // key material at all, which is why there is nothing here to guard. |
| 990 | 1072 | ||
| 1073 | // The claim cases below are the same three the admission above already | ||
| 1074 | // checks, re-answered from inside the transaction that is authoritative | ||
| 1075 | // for them: the preflight read and this write are separate moments, and | ||
| 1076 | // another create can attach a claim in between. That is the race the | ||
| 1077 | // attachment table's unique index referees, and this is where its verdict | ||
| 1078 | // is turned back into an answer. | ||
| 1079 | var attached *store.ClaimAttachedError | ||
| 1080 | var pinned *store.ClaimPinnedError | ||
| 991 | if err := a.st.CreateVM(vm); err != nil { | 1081 | if err := a.st.CreateVM(vm); err != nil { |
| 992 | switch { | 1082 | switch { |
| 993 | case errors.Is(err, store.ErrNameTaken): | 1083 | case errors.Is(err, store.ErrNameTaken): |
| @@ -996,13 +1086,21 @@ func (a *API) handleCreateVM(w http.ResponseWriter, r *http.Request) { | |||
| 996 | http.Error(w, "unknown host_id", http.StatusBadRequest) | 1086 | http.Error(w, "unknown host_id", http.StatusBadRequest) |
| 997 | case errors.Is(err, store.ErrHostNotEnrolled): | 1087 | case errors.Is(err, store.ErrHostNotEnrolled): |
| 998 | http.Error(w, "host is not accepting new VMs", http.StatusConflict) | 1088 | http.Error(w, "host is not accepting new VMs", http.StatusConflict) |
| 1089 | case errors.Is(err, store.ErrClaimNotFound): | ||
| 1090 | http.Error(w, "unknown volume claim", http.StatusNotFound) | ||
| 1091 | case errors.As(err, &attached): | ||
| 1092 | http.Error(w, "volume claim "+attached.ClaimID+" is attached to vm "+attached.VMID, http.StatusConflict) | ||
| 1093 | case errors.As(err, &pinned): | ||
| 1094 | http.Error(w, "volume claim "+pinned.ClaimID+" is bound to host "+pinned.HostID+ | ||
| 1095 | "; its data lives there, so a VM using it must be placed there", http.StatusConflict) | ||
| 999 | default: | 1096 | default: |
| 1000 | http.Error(w, "internal error", http.StatusInternalServerError) | 1097 | http.Error(w, "internal error", http.StatusInternalServerError) |
| 1001 | } | 1098 | } |
| 1002 | return | 1099 | return |
| 1003 | } | 1100 | } |
| 1004 | 1101 | ||
| 1005 | a.audit(host.Tenant, "vm.create", map[string]string{"vm_id": id, "name": req.Name, "host_id": req.HostID}) | 1102 | a.audit(host.Tenant, "vm.create", map[string]string{"vm_id": id, "name": req.Name, "host_id": req.HostID, |
| 1103 | "volume_claims": strings.Join(claimIDs, ",")}) | ||
| 1006 | a.hub.Poke(req.HostID) | 1104 | a.hub.Poke(req.HostID) |
| 1007 | a.notif.notify() | 1105 | a.notif.notify() |
| 1008 | writeJSON(w, http.StatusCreated, types.CreateVMResponse{ID: id, Name: req.Name}) | 1106 | writeJSON(w, http.StatusCreated, types.CreateVMResponse{ID: id, Name: req.Name}) |
internal/server/api/api_test.go
| Old | New | ||
|---|---|---|---|
| @@ -987,7 +987,8 @@ func TestEnrollMintsGenerationCredential(t *testing.T) { | |||
| 987 | } | 987 | } |
| 988 | 988 | ||
| 989 | // TestAuditDetailKeysArePinned nails down the KEY SET of every audit detail an | 989 | // TestAuditDetailKeysArePinned nails down the KEY SET of every audit detail an |
| 990 | // action in api.go, tokens.go, and usercas.go emits. AuditEvent.Detail is a | 990 | // action in api.go, volumes.go, events.go, tokens.go, and usercas.go emits. |
| 991 | // AuditEvent.Detail is a | ||
| 991 | // json.RawMessage, so the wire golden marshals it as opaque bytes and cannot | 992 | // json.RawMessage, so the wire golden marshals it as opaque bytes and cannot |
| 992 | // see inside — a rename like host_id→hostId would sail through every other | 993 | // see inside — a rename like host_id→hostId would sail through every other |
| 993 | // test. Here each action is driven for real and its emitted detail decoded, so | 994 | // test. Here each action is driven for real and its emitted detail decoded, so |
| @@ -1012,6 +1013,15 @@ func TestAuditDetailKeysArePinned(t *testing.T) { | |||
| 1012 | require.Equal(t, 204, do(t, "DELETE", ts.URL+"/api/v1/vms/"+vmID, testPAT, nil).StatusCode) | 1013 | require.Equal(t, 204, do(t, "DELETE", ts.URL+"/api/v1/vms/"+vmID, testPAT, nil).StatusCode) |
| 1013 | require.Equal(t, 204, do(t, "POST", ts.URL+"/api/v1/vms/"+vmID+"/restore", testPAT, nil).StatusCode) | 1014 | require.Equal(t, 204, do(t, "POST", ts.URL+"/api/v1/vms/"+vmID+"/restore", testPAT, nil).StatusCode) |
| 1014 | 1015 | ||
| 1016 | // volumes.go: claim storage and give it back. The claim is never attached, | ||
| 1017 | // so the delete is the ordinary path rather than the 409. | ||
| 1018 | resp = do(t, "POST", ts.URL+"/api/v1/volume-claims", testPAT, | ||
| 1019 | map[string]any{"name": "audited-claim", "size_gb": 5}) | ||
| 1020 | require.Equal(t, 201, resp.StatusCode) | ||
| 1021 | var claim map[string]any | ||
| 1022 | require.NoError(t, json.NewDecoder(resp.Body).Decode(&claim)) | ||
| 1023 | require.Equal(t, 204, do(t, "DELETE", ts.URL+"/api/v1/volume-claims/"+claim["id"].(string), testPAT, nil).StatusCode) | ||
| 1024 | |||
| 1015 | // tokens.go: mint then revoke a PAT. | 1025 | // tokens.go: mint then revoke a PAT. |
| 1016 | resp = do(t, "POST", ts.URL+"/api/v1/tokens", testPAT, | 1026 | resp = do(t, "POST", ts.URL+"/api/v1/tokens", testPAT, |
| 1017 | map[string]any{"name": "audited-token", "ttl_seconds": 3600}) | 1027 | map[string]any{"name": "audited-token", "ttl_seconds": 3600}) |
| @@ -1027,6 +1037,12 @@ func TestAuditDetailKeysArePinned(t *testing.T) { | |||
| 1027 | "label": "audit-ca", | 1037 | "label": "audit-ca", |
| 1028 | }).StatusCode) | 1038 | }).StatusCode) |
| 1029 | 1039 | ||
| 1040 | // events.go: the forced decommission, LAST because it destroys the host | ||
| 1041 | // everything above ran on. Only the force shape is pinned — it is the one | ||
| 1042 | // that reports what was lost, and the graceful path's row shares the action | ||
| 1043 | // name, so newest-row-per-action can only hold one of the two. | ||
| 1044 | require.Equal(t, 200, do(t, "DELETE", ts.URL+"/api/v1/hosts/"+hostID+"?force=true", testPAT, nil).StatusCode) | ||
| 1045 | |||
| 1030 | rows, err := st.ListAudit(testTenant, 100) | 1046 | rows, err := st.ListAudit(testTenant, 100) |
| 1031 | require.NoError(t, err) | 1047 | require.NoError(t, err) |
| 1032 | // Newest row per action wins; the detail shape is one per action. | 1048 | // Newest row per action wins; the detail shape is one per action. |
| @@ -1046,10 +1062,20 @@ func TestAuditDetailKeysArePinned(t *testing.T) { | |||
| 1046 | } | 1062 | } |
| 1047 | 1063 | ||
| 1048 | want := map[string][]string{ | 1064 | want := map[string][]string{ |
| 1049 | "vm.create": {"vm_id", "name", "host_id"}, | 1065 | // volume_claims is on every vm.create, empty when the VM asked for |
| 1050 | "vm.power": {"vm_id", "name", "power"}, | 1066 | // none: "this guest was given no volumes" is a fact worth recording, |
| 1051 | "vm.delete": {"vm_id", "name"}, | 1067 | // and a key that comes and goes is a shape no reader can rely on. |
| 1052 | "vm.restore": {"vm_id", "name"}, | 1068 | "vm.create": {"vm_id", "name", "host_id", "volume_claims"}, |
| 1069 | "vm.power": {"vm_id", "name", "power"}, | ||
| 1070 | "vm.delete": {"vm_id", "name"}, | ||
| 1071 | "vm.restore": {"vm_id", "name"}, | ||
| 1072 | "volume_claim.create": {"claim_id", "name", "size_gb"}, | ||
| 1073 | "volume_claim.delete": {"claim_id", "name", "volume_id"}, | ||
| 1074 | // Force is the one path that destroys data on purpose: its row is the | ||
| 1075 | // only surviving record of which volumes went with the hardware, so | ||
| 1076 | // every count and the id list are part of the pinned shape. | ||
| 1077 | "host.decommission": {"host_id", "remote", "force", "vms_purged", | ||
| 1078 | "volumes_destroyed", "claims_unbound", "volume_ids"}, | ||
| 1053 | "enroll-token.mint": {"remote", "token_hash_prefix"}, | 1079 | "enroll-token.mint": {"remote", "token_hash_prefix"}, |
| 1054 | "api-token.mint": {"token_id", "name"}, | 1080 | "api-token.mint": {"token_id", "name"}, |
| 1055 | "api-token.revoke": {"token_id"}, | 1081 | "api-token.revoke": {"token_id"}, |
internal/server/api/capacity_api_test.go
| Old | New | ||
|---|---|---|---|
| @@ -97,6 +97,36 @@ func TestCreateVMRefusesAHostWithNoRoom(t *testing.T) { | |||
| 97 | } | 97 | } |
| 98 | } | 98 | } |
| 99 | 99 | ||
| 100 | // TestCreateVMCountsAPendingClaimAgainstTheDisk is the disk dimension's other | ||
| 101 | // half. A create that names a pending claim commits the host to that claim's | ||
| 102 | // bytes as surely as to the guest's root disk — the volume is placed by THIS | ||
| 103 | // create — so the two are judged together. Judging only disk_gb would admit a | ||
| 104 | // VM whose volume the host has no room for, and the tenant would find that out | ||
| 105 | // when the agent failed to materialize the file. | ||
| 106 | // | ||
| 107 | // A bound claim is not added: its volume is already on the host, so | ||
| 108 | // CommittedOnHost has counted it, and counting it again would refuse a VM for | ||
| 109 | // disk it is re-using rather than asking for. | ||
| 110 | func TestCreateVMCountsAPendingClaimAgainstTheDisk(t *testing.T) { | ||
| 111 | ts, _, _, reg, _ := newServer(t) | ||
| 112 | floorVolumes(t, "v0.0.7") | ||
| 113 | out := enroll(t, ts) | ||
| 114 | hostID := out["host_id"] | ||
| 115 | onlineAt(reg, hostID, "v0.0.7") | ||
| 116 | agentReportsCappedCapacity(hostID, 8, 8192, 100) | ||
| 117 | claimPost(t, ts, "data", 60) | ||
| 118 | |||
| 119 | resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT, map[string]any{ | ||
| 120 | "host_id": hostID, "name": "with-volume", "vcpus": 1, "mem_mb": 1024, "disk_gb": 50, | ||
| 121 | "volume_claims": []string{"data"}}) | ||
| 122 | require.Equal(t, 409, resp.StatusCode) | ||
| 123 | assert.Contains(t, bodyOf(t, resp), "disk — needs 110GB, already holds 0 of 100GB", | ||
| 124 | "the refusal must count the volume the create would place") | ||
| 125 | |||
| 126 | assert.Equal(t, 201, createVM(t, ts, hostID, "no-volume", 1, 1024, 50).StatusCode, | ||
| 127 | "the same VM without the claim fits") | ||
| 128 | } | ||
| 129 | |||
| 100 | // TestCreateVMRefusalNamesEveryBindingDimension: a VM too big in two ways | 130 | // TestCreateVMRefusalNamesEveryBindingDimension: a VM too big in two ways |
| 101 | // should be resized once, not discovered twice. | 131 | // should be resized once, not discovered twice. |
| 102 | func TestCreateVMRefusalNamesEveryBindingDimension(t *testing.T) { | 132 | func TestCreateVMRefusalNamesEveryBindingDimension(t *testing.T) { |
internal/server/api/client/client.go
| Old | New | ||
|---|---|---|---|
| @@ -40,6 +40,9 @@ type ( | |||
| 40 | DelegationChallenge = types.DelegationChallenge | 40 | DelegationChallenge = types.DelegationChallenge |
| 41 | DelegationRequest = types.DelegationRequest | 41 | DelegationRequest = types.DelegationRequest |
| 42 | Delegation = types.Delegation | 42 | Delegation = types.Delegation |
| 43 | |||
| 44 | VolumeClaim = types.VolumeClaim | ||
| 45 | CreateVolumeClaimRequest = types.CreateVolumeClaimRequest | ||
| 43 | ) | 46 | ) |
| 44 | 47 | ||
| 45 | // DefaultDiskGB is the wire contract's default disk size, re-exported for the | 48 | // DefaultDiskGB is the wire contract's default disk size, re-exported for the |
| @@ -180,6 +183,34 @@ func (c *Client) DeleteExposure(ctx context.Context, id string) error { | |||
| 180 | return c.do(ctx, http.MethodDelete, "/api/v1/exposures/"+url.PathEscape(id), nil, nil) | 183 | return c.do(ctx, http.MethodDelete, "/api/v1/exposures/"+url.PathEscape(id), nil, nil) |
| 181 | } | 184 | } |
| 182 | 185 | ||
| 186 | // CreateVolumeClaim claims sizeGB of durable storage under name. The claim is | ||
| 187 | // Pending — nothing is placed — until the first VM that names it is created, | ||
| 188 | // which is what decides the host its bytes live on. | ||
| 189 | func (c *Client) CreateVolumeClaim(ctx context.Context, name string, sizeGB int64) (VolumeClaim, error) { | ||
| 190 | var out VolumeClaim | ||
| 191 | return out, c.do(ctx, http.MethodPost, "/api/v1/volume-claims", | ||
| 192 | CreateVolumeClaimRequest{Name: name, SizeGB: sizeGB}, &out) | ||
| 193 | } | ||
| 194 | |||
| 195 | // ListVolumeClaims returns the caller tenant's claims, oldest first. | ||
| 196 | func (c *Client) ListVolumeClaims(ctx context.Context) ([]VolumeClaim, error) { | ||
| 197 | var out []VolumeClaim | ||
| 198 | return out, c.do(ctx, http.MethodGet, "/api/v1/volume-claims", nil, &out) | ||
| 199 | } | ||
| 200 | |||
| 201 | // GetVolumeClaim reads one claim by id: where it is bound, which VM holds it, | ||
| 202 | // and whether its host has reported the file. | ||
| 203 | func (c *Client) GetVolumeClaim(ctx context.Context, id string) (VolumeClaim, error) { | ||
| 204 | var out VolumeClaim | ||
| 205 | return out, c.do(ctx, http.MethodGet, "/api/v1/volume-claims/"+url.PathEscape(id), nil, &out) | ||
| 206 | } | ||
| 207 | |||
| 208 | // DeleteVolumeClaim deletes a claim and the data behind it. Refused while a VM | ||
| 209 | // holds it — delete that VM first. | ||
| 210 | func (c *Client) DeleteVolumeClaim(ctx context.Context, id string) error { | ||
| 211 | return c.do(ctx, http.MethodDelete, "/api/v1/volume-claims/"+url.PathEscape(id), nil, nil) | ||
| 212 | } | ||
| 213 | |||
| 183 | // Me returns the signed-in identity (tenant handle + bound email) for the | 214 | // Me returns the signed-in identity (tenant handle + bound email) for the |
| 184 | // credential this client carries. It takes no context — the consumers (smoke | 215 | // credential this client carries. It takes no context — the consumers (smoke |
| 185 | // gate, CLI) call it as a quick synchronous probe; the do timeout bounds it. | 216 | // gate, CLI) call it as a quick synchronous probe; the do timeout bounds it. |
internal/server/api/client/client_test.go
| Old | New | ||
|---|---|---|---|
| @@ -528,6 +528,88 @@ func TestDeleteExposure(t *testing.T) { | |||
| 528 | } | 528 | } |
| 529 | } | 529 | } |
| 530 | 530 | ||
| 531 | func TestCreateVolumeClaim(t *testing.T) { | ||
| 532 | var cap capture | ||
| 533 | srv := serve(t, &cap, http.StatusCreated, | ||
| 534 | `{"id":"c-1","name":"project-data","size_gb":50,"status":"pending",`+ | ||
| 535 | `"host_id":"","vm_id":"","present":null,"created_at":"2026-08-22T12:00:00Z"}`) | ||
| 536 | c := &client.Client{BaseURL: srv.URL, Token: "eitri_pat_x"} | ||
| 537 | |||
| 538 | got, err := c.CreateVolumeClaim(context.Background(), "project-data", 50) | ||
| 539 | if err != nil { | ||
| 540 | t.Fatalf("CreateVolumeClaim: %v", err) | ||
| 541 | } | ||
| 542 | if cap.method != http.MethodPost || cap.path != "/api/v1/volume-claims" { | ||
| 543 | t.Errorf("request = %s %s, want POST /api/v1/volume-claims", cap.method, cap.path) | ||
| 544 | } | ||
| 545 | var sent types.CreateVolumeClaimRequest | ||
| 546 | if err := json.Unmarshal(cap.body, &sent); err != nil { | ||
| 547 | t.Fatalf("decode sent body: %v", err) | ||
| 548 | } | ||
| 549 | if sent.Name != "project-data" || sent.SizeGB != 50 { | ||
| 550 | t.Errorf("sent = %+v, want project-data / 50GB", sent) | ||
| 551 | } | ||
| 552 | if got.ID != "c-1" || got.Status != "pending" { | ||
| 553 | t.Errorf("got = %+v, want the pending claim back", got) | ||
| 554 | } | ||
| 555 | // present is null on the wire and must decode to nil, not false: a client | ||
| 556 | // that flattens the two reports a missing disk for one nobody has looked at. | ||
| 557 | if got.Present != nil { | ||
| 558 | t.Errorf("present = %v, want nil for a claim no host has reported on", *got.Present) | ||
| 559 | } | ||
| 560 | } | ||
| 561 | |||
| 562 | func TestListVolumeClaims(t *testing.T) { | ||
| 563 | var cap capture | ||
| 564 | srv := serve(t, &cap, http.StatusOK, | ||
| 565 | `[{"id":"c-1","name":"data","size_gb":5,"status":"bound","host_id":"h-1","vm_id":"v-1","present":true}]`) | ||
| 566 | c := &client.Client{BaseURL: srv.URL, Token: "eitri_pat_x"} | ||
| 567 | |||
| 568 | got, err := c.ListVolumeClaims(context.Background()) | ||
| 569 | if err != nil { | ||
| 570 | t.Fatalf("ListVolumeClaims: %v", err) | ||
| 571 | } | ||
| 572 | if cap.method != http.MethodGet || cap.path != "/api/v1/volume-claims" { | ||
| 573 | t.Errorf("request = %s %s, want GET /api/v1/volume-claims", cap.method, cap.path) | ||
| 574 | } | ||
| 575 | if len(got) != 1 || got[0].Status != "bound" || got[0].HostID != "h-1" { | ||
| 576 | t.Errorf("got = %+v, want one bound claim on h-1", got) | ||
| 577 | } | ||
| 578 | if got[0].Present == nil || !*got[0].Present { | ||
| 579 | t.Errorf("present = %v, want true", got[0].Present) | ||
| 580 | } | ||
| 581 | } | ||
| 582 | |||
| 583 | func TestGetVolumeClaimEscapesID(t *testing.T) { | ||
| 584 | var cap capture | ||
| 585 | srv := serve(t, &cap, http.StatusOK, `{"id":"c-1","name":"data","status":"pending"}`) | ||
| 586 | c := &client.Client{BaseURL: srv.URL, Token: "eitri_pat_x"} | ||
| 587 | |||
| 588 | got, err := c.GetVolumeClaim(context.Background(), "a b/c") | ||
| 589 | if err != nil { | ||
| 590 | t.Fatalf("GetVolumeClaim: %v", err) | ||
| 591 | } | ||
| 592 | if want := "/api/v1/volume-claims/a%20b%2Fc"; cap.method != http.MethodGet || cap.path != want { | ||
| 593 | t.Errorf("request = %s %s, want GET %s", cap.method, cap.path, want) | ||
| 594 | } | ||
| 595 | if got.ID != "c-1" { | ||
| 596 | t.Errorf("got = %+v, want claim c-1", got) | ||
| 597 | } | ||
| 598 | } | ||
| 599 | |||
| 600 | func TestDeleteVolumeClaim(t *testing.T) { | ||
| 601 | var cap capture | ||
| 602 | srv := serve(t, &cap, http.StatusNoContent, "") | ||
| 603 | c := &client.Client{BaseURL: srv.URL, Token: "eitri_pat_x"} | ||
| 604 | |||
| 605 | if err := c.DeleteVolumeClaim(context.Background(), "c-1"); err != nil { | ||
| 606 | t.Fatalf("DeleteVolumeClaim: %v", err) | ||
| 607 | } | ||
| 608 | if cap.method != http.MethodDelete || cap.path != "/api/v1/volume-claims/c-1" { | ||
| 609 | t.Errorf("request = %s %s, want DELETE /api/v1/volume-claims/c-1", cap.method, cap.path) | ||
| 610 | } | ||
| 611 | } | ||
| 612 | |||
| 531 | // The four delegation calls are one endpoint distinguished by method, so the | 613 | // The four delegation calls are one endpoint distinguished by method, so the |
| 532 | // method is the thing worth pinning: getting it wrong would silently start or | 614 | // method is the thing worth pinning: getting it wrong would silently start or |
| 533 | // end a delegation instead of reading one. | 615 | // end a delegation instead of reading one. |
internal/server/api/decommission_api_test.go
| Old | New | ||
|---|---|---|---|
| @@ -3,11 +3,13 @@ package api | |||
| 3 | import ( | 3 | import ( |
| 4 | "bufio" | 4 | "bufio" |
| 5 | "context" | 5 | "context" |
| 6 | "log/slog" | ||
| 6 | "net/http" | 7 | "net/http" |
| 7 | "strings" | 8 | "strings" |
| 8 | "testing" | 9 | "testing" |
| 9 | "time" | 10 | "time" |
| 10 | 11 | ||
| 12 | "github.com/a73x/eitri/internal/server/store" | ||
| 11 | "github.com/stretchr/testify/assert" | 13 | "github.com/stretchr/testify/assert" |
| 12 | "github.com/stretchr/testify/require" | 14 | "github.com/stretchr/testify/require" |
| 13 | ) | 15 | ) |
| @@ -52,6 +54,51 @@ func TestSweepLeavesHostWithVMs(t *testing.T) { | |||
| 52 | require.Len(t, hosts, 1) | 54 | require.Len(t, hosts, 1) |
| 53 | } | 55 | } |
| 54 | 56 | ||
| 57 | // A decommission blocked on volumes never finishes on its own, so the sweep | ||
| 58 | // has to say so — once, not thirty times a minute. | ||
| 59 | func TestSweepSaysWhenAHostIsStuckOnVolumes(t *testing.T) { | ||
| 60 | ts, st, _, _, a := newServer(t) | ||
| 61 | out := enroll(t, ts) | ||
| 62 | hostID := out["host_id"] | ||
| 63 | |||
| 64 | c, err := st.CreateVolumeClaim(testTenant, "data", 5) | ||
| 65 | require.NoError(t, err) | ||
| 66 | // Straight to the store: no HTTP surface names a claim yet. | ||
| 67 | require.NoError(t, st.CreateVM(store.VM{ | ||
| 68 | ID: "vm-a", HostID: hostID, Name: "vm-a", ImageURL: "u", ImageSHA256: "s", | ||
| 69 | VCPUs: 1, MemMB: 512, DiskGB: 1, PowerState: "running", | ||
| 70 | VolumeClaimIDs: []string{c.ID}, | ||
| 71 | })) | ||
| 72 | |||
| 73 | do(t, "DELETE", ts.URL+"/api/v1/hosts/"+hostID, testPAT, nil) | ||
| 74 | |||
| 75 | var logged strings.Builder | ||
| 76 | restore := slog.Default() | ||
| 77 | slog.SetDefault(slog.New(slog.NewTextHandler(&logged, nil))) | ||
| 78 | t.Cleanup(func() { slog.SetDefault(restore) }) | ||
| 79 | |||
| 80 | // The VM is still there, so the first refusals are the ordinary undrained | ||
| 81 | // kind and stay quiet. | ||
| 82 | assert.False(t, a.sweepDecommissioned()) | ||
| 83 | assert.Empty(t, logged.String(), "an undrained VM is a decommission in progress, not a stuck one") | ||
| 84 | |||
| 85 | // Reap the VM the decommission already tombstoned: now only the volume is | ||
| 86 | // in the way, and that one is terminal. | ||
| 87 | require.NoError(t, st.HardDeleteVM("vm-a", hostID)) | ||
| 88 | assert.False(t, a.sweepDecommissioned(), "a host holding volumes must not be swept") | ||
| 89 | first := logged.String() | ||
| 90 | assert.Contains(t, first, "decommission is stuck") | ||
| 91 | assert.Contains(t, first, "volumes=1") | ||
| 92 | |||
| 93 | // Every later pass finds the same count and stays silent. | ||
| 94 | assert.False(t, a.sweepDecommissioned()) | ||
| 95 | assert.False(t, a.sweepDecommissioned()) | ||
| 96 | assert.Equal(t, first, logged.String(), "the stuck line is said once, not once per tick") | ||
| 97 | |||
| 98 | hosts := decodeJSONKeys(t, do(t, "GET", ts.URL+"/api/v1/hosts", testPAT, nil)) | ||
| 99 | require.Len(t, hosts, 1, "the host stays until its claims are deleted") | ||
| 100 | } | ||
| 101 | |||
| 55 | func TestEventsStreamSendsSnapshot(t *testing.T) { | 102 | func TestEventsStreamSendsSnapshot(t *testing.T) { |
| 56 | ts, _, _, _, _ := newServer(t) | 103 | ts, _, _, _, _ := newServer(t) |
| 57 | enroll(t, ts) | 104 | enroll(t, ts) |
internal/server/api/decommission_poke_test.go
| Old | New | ||
|---|---|---|---|
| @@ -1,6 +1,7 @@ | |||
| 1 | package api | 1 | package api |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "encoding/json" | ||
| 4 | "net/http" | 5 | "net/http" |
| 5 | "testing" | 6 | "testing" |
| 6 | "time" | 7 | "time" |
| @@ -50,6 +51,49 @@ func TestForceDecommissionRemovesHostWithVMs(t *testing.T) { | |||
| 50 | assert.Empty(t, hosts, "force must remove the host immediately") | 51 | assert.Empty(t, hosts, "force must remove the host immediately") |
| 51 | } | 52 | } |
| 52 | 53 | ||
| 54 | // TestForceDecommissionAuditNamesWhatItDestroyed pins the receipt for the one | ||
| 55 | // path that loses data on purpose. The volumes are deleted with the host and | ||
| 56 | // their ids exist nowhere afterwards, so this audit row is the only record an | ||
| 57 | // operator reconciling a lost host against backups has to work from. | ||
| 58 | func TestForceDecommissionAuditNamesWhatItDestroyed(t *testing.T) { | ||
| 59 | ts, st, _, reg, _ := newServer(t) | ||
| 60 | floorVolumes(t, "v0.0.7") | ||
| 61 | out := enroll(t, ts) | ||
| 62 | hostID := out["host_id"] | ||
| 63 | onlineAt(reg, hostID, "v0.0.7") | ||
| 64 | |||
| 65 | resp := do(t, "POST", ts.URL+"/api/v1/volume-claims", testPAT, | ||
| 66 | map[string]any{"name": "payroll", "size_gb": 5}) | ||
| 67 | require.Equal(t, http.StatusCreated, resp.StatusCode) | ||
| 68 | var claim struct { | ||
| 69 | ID string `json:"id"` | ||
| 70 | } | ||
| 71 | require.NoError(t, json.NewDecoder(resp.Body).Decode(&claim)) | ||
| 72 | require.NotEmpty(t, claim.ID) | ||
| 73 | require.Equal(t, http.StatusCreated, do(t, "POST", ts.URL+"/api/v1/vms", testPAT, | ||
| 74 | map[string]any{"host_id": hostID, "name": "vm-a", "volume_claims": []string{claim.ID}}).StatusCode) | ||
| 75 | |||
| 76 | // The volume id as minted, read while the row still exists. | ||
| 77 | vols, err := st.ListVolumesForHost(hostID) | ||
| 78 | require.NoError(t, err) | ||
| 79 | require.Len(t, vols, 1) | ||
| 80 | |||
| 81 | require.Equal(t, http.StatusOK, | ||
| 82 | do(t, "DELETE", ts.URL+"/api/v1/hosts/"+hostID+"?force=true", testPAT, nil).StatusCode) | ||
| 83 | |||
| 84 | rows, err := st.ListAudit(testTenant, 10) | ||
| 85 | require.NoError(t, err) | ||
| 86 | require.NotEmpty(t, rows) | ||
| 87 | require.Equal(t, "host.decommission", rows[0].Action, "newest first") | ||
| 88 | var detail map[string]string | ||
| 89 | require.NoError(t, json.Unmarshal([]byte(rows[0].Detail), &detail)) | ||
| 90 | assert.Equal(t, "true", detail["force"]) | ||
| 91 | assert.Equal(t, "1", detail["vms_purged"]) | ||
| 92 | assert.Equal(t, "1", detail["volumes_destroyed"]) | ||
| 93 | assert.Equal(t, "1", detail["claims_unbound"]) | ||
| 94 | assert.Equal(t, vols[0].ID, detail["volume_ids"], "the destroyed volume, named") | ||
| 95 | } | ||
| 96 | |||
| 53 | // TestCreateVMRejectedOnDecommissioningHost pins that a host mid-decommission | 97 | // TestCreateVMRejectedOnDecommissioningHost pins that a host mid-decommission |
| 54 | // no longer accepts new VMs (previously only the FK was enforced, so a create | 98 | // no longer accepts new VMs (previously only the FK was enforced, so a create |
| 55 | // could land on a host being torn down). | 99 | // could land on a host being torn down). |
internal/server/api/events.go
| Old | New | ||
|---|---|---|---|
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | "fmt" | 7 | "fmt" |
| 8 | "net/http" | 8 | "net/http" |
| 9 | "strconv" | 9 | "strconv" |
| 10 | "strings" | ||
| 10 | "time" | 11 | "time" |
| 11 | 12 | ||
| 12 | "github.com/a73x/eitri/internal/server/api/types" | 13 | "github.com/a73x/eitri/internal/server/api/types" |
| @@ -46,7 +47,7 @@ func (a *API) handleDecommissionHost(w http.ResponseWriter, r *http.Request) { | |||
| 46 | } | 47 | } |
| 47 | 48 | ||
| 48 | if forceParam(r) { | 49 | if forceParam(r) { |
| 49 | purged, err := a.st.ForceRemoveHost(id) | 50 | gone, err := a.st.ForceRemoveHost(id) |
| 50 | switch { | 51 | switch { |
| 51 | case errors.Is(err, sql.ErrNoRows): | 52 | case errors.Is(err, sql.ErrNoRows): |
| 52 | http.Error(w, "host not found", http.StatusNotFound) | 53 | http.Error(w, "host not found", http.StatusNotFound) |
| @@ -55,9 +56,16 @@ func (a *API) handleDecommissionHost(w http.ResponseWriter, r *http.Request) { | |||
| 55 | http.Error(w, "internal error", http.StatusInternalServerError) | 56 | http.Error(w, "internal error", http.StatusInternalServerError) |
| 56 | return | 57 | return |
| 57 | } | 58 | } |
| 59 | // Force is the one path that destroys data on purpose, so its audit row | ||
| 60 | // is the only record of WHAT it destroyed: the volumes are deleted here | ||
| 61 | // and their ids exist nowhere afterwards. An operator reconciling a lost | ||
| 62 | // host against backups has this row and nothing else. | ||
| 58 | a.audit(h.Tenant, "host.decommission", map[string]string{ | 63 | a.audit(h.Tenant, "host.decommission", map[string]string{ |
| 59 | "host_id": id, "remote": clientIP(r), | 64 | "host_id": id, "remote": clientIP(r), |
| 60 | "force": "true", "vms_purged": strconv.Itoa(purged), | 65 | "force": "true", "vms_purged": strconv.Itoa(gone.VMsPurged), |
| 66 | "volumes_destroyed": strconv.Itoa(gone.VolumesDestroyed), | ||
| 67 | "claims_unbound": strconv.Itoa(gone.ClaimsUnbound), | ||
| 68 | "volume_ids": strings.Join(gone.VolumeIDs, ","), | ||
| 61 | }) | 69 | }) |
| 62 | if a.upgrader != nil { | 70 | if a.upgrader != nil { |
| 63 | a.upgrader.ClearAgentUpgrade(id) | 71 | a.upgrader.ClearAgentUpgrade(id) |
internal/server/api/routes.go
| Old | New | ||
|---|---|---|---|
| @@ -266,6 +266,32 @@ var routeTable = []Route{ | |||
| 266 | Doc: "Revoke an exposure; its host closes the listener on the next converge.", | 266 | Doc: "Revoke an exposure; its host closes the listener on the next converge.", |
| 267 | handler: (*API).handleDeleteExposure, | 267 | handler: (*API).handleDeleteExposure, |
| 268 | }, | 268 | }, |
| 269 | // Volume claims: durable raw block storage a tenant claims and attaches to | ||
| 270 | // a VM at create; the bytes outlive the VM. There is no update verb — size | ||
| 271 | // is immutable once bound, because a guest filesystem sits on it. | ||
| 272 | { | ||
| 273 | Method: "POST", Path: "/api/v1/volume-claims", Auth: AuthUser, Kind: KindJSON, | ||
| 274 | Request: (*types.CreateVolumeClaimRequest)(nil), Response: (*types.VolumeClaim)(nil), Success: http.StatusCreated, | ||
| 275 | Doc: "Claim durable storage. Pending until the first VM naming it is created; that VM's host then holds the bytes, and every later VM using the claim is placed there.", | ||
| 276 | handler: (*API).handleCreateVolumeClaim, | ||
| 277 | }, | ||
| 278 | { | ||
| 279 | Method: "GET", Path: "/api/v1/volume-claims", Auth: AuthUser, Kind: KindJSON, | ||
| 280 | Response: []types.VolumeClaim(nil), Success: http.StatusOK, | ||
| 281 | Doc: "List the tenant's claims: where each is bound, which VM holds it, and whether its host has the file.", | ||
| 282 | handler: (*API).handleListVolumeClaims, | ||
| 283 | }, | ||
| 284 | { | ||
| 285 | Method: "GET", Path: "/api/v1/volume-claims/{id}", Auth: AuthUser, Kind: KindJSON, | ||
| 286 | Response: (*types.VolumeClaim)(nil), Success: http.StatusOK, | ||
| 287 | Doc: "One claim.", | ||
| 288 | handler: (*API).handleGetVolumeClaim, | ||
| 289 | }, | ||
| 290 | { | ||
| 291 | Method: "DELETE", Path: "/api/v1/volume-claims/{id}", Auth: AuthUser, Kind: KindJSON, Success: http.StatusNoContent, | ||
| 292 | Doc: "Delete a claim and the data behind it. Refused (409) while a VM holds it.", | ||
| 293 | handler: (*API).handleDeleteVolumeClaim, | ||
| 294 | }, | ||
| 269 | // BYO per-tenant SSH user CAs: eitri stores only the CA pubkey and never | 295 | // BYO per-tenant SSH user CAs: eitri stores only the CA pubkey and never |
| 270 | // holds a user signing key. Tenant-scoped (caller must act for {tenant}). | 296 | // holds a user signing key. Tenant-scoped (caller must act for {tenant}). |
| 271 | { | 297 | { |
internal/server/api/routes_test.go
| Old | New | ||
|---|---|---|---|
| @@ -34,7 +34,7 @@ func exemplarElem(t *testing.T, route Route, role string, v any) reflect.Type { | |||
| 34 | // `required` array for request schemas, so a type serving both roles would | 34 | // `required` array for request schemas, so a type serving both roles would |
| 35 | // get the wrong treatment on one of them). | 35 | // get the wrong treatment on one of them). |
| 36 | func TestRouteTable(t *testing.T) { | 36 | func TestRouteTable(t *testing.T) { |
| 37 | const wantRoutes = 35 | 37 | const wantRoutes = 39 |
| 38 | if len(routeTable) != wantRoutes { | 38 | if len(routeTable) != wantRoutes { |
| 39 | t.Fatalf("route table has %d entries, want %d — new endpoint? update this pin and cmd/eitri-apispec coverage together", len(routeTable), wantRoutes) | 39 | t.Fatalf("route table has %d entries, want %d — new endpoint? update this pin and cmd/eitri-apispec coverage together", len(routeTable), wantRoutes) |
| 40 | } | 40 | } |
internal/server/api/testdata/create-vm-request.golden.json
| Old | New | ||
|---|---|---|---|
| @@ -9,5 +9,9 @@ | |||
| 9 | "vcpus": 4, | 9 | "vcpus": 4, |
| 10 | "mem_mb": 4096, | 10 | "mem_mb": 4096, |
| 11 | "disk_gb": 20, | 11 | "disk_gb": 20, |
| 12 | "network": "lan" | 12 | "network": "lan", |
| 13 | "volume_claims": [ | ||
| 14 | "project-data", | ||
| 15 | "scratch" | ||
| 16 | ] | ||
| 13 | } | 17 | } |
internal/server/api/testdata/create-volume-claim-request.golden.json
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,4 @@ | |||
| 1 | { | ||
| 2 | "name": "project-data", | ||
| 3 | "size_gb": 50 | ||
| 4 | } | ||
internal/server/api/testdata/volume-claim.golden.json
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,10 @@ | |||
| 1 | { | ||
| 2 | "id": "c-3456", | ||
| 3 | "name": "project-data", | ||
| 4 | "size_gb": 50, | ||
| 5 | "status": "pending", | ||
| 6 | "host_id": "", | ||
| 7 | "vm_id": "", | ||
| 8 | "present": null, | ||
| 9 | "created_at": "2026-07-27T12:05:00Z" | ||
| 10 | } | ||
internal/server/api/types/types.go
| Old | New | ||
|---|---|---|---|
| @@ -276,6 +276,34 @@ type CreateVMRequest struct { | |||
| 276 | // no silent fallback, a VM that asked for the LAN either gets it or is | 276 | // no silent fallback, a VM that asked for the LAN either gets it or is |
| 277 | // never created. | 277 | // never created. |
| 278 | Network string `json:"network"` | 278 | Network string `json:"network"` |
| 279 | // VolumeClaims names this tenant's claims (ids or names) to attach after | ||
| 280 | // the root and seed disks, in this order: the first is /dev/vdc. A | ||
| 281 | // pending claim is bound to this VM's host; a bound one pins the VM there. | ||
| 282 | VolumeClaims []string `json:"volume_claims"` | ||
| 283 | } | ||
| 284 | |||
| 285 | // CreateVolumeClaimRequest is the POST /api/v1/volume-claims body. | ||
| 286 | type CreateVolumeClaimRequest struct { | ||
| 287 | Name string `json:"name"` | ||
| 288 | SizeGB int64 `json:"size_gb"` | ||
| 289 | } | ||
| 290 | |||
| 291 | // VolumeClaim is durable storage a tenant holds. Status is "pending" until | ||
| 292 | // the first VM naming it is created, then "bound" to that VM's host for good. | ||
| 293 | type VolumeClaim struct { | ||
| 294 | ID string `json:"id"` | ||
| 295 | Name string `json:"name"` | ||
| 296 | SizeGB int64 `json:"size_gb"` | ||
| 297 | Status string `json:"status"` | ||
| 298 | HostID string `json:"host_id"` // "" until bound | ||
| 299 | VMID string `json:"vm_id"` // "" when no VM holds it | ||
| 300 | // Present is what the claim's host last said about the file behind it, and | ||
| 301 | // null until it has said anything — a pending claim (no file to look for), | ||
| 302 | // a host that is not reporting, or a report that predates the volume. Null | ||
| 303 | // is not false: "nobody has looked" and "it is gone" are different answers | ||
| 304 | // and only one of them is alarming. | ||
| 305 | Present *bool `json:"present"` | ||
| 306 | CreatedAt time.Time `json:"created_at"` | ||
| 279 | } | 307 | } |
| 280 | 308 | ||
| 281 | // Default VM sizes the control plane applies when a create request leaves a | 309 | // Default VM sizes the control plane applies when a create request leaves a |
internal/server/api/volumes.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,229 @@ | |||
| 1 | package api | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "database/sql" | ||
| 5 | "errors" | ||
| 6 | "net/http" | ||
| 7 | "regexp" | ||
| 8 | "strconv" | ||
| 9 | |||
| 10 | "github.com/a73x/eitri/internal/server/api/types" | ||
| 11 | "github.com/a73x/eitri/internal/server/registry" | ||
| 12 | "github.com/a73x/eitri/internal/server/store" | ||
| 13 | ) | ||
| 14 | |||
| 15 | // claimName mirrors the VM name rule: DNS-label shaped, so a claim can be | ||
| 16 | // named on the command line without quoting. | ||
| 17 | var claimName = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`) | ||
| 18 | |||
| 19 | // maxClaimGB bounds a single claim; the host's disk is the real cap and | ||
| 20 | // capacity admission judges it. | ||
| 21 | const maxClaimGB = 4096 | ||
| 22 | |||
| 23 | func claimToWire(c store.VolumeClaim, present *bool) types.VolumeClaim { | ||
| 24 | status := "pending" | ||
| 25 | if c.BoundVolumeID != "" { | ||
| 26 | status = "bound" | ||
| 27 | } | ||
| 28 | return types.VolumeClaim{ID: c.ID, Name: c.Name, SizeGB: c.SizeGB, Status: status, | ||
| 29 | HostID: c.HostID, VMID: c.VMID, Present: present, CreatedAt: c.CreatedAt} | ||
| 30 | } | ||
| 31 | |||
| 32 | // presence answers "is the file there" with what the claim's host is saying | ||
| 33 | // NOW, and nil whenever nothing is being said: an unbound claim (no file to | ||
| 34 | // look for), a host that is not reporting, or a report that does not name the | ||
| 35 | // volume. online is the caller's read of that host's registry entry. | ||
| 36 | // | ||
| 37 | // A host that has gone quiet says NOTHING rather than saying no — the same | ||
| 38 | // reading every sibling admission takes of silence (see refuseBelowFloor). | ||
| 39 | // Its last report is minutes old and the file it described may have been | ||
| 40 | // reclaimed since; answering false there would raise an alarm about a disk | ||
| 41 | // nobody has actually looked at. | ||
| 42 | func presence(hs registry.HostState, online bool, c store.VolumeClaim) *bool { | ||
| 43 | if c.BoundVolumeID == "" || !online { | ||
| 44 | return nil | ||
| 45 | } | ||
| 46 | for _, v := range hs.Report.Volumes { | ||
| 47 | if v.VolumeID == c.BoundVolumeID { | ||
| 48 | p := v.Present | ||
| 49 | return &p | ||
| 50 | } | ||
| 51 | } | ||
| 52 | return nil | ||
| 53 | } | ||
| 54 | |||
| 55 | // presenceOf is presence for one claim read on its own, taking the registry | ||
| 56 | // hit the list handler hoists out of its loop. | ||
| 57 | func (a *API) presenceOf(c store.VolumeClaim) *bool { | ||
| 58 | if c.BoundVolumeID == "" { | ||
| 59 | return nil | ||
| 60 | } | ||
| 61 | hs, ok := a.reg.Get(c.HostID) | ||
| 62 | return presence(hs, ok && hs.Online, c) | ||
| 63 | } | ||
| 64 | |||
| 65 | func (a *API) handleCreateVolumeClaim(w http.ResponseWriter, r *http.Request) { | ||
| 66 | var req types.CreateVolumeClaimRequest | ||
| 67 | if !decodeJSON(w, r, &req) { | ||
| 68 | return | ||
| 69 | } | ||
| 70 | if !claimName.MatchString(req.Name) { | ||
| 71 | http.Error(w, "name must be 1-63 chars of [a-z0-9-], starting and ending alphanumeric", http.StatusBadRequest) | ||
| 72 | return | ||
| 73 | } | ||
| 74 | if req.SizeGB < 1 || req.SizeGB > maxClaimGB { | ||
| 75 | http.Error(w, "size_gb must be in [1, "+strconv.Itoa(maxClaimGB)+"]", http.StatusBadRequest) | ||
| 76 | return | ||
| 77 | } | ||
| 78 | tenant := principalFromContext(r).Tenant | ||
| 79 | c, err := a.st.CreateVolumeClaim(tenant, req.Name, req.SizeGB) | ||
| 80 | if err != nil { | ||
| 81 | if errors.Is(err, store.ErrClaimNameTaken) { | ||
| 82 | http.Error(w, "name already in use", http.StatusConflict) | ||
| 83 | return | ||
| 84 | } | ||
| 85 | http.Error(w, "internal error", http.StatusInternalServerError) | ||
| 86 | return | ||
| 87 | } | ||
| 88 | a.audit(tenant, "volume_claim.create", map[string]string{"claim_id": c.ID, "name": c.Name, | ||
| 89 | "size_gb": strconv.FormatInt(c.SizeGB, 10)}) | ||
| 90 | a.notif.notify() | ||
| 91 | // A fresh claim is Pending: it names no host, so there is nothing that | ||
| 92 | // could have reported on it yet. | ||
| 93 | writeJSON(w, http.StatusCreated, claimToWire(c, nil)) | ||
| 94 | } | ||
| 95 | |||
| 96 | func (a *API) handleListVolumeClaims(w http.ResponseWriter, r *http.Request) { | ||
| 97 | list, err := a.st.ListVolumeClaims(principalFromContext(r).Tenant) | ||
| 98 | if err != nil { | ||
| 99 | http.Error(w, "internal error", http.StatusInternalServerError) | ||
| 100 | return | ||
| 101 | } | ||
| 102 | // One registry read per HOST, not per claim. reg.Get deep-clones the | ||
| 103 | // host's whole report, and claims cluster on hosts by construction — every | ||
| 104 | // claim a VM was given is bound to that VM's host — so the per-claim read | ||
| 105 | // this replaces cloned the same report once for each of them. | ||
| 106 | type liveHost struct { | ||
| 107 | state registry.HostState | ||
| 108 | online bool | ||
| 109 | } | ||
| 110 | hosts := make(map[string]liveHost) | ||
| 111 | out := make([]types.VolumeClaim, 0, len(list)) | ||
| 112 | for _, c := range list { | ||
| 113 | if c.BoundVolumeID == "" { | ||
| 114 | out = append(out, claimToWire(c, nil)) | ||
| 115 | continue | ||
| 116 | } | ||
| 117 | h, seen := hosts[c.HostID] | ||
| 118 | if !seen { | ||
| 119 | hs, ok := a.reg.Get(c.HostID) | ||
| 120 | h = liveHost{state: hs, online: ok && hs.Online} | ||
| 121 | hosts[c.HostID] = h | ||
| 122 | } | ||
| 123 | out = append(out, claimToWire(c, presence(h.state, h.online, c))) | ||
| 124 | } | ||
| 125 | writeJSON(w, http.StatusOK, out) | ||
| 126 | } | ||
| 127 | |||
| 128 | // ownedClaim reads one claim the caller may act on. A foreign claim answers | ||
| 129 | // exactly like a missing one — existence is not leaked across tenants. | ||
| 130 | func (a *API) ownedClaim(w http.ResponseWriter, r *http.Request) (store.VolumeClaim, bool) { | ||
| 131 | c, err := a.st.GetVolumeClaim(r.PathValue("id")) | ||
| 132 | switch { | ||
| 133 | case errors.Is(err, sql.ErrNoRows), err == nil && !mayActAs(principalFromContext(r), c.Tenant): | ||
| 134 | http.Error(w, "not found", http.StatusNotFound) | ||
| 135 | return store.VolumeClaim{}, false | ||
| 136 | case err != nil: | ||
| 137 | http.Error(w, "internal error", http.StatusInternalServerError) | ||
| 138 | return store.VolumeClaim{}, false | ||
| 139 | } | ||
| 140 | return c, true | ||
| 141 | } | ||
| 142 | |||
| 143 | func (a *API) handleGetVolumeClaim(w http.ResponseWriter, r *http.Request) { | ||
| 144 | c, ok := a.ownedClaim(w, r) | ||
| 145 | if !ok { | ||
| 146 | return | ||
| 147 | } | ||
| 148 | writeJSON(w, http.StatusOK, claimToWire(c, a.presenceOf(c))) | ||
| 149 | } | ||
| 150 | |||
| 151 | func (a *API) handleDeleteVolumeClaim(w http.ResponseWriter, r *http.Request) { | ||
| 152 | c, ok := a.ownedClaim(w, r) | ||
| 153 | if !ok { | ||
| 154 | return | ||
| 155 | } | ||
| 156 | if err := a.st.TombstoneVolumeClaim(c.ID); err != nil { | ||
| 157 | var attached *store.ClaimAttachedError | ||
| 158 | switch { | ||
| 159 | case errors.As(err, &attached): | ||
| 160 | http.Error(w, "volume claim is attached to vm "+attached.VMID+"; delete that VM first", http.StatusConflict) | ||
| 161 | case errors.Is(err, sql.ErrNoRows): | ||
| 162 | http.Error(w, "not found", http.StatusNotFound) | ||
| 163 | default: | ||
| 164 | http.Error(w, "internal error", http.StatusInternalServerError) | ||
| 165 | } | ||
| 166 | return | ||
| 167 | } | ||
| 168 | a.audit(c.Tenant, "volume_claim.delete", map[string]string{"claim_id": c.ID, "name": c.Name, "volume_id": c.BoundVolumeID}) | ||
| 169 | // A bound claim's host has to be told; an unbound one has no host to tell. | ||
| 170 | if c.HostID != "" { | ||
| 171 | a.hub.Poke(c.HostID) | ||
| 172 | } | ||
| 173 | a.notif.notify() | ||
| 174 | w.WriteHeader(http.StatusNoContent) | ||
| 175 | } | ||
| 176 | |||
| 177 | // resolveClaims turns a create's claim ids-or-names into ids, in order, and | ||
| 178 | // sums the sizes of the PENDING ones — the disk this create newly commits; | ||
| 179 | // a bound claim is already in the host's held total (see CommittedOnHost). | ||
| 180 | // | ||
| 181 | // It refuses a reference that names nothing, and one claim named twice. The | ||
| 182 | // second refusal is this layer's job rather than the store's: the store would | ||
| 183 | // see two attach attempts and answer "already attached to <this vm>", naming | ||
| 184 | // the VM being created as though a race had happened. A caller who wrote a | ||
| 185 | // name twice deserves to read that they wrote it twice. | ||
| 186 | // | ||
| 187 | // ID BEFORE NAME, in two maps rather than one. Nothing stops a tenant naming | ||
| 188 | // one claim after another's 32-hex id, and a single keyspace would let that | ||
| 189 | // name shadow the id it copies — silently attaching the wrong disk, with the | ||
| 190 | // later-created claim winning because it overwrites. An id is the unambiguous | ||
| 191 | // handle, so an id that resolves wins outright and no name can displace it. | ||
| 192 | // | ||
| 193 | // msg/code is "" / 0 when the create may proceed. | ||
| 194 | func (a *API) resolveClaims(tenant string, refs []string) (ids []string, pendingGB int64, msg string, code int, err error) { | ||
| 195 | if len(refs) == 0 { | ||
| 196 | return nil, 0, "", 0, nil | ||
| 197 | } | ||
| 198 | list, err := a.st.ListVolumeClaims(tenant) | ||
| 199 | if err != nil { | ||
| 200 | return nil, 0, "", 0, err | ||
| 201 | } | ||
| 202 | byID := make(map[string]store.VolumeClaim, len(list)) | ||
| 203 | byName := make(map[string]store.VolumeClaim, len(list)) | ||
| 204 | for _, c := range list { | ||
| 205 | byID[c.ID] = c | ||
| 206 | byName[c.Name] = c | ||
| 207 | } | ||
| 208 | seen := make(map[string]bool, len(refs)) | ||
| 209 | for _, ref := range refs { | ||
| 210 | c, ok := byID[ref] | ||
| 211 | if !ok { | ||
| 212 | c, ok = byName[ref] | ||
| 213 | } | ||
| 214 | if !ok { | ||
| 215 | return nil, 0, "unknown volume claim " + ref, http.StatusNotFound, nil | ||
| 216 | } | ||
| 217 | if seen[c.ID] { | ||
| 218 | // By NAME, whichever way it was referenced: naming a claim once by | ||
| 219 | // id and once by name is the way this mistake actually happens. | ||
| 220 | return nil, 0, "volume claim " + c.Name + " named twice", http.StatusBadRequest, nil | ||
| 221 | } | ||
| 222 | seen[c.ID] = true | ||
| 223 | ids = append(ids, c.ID) | ||
| 224 | if c.BoundVolumeID == "" { | ||
| 225 | pendingGB += c.SizeGB | ||
| 226 | } | ||
| 227 | } | ||
| 228 | return ids, pendingGB, "", 0, nil | ||
| 229 | } | ||
internal/server/api/volumes_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,324 @@ | |||
| 1 | package api | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "encoding/json" | ||
| 5 | "io" | ||
| 6 | "net/http" | ||
| 7 | "net/http/httptest" | ||
| 8 | "sort" | ||
| 9 | "sync" | ||
| 10 | "testing" | ||
| 11 | |||
| 12 | "github.com/stretchr/testify/require" | ||
| 13 | |||
| 14 | "github.com/a73x/eitri/internal/server/registry" | ||
| 15 | "github.com/a73x/eitri/internal/server/release" | ||
| 16 | "github.com/a73x/eitri/internal/server/store" | ||
| 17 | ) | ||
| 18 | |||
| 19 | func claimPost(t *testing.T, ts *httptest.Server, name string, size int64) map[string]any { | ||
| 20 | t.Helper() | ||
| 21 | resp := do(t, "POST", ts.URL+"/api/v1/volume-claims", testPAT, map[string]any{"name": name, "size_gb": size}) | ||
| 22 | require.Equal(t, 201, resp.StatusCode) | ||
| 23 | var out map[string]any | ||
| 24 | require.NoError(t, json.NewDecoder(resp.Body).Decode(&out)) | ||
| 25 | return out | ||
| 26 | } | ||
| 27 | |||
| 28 | func getClaim(t *testing.T, ts *httptest.Server, id string) (int, map[string]any) { | ||
| 29 | t.Helper() | ||
| 30 | resp := do(t, "GET", ts.URL+"/api/v1/volume-claims/"+id, testPAT, nil) | ||
| 31 | var out map[string]any | ||
| 32 | _ = json.NewDecoder(resp.Body).Decode(&out) | ||
| 33 | return resp.StatusCode, out | ||
| 34 | } | ||
| 35 | |||
| 36 | // floorVolumes makes the placeholder-free floor the tests drive against: the | ||
| 37 | // shipped Since is a tag no test host reports, so every test that wants a | ||
| 38 | // host ABOVE the floor names its own. | ||
| 39 | func floorVolumes(t *testing.T, since string) { | ||
| 40 | t.Helper() | ||
| 41 | prev := volumesFeature | ||
| 42 | volumesFeature = release.Feature{Name: "volumes", Since: since} | ||
| 43 | t.Cleanup(func() { volumesFeature = prev }) | ||
| 44 | } | ||
| 45 | |||
| 46 | // onlineAt is the pair of facts that make a host judgeable: the version its | ||
| 47 | // agent named in the Hello, and a report, which is what makes it Online. | ||
| 48 | func onlineAt(reg *registry.Registry, hostID, v string) { | ||
| 49 | reg.SetAgentVersion(hostID, v) | ||
| 50 | reg.UpdateReport(hostID, registry.Report{}) | ||
| 51 | } | ||
| 52 | |||
| 53 | func TestVolumeClaimCreateListGetDelete(t *testing.T) { | ||
| 54 | ts, _, _, _, _ := newServer(t) | ||
| 55 | c := claimPost(t, ts, "data", 5) | ||
| 56 | require.Equal(t, "pending", c["status"]) | ||
| 57 | require.Nil(t, c["present"], "nothing has reported on a claim no host holds") | ||
| 58 | resp := do(t, "GET", ts.URL+"/api/v1/volume-claims", testPAT, nil) | ||
| 59 | require.Equal(t, 200, resp.StatusCode) | ||
| 60 | require.Len(t, decodeJSONKeys(t, resp), 1) | ||
| 61 | code, _ := getClaim(t, ts, c["id"].(string)) | ||
| 62 | require.Equal(t, 200, code) | ||
| 63 | require.Equal(t, 204, do(t, "DELETE", ts.URL+"/api/v1/volume-claims/"+c["id"].(string), testPAT, nil).StatusCode) | ||
| 64 | code, _ = getClaim(t, ts, c["id"].(string)) | ||
| 65 | require.Equal(t, 404, code) | ||
| 66 | } | ||
| 67 | |||
| 68 | func TestVolumeClaimValidation(t *testing.T) { | ||
| 69 | ts, _, _, _, _ := newServer(t) | ||
| 70 | for _, body := range []map[string]any{ | ||
| 71 | {"name": "", "size_gb": 1}, {"name": "x", "size_gb": 0}, {"name": "x", "size_gb": -1}, | ||
| 72 | {"name": "bad name", "size_gb": 1}, {"name": "x", "size_gb": 4097}, | ||
| 73 | } { | ||
| 74 | require.Equal(t, 400, do(t, "POST", ts.URL+"/api/v1/volume-claims", testPAT, body).StatusCode, "%v", body) | ||
| 75 | } | ||
| 76 | claimPost(t, ts, "dup", 1) | ||
| 77 | require.Equal(t, 409, do(t, "POST", ts.URL+"/api/v1/volume-claims", testPAT, map[string]any{"name": "dup", "size_gb": 1}).StatusCode) | ||
| 78 | } | ||
| 79 | |||
| 80 | func TestVolumeClaimsAreTenantScoped(t *testing.T) { | ||
| 81 | ts, st, _, _, _ := newServer(t) | ||
| 82 | c := claimPost(t, ts, "data", 5) | ||
| 83 | other := patForOtherTenant(t, st) | ||
| 84 | id := c["id"].(string) | ||
| 85 | require.Equal(t, 404, do(t, "GET", ts.URL+"/api/v1/volume-claims/"+id, other, nil).StatusCode) | ||
| 86 | require.Equal(t, 404, do(t, "DELETE", ts.URL+"/api/v1/volume-claims/"+id, other, nil).StatusCode) | ||
| 87 | require.Len(t, decodeJSONKeys(t, do(t, "GET", ts.URL+"/api/v1/volume-claims", other, nil)), 0) | ||
| 88 | } | ||
| 89 | |||
| 90 | // A claim belonging to somebody else is not a claim this tenant can mount, on | ||
| 91 | // its own host or anywhere. resolveClaims reads the CALLER's claims only, so a | ||
| 92 | // foreign id resolves to nothing and answers exactly like a typo — existence | ||
| 93 | // is not leaked across tenants here any more than on the claim routes. | ||
| 94 | func TestCreateVMCannotNameAnotherTenantsClaim(t *testing.T) { | ||
| 95 | ts, st, _, reg, _ := newServer(t) | ||
| 96 | floorVolumes(t, "v0.0.7") | ||
| 97 | hostID := enroll(t, ts)["host_id"] // the CALLER's own host | ||
| 98 | onlineAt(reg, hostID, "v0.0.7") | ||
| 99 | |||
| 100 | other := patForOtherTenant(t, st) | ||
| 101 | resp := do(t, "POST", ts.URL+"/api/v1/volume-claims", other, map[string]any{"name": "theirs", "size_gb": 5}) | ||
| 102 | require.Equal(t, 201, resp.StatusCode) | ||
| 103 | var theirs map[string]any | ||
| 104 | require.NoError(t, json.NewDecoder(resp.Body).Decode(&theirs)) | ||
| 105 | theirID := theirs["id"].(string) | ||
| 106 | |||
| 107 | resp = do(t, "POST", ts.URL+"/api/v1/vms", testPAT, map[string]any{ | ||
| 108 | "host_id": hostID, "name": "thief", "volume_claims": []string{theirID}}) | ||
| 109 | require.Equal(t, 404, resp.StatusCode) | ||
| 110 | body, _ := io.ReadAll(resp.Body) | ||
| 111 | require.Contains(t, string(body), "unknown volume claim") | ||
| 112 | |||
| 113 | // By name too, and the name is one this tenant does not have either. | ||
| 114 | resp = do(t, "POST", ts.URL+"/api/v1/vms", testPAT, map[string]any{ | ||
| 115 | "host_id": hostID, "name": "thief", "volume_claims": []string{"theirs"}}) | ||
| 116 | require.Equal(t, 404, resp.StatusCode) | ||
| 117 | } | ||
| 118 | |||
| 119 | // A claim NAMED after another claim's id must not shadow it. Names are the | ||
| 120 | // tenant's to choose and ids are 32 lowercase hex — a legal name — so a single | ||
| 121 | // keyspace would let the later claim win the lookup and quietly attach the | ||
| 122 | // wrong disk. The id is the unambiguous handle and wins. | ||
| 123 | func TestCreateVMResolvesAClaimIDBeforeAName(t *testing.T) { | ||
| 124 | ts, _, _, reg, _ := newServer(t) | ||
| 125 | floorVolumes(t, "v0.0.7") | ||
| 126 | hostID := enroll(t, ts)["host_id"] | ||
| 127 | onlineAt(reg, hostID, "v0.0.7") | ||
| 128 | |||
| 129 | x := claimPost(t, ts, "alpha", 1) | ||
| 130 | xID := x["id"].(string) | ||
| 131 | // Created AFTER x, so in a single map its name would overwrite x's id. | ||
| 132 | y := claimPost(t, ts, xID, 2) | ||
| 133 | yID := y["id"].(string) | ||
| 134 | |||
| 135 | require.Equal(t, 201, do(t, "POST", ts.URL+"/api/v1/vms", testPAT, map[string]any{ | ||
| 136 | "host_id": hostID, "name": "holder", "volume_claims": []string{xID}}).StatusCode) | ||
| 137 | |||
| 138 | _, gotX := getClaim(t, ts, xID) | ||
| 139 | require.Equal(t, "bound", gotX["status"], "the claim whose ID was named is the one attached") | ||
| 140 | require.NotEmpty(t, gotX["vm_id"]) | ||
| 141 | |||
| 142 | _, gotY := getClaim(t, ts, yID) | ||
| 143 | require.Equal(t, "pending", gotY["status"], "the claim that merely borrowed that id as its name is untouched") | ||
| 144 | require.Equal(t, "", gotY["vm_id"]) | ||
| 145 | } | ||
| 146 | |||
| 147 | // presence is the one field on a claim the control plane cannot decide for | ||
| 148 | // itself, so every way of knowing nothing has to read as null rather than as | ||
| 149 | // "the file is gone". | ||
| 150 | func TestPresenceSaysNothingUntilAHostDoes(t *testing.T) { | ||
| 151 | bound := store.VolumeClaim{ID: "c-1", HostID: "h-1", BoundVolumeID: "vol-1"} | ||
| 152 | reporting := registry.HostState{Report: registry.Report{ | ||
| 153 | Volumes: []registry.VolumeStatus{{VolumeID: "vol-1", Present: true, SizeGB: 5}}}} | ||
| 154 | gone := registry.HostState{Report: registry.Report{ | ||
| 155 | Volumes: []registry.VolumeStatus{{VolumeID: "vol-1", Present: false, SizeGB: 5}}}} | ||
| 156 | |||
| 157 | t.Run("unbound claim", func(t *testing.T) { | ||
| 158 | require.Nil(t, presence(reporting, true, store.VolumeClaim{ID: "c-2"})) | ||
| 159 | }) | ||
| 160 | t.Run("host not reporting", func(t *testing.T) { | ||
| 161 | require.Nil(t, presence(reporting, false, bound), | ||
| 162 | "a host that has gone quiet says nothing; its last report may be minutes stale") | ||
| 163 | }) | ||
| 164 | t.Run("report does not name the volume", func(t *testing.T) { | ||
| 165 | require.Nil(t, presence(registry.HostState{}, true, bound)) | ||
| 166 | }) | ||
| 167 | t.Run("host says it is there", func(t *testing.T) { | ||
| 168 | got := presence(reporting, true, bound) | ||
| 169 | require.NotNil(t, got) | ||
| 170 | require.True(t, *got) | ||
| 171 | }) | ||
| 172 | t.Run("host says it is gone", func(t *testing.T) { | ||
| 173 | got := presence(gone, true, bound) | ||
| 174 | require.NotNil(t, got) | ||
| 175 | require.False(t, *got, "false is a real answer and must survive; only silence is null") | ||
| 176 | }) | ||
| 177 | } | ||
| 178 | |||
| 179 | // Admission steps 1, 2, 3 and 6 of the spec, plus delete-while-attached. | ||
| 180 | func TestCreateVMWithVolumeClaims(t *testing.T) { | ||
| 181 | ts, st, _, reg, _ := newServer(t) | ||
| 182 | floorVolumes(t, "v0.0.7") | ||
| 183 | host := enroll(t, ts) | ||
| 184 | hostID := host["host_id"] | ||
| 185 | onlineAt(reg, hostID, "v0.0.7") | ||
| 186 | c := claimPost(t, ts, "data", 5) | ||
| 187 | cid := c["id"].(string) | ||
| 188 | create := func(host, name string, claims ...string) *http.Response { | ||
| 189 | return do(t, "POST", ts.URL+"/api/v1/vms", testPAT, map[string]any{"host_id": host, "name": name, "volume_claims": claims}) | ||
| 190 | } | ||
| 191 | |||
| 192 | // 1. unknown claim → 404 naming it | ||
| 193 | resp := create(hostID, "a", "nope") | ||
| 194 | require.Equal(t, 404, resp.StatusCode) | ||
| 195 | body, _ := io.ReadAll(resp.Body) | ||
| 196 | require.Contains(t, string(body), "nope") | ||
| 197 | |||
| 198 | // happy path binds; a claim may be named by name as well as id | ||
| 199 | require.Equal(t, 201, create(hostID, "a", "data").StatusCode) | ||
| 200 | _, got := getClaim(t, ts, cid) | ||
| 201 | require.Equal(t, "bound", got["status"]) | ||
| 202 | require.Equal(t, hostID, got["host_id"]) | ||
| 203 | vmID := got["vm_id"].(string) | ||
| 204 | require.NotEmpty(t, vmID) | ||
| 205 | |||
| 206 | // 2. attached → 409 naming the holder | ||
| 207 | resp = create(hostID, "b", cid) | ||
| 208 | require.Equal(t, 409, resp.StatusCode) | ||
| 209 | body, _ = io.ReadAll(resp.Body) | ||
| 210 | require.Contains(t, string(body), vmID) | ||
| 211 | |||
| 212 | // delete while attached → 409 | ||
| 213 | require.Equal(t, 409, do(t, "DELETE", ts.URL+"/api/v1/volume-claims/"+cid, testPAT, nil).StatusCode) | ||
| 214 | |||
| 215 | // 3. pinned to another host → 409 naming the host | ||
| 216 | require.Equal(t, 204, do(t, "DELETE", ts.URL+"/api/v1/vms/"+vmID, testPAT, nil).StatusCode) | ||
| 217 | require.NoError(t, st.HardDeleteVM(vmID, hostID)) | ||
| 218 | h2 := enroll(t, ts)["host_id"] | ||
| 219 | onlineAt(reg, h2, "v0.0.7") | ||
| 220 | resp = create(h2, "c", cid) | ||
| 221 | require.Equal(t, 409, resp.StatusCode) | ||
| 222 | body, _ = io.ReadAll(resp.Body) | ||
| 223 | require.Contains(t, string(body), hostID) | ||
| 224 | |||
| 225 | // ... and on the pinned host it re-attaches. | ||
| 226 | require.Equal(t, 201, create(hostID, "c", cid).StatusCode) | ||
| 227 | } | ||
| 228 | |||
| 229 | // One claim named twice is refused before the store sees it: the store's own | ||
| 230 | // refusal would say the claim is attached to the VM being created, which reads | ||
| 231 | // as a race that never happened. | ||
| 232 | func TestCreateVMRefusesAClaimNamedTwice(t *testing.T) { | ||
| 233 | ts, _, _, reg, _ := newServer(t) | ||
| 234 | floorVolumes(t, "v0.0.7") | ||
| 235 | hostID := enroll(t, ts)["host_id"] | ||
| 236 | onlineAt(reg, hostID, "v0.0.7") | ||
| 237 | c := claimPost(t, ts, "data", 5) | ||
| 238 | |||
| 239 | resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT, map[string]any{ | ||
| 240 | "host_id": hostID, "name": "double", "volume_claims": []string{"data", c["id"].(string)}}) | ||
| 241 | require.Equal(t, 400, resp.StatusCode) | ||
| 242 | body, _ := io.ReadAll(resp.Body) | ||
| 243 | require.Contains(t, string(body), "volume claim data named twice", | ||
| 244 | "the refusal names the claim, not the VM the store would have blamed") | ||
| 245 | |||
| 246 | // And nothing was bound on the way to the refusal. | ||
| 247 | _, got := getClaim(t, ts, c["id"].(string)) | ||
| 248 | require.Equal(t, "pending", got["status"]) | ||
| 249 | } | ||
| 250 | |||
| 251 | // 4. The floor judges a silent host for a volume-bearing create only. | ||
| 252 | func TestCreateVMWithVolumeClaimsFloorsTheAgent(t *testing.T) { | ||
| 253 | ts, _, _, reg, _ := newServer(t) | ||
| 254 | floorVolumes(t, "v0.0.7") | ||
| 255 | hostID := enroll(t, ts)["host_id"] | ||
| 256 | claimPost(t, ts, "data", 5) | ||
| 257 | create := func(name string, claims ...string) *http.Response { | ||
| 258 | return do(t, "POST", ts.URL+"/api/v1/vms", testPAT, map[string]any{"host_id": hostID, "name": name, "volume_claims": claims}) | ||
| 259 | } | ||
| 260 | resp := create("a", "data") | ||
| 261 | require.Equal(t, 409, resp.StatusCode, "a silent host is refused a volume-bearing create") | ||
| 262 | body, _ := io.ReadAll(resp.Body) | ||
| 263 | require.Contains(t, string(body), "volumes") | ||
| 264 | require.Contains(t, string(body), "upgrade-agent") | ||
| 265 | require.Equal(t, 201, create("plain").StatusCode, "a create with no claims is not floored") | ||
| 266 | |||
| 267 | onlineAt(reg, hostID, "v0.0.6") | ||
| 268 | require.Equal(t, 409, create("b", "data").StatusCode, "a pre-volumes agent is refused") | ||
| 269 | onlineAt(reg, hostID, "v0.0.7") | ||
| 270 | require.Equal(t, 201, create("b", "data").StatusCode) | ||
| 271 | } | ||
| 272 | |||
| 273 | // Two creates race for one pending claim: exactly one wins. | ||
| 274 | func TestTwoCreatesRaceForOneClaim(t *testing.T) { | ||
| 275 | ts, _, _, reg, _ := newServer(t) | ||
| 276 | floorVolumes(t, "v0.0.7") | ||
| 277 | hostID := enroll(t, ts)["host_id"] | ||
| 278 | onlineAt(reg, hostID, "v0.0.7") | ||
| 279 | claimPost(t, ts, "data", 1) | ||
| 280 | codes := make(chan int, 2) | ||
| 281 | var wg sync.WaitGroup | ||
| 282 | for _, name := range []string{"r1", "r2"} { | ||
| 283 | wg.Add(1) | ||
| 284 | go func() { | ||
| 285 | defer wg.Done() | ||
| 286 | codes <- do(t, "POST", ts.URL+"/api/v1/vms", testPAT, map[string]any{"host_id": hostID, "name": name, "volume_claims": []string{"data"}}).StatusCode | ||
| 287 | }() | ||
| 288 | } | ||
| 289 | wg.Wait() | ||
| 290 | close(codes) | ||
| 291 | var got []int | ||
| 292 | for c := range codes { | ||
| 293 | got = append(got, c) | ||
| 294 | } | ||
| 295 | sort.Ints(got) | ||
| 296 | require.Equal(t, []int{201, 409}, got) | ||
| 297 | } | ||
| 298 | |||
| 299 | // A claim its host has reported on carries that host's answer: present is the | ||
| 300 | // one field on a claim nothing in the control plane can decide for itself. | ||
| 301 | func TestVolumeClaimReportsWhatTheHostFound(t *testing.T) { | ||
| 302 | ts, st, _, reg, _ := newServer(t) | ||
| 303 | floorVolumes(t, "v0.0.7") | ||
| 304 | hostID := enroll(t, ts)["host_id"] | ||
| 305 | onlineAt(reg, hostID, "v0.0.7") | ||
| 306 | c := claimPost(t, ts, "data", 5) | ||
| 307 | cid := c["id"].(string) | ||
| 308 | require.Equal(t, 201, do(t, "POST", ts.URL+"/api/v1/vms", testPAT, map[string]any{ | ||
| 309 | "host_id": hostID, "name": "holder", "volume_claims": []string{"data"}}).StatusCode) | ||
| 310 | |||
| 311 | // Bound, but the host has not named the volume yet: null, not false. | ||
| 312 | _, got := getClaim(t, ts, cid) | ||
| 313 | require.Nil(t, got["present"]) | ||
| 314 | |||
| 315 | vols, err := st.ListVolumesForHost(hostID) | ||
| 316 | require.NoError(t, err) | ||
| 317 | require.Len(t, vols, 1) | ||
| 318 | reg.SetAgentVersion(hostID, "v0.0.7") | ||
| 319 | reg.UpdateReport(hostID, registry.Report{ | ||
| 320 | Volumes: []registry.VolumeStatus{{VolumeID: vols[0].ID, Present: true, SizeGB: 5}}}) | ||
| 321 | |||
| 322 | _, got = getClaim(t, ts, cid) | ||
| 323 | require.Equal(t, true, got["present"]) | ||
| 324 | } | ||
internal/server/api/wire_golden_test.go
| Old | New | ||
|---|---|---|---|
| @@ -183,6 +183,7 @@ func TestWireGolden(t *testing.T) { | |||
| 183 | MemMB: 4096, | 183 | MemMB: 4096, |
| 184 | DiskGB: 20, | 184 | DiskGB: 20, |
| 185 | Network: "lan", | 185 | Network: "lan", |
| 186 | VolumeClaims: []string{"project-data", "scratch"}, | ||
| 186 | }) | 187 | }) |
| 187 | 188 | ||
| 188 | goldenCheck(t, "patch-vm-request", types.PatchVMRequest{ | 189 | goldenCheck(t, "patch-vm-request", types.PatchVMRequest{ |
| @@ -305,4 +306,22 @@ func TestWireGolden(t *testing.T) { | |||
| 305 | Sessions: &types.ExposureSessions{Active: 7, Refused: 12, Dropped: 3}, | 306 | Sessions: &types.ExposureSessions{Active: 7, Refused: 12, Dropped: 3}, |
| 306 | CreatedAt: base.Add(4 * time.Minute), | 307 | CreatedAt: base.Add(4 * time.Minute), |
| 307 | }}) | 308 | }}) |
| 309 | |||
| 310 | goldenCheck(t, "create-volume-claim-request", types.CreateVolumeClaimRequest{ | ||
| 311 | Name: "project-data", | ||
| 312 | SizeGB: 50, | ||
| 313 | }) | ||
| 314 | |||
| 315 | // The PENDING exemplar, deliberately: it is the shape with the most null in | ||
| 316 | // it, and each null is load-bearing. host_id and vm_id are "" because | ||
| 317 | // nothing has placed or attached it, and present is null because no host | ||
| 318 | // has been asked about a volume that does not exist yet — null is not | ||
| 319 | // false, and a client must not render "missing" for "unreported". | ||
| 320 | goldenCheck(t, "volume-claim", types.VolumeClaim{ | ||
| 321 | ID: "c-3456", | ||
| 322 | Name: "project-data", | ||
| 323 | SizeGB: 50, | ||
| 324 | Status: "pending", | ||
| 325 | CreatedAt: base.Add(5 * time.Minute), | ||
| 326 | }) | ||
| 308 | } | 327 | } |
internal/server/registry/registry.go
| Old | New | ||
|---|---|---|---|
| @@ -69,10 +69,22 @@ type ExposureStatus struct { | |||
| 69 | // See ExposureSessions in the proto for why the two totals are not gauges. | 69 | // See ExposureSessions in the proto for why the two totals are not gauges. |
| 70 | type ExposureSessions struct{ Active, Refused, Dropped int64 } | 70 | type ExposureSessions struct{ Active, Refused, Dropped int64 } |
| 71 | 71 | ||
| 72 | // VolumeStatus is what a host found on disk for one volume id: whether the | ||
| 73 | // file is there, and how big it is. Never persisted — like ExposureStatus it | ||
| 74 | // lives only while the host is connected. It is also the only evidence a | ||
| 75 | // tombstoned volume's row may be reaped on, so Present=false is a statement | ||
| 76 | // the agent makes, never an absence the server infers. | ||
| 77 | type VolumeStatus struct { | ||
| 78 | VolumeID string | ||
| 79 | Present bool | ||
| 80 | SizeGB int64 | ||
| 81 | } | ||
| 82 | |||
| 72 | type Report struct { | 83 | type Report struct { |
| 73 | VMs []VMStatus | 84 | VMs []VMStatus |
| 74 | Quarantined []QuarantinedVM | 85 | Quarantined []QuarantinedVM |
| 75 | Exposures []ExposureStatus | 86 | Exposures []ExposureStatus |
| 87 | Volumes []VolumeStatus | ||
| 76 | Capacity Capacity | 88 | Capacity Capacity |
| 77 | Metrics Metrics | 89 | Metrics Metrics |
| 78 | FenceViolation bool | 90 | FenceViolation bool |
| @@ -187,6 +199,7 @@ func (r *Registry) Get(hostID string) (HostState, bool) { | |||
| 187 | // Deep-copy slices so callers cannot corrupt registry state. | 199 | // Deep-copy slices so callers cannot corrupt registry state. |
| 188 | st.Report.VMs = slices.Clone(st.Report.VMs) | 200 | st.Report.VMs = slices.Clone(st.Report.VMs) |
| 189 | st.Report.Exposures = slices.Clone(st.Report.Exposures) | 201 | st.Report.Exposures = slices.Clone(st.Report.Exposures) |
| 202 | st.Report.Volumes = slices.Clone(st.Report.Volumes) | ||
| 190 | quarantined := slices.Clone(st.Report.Quarantined) | 203 | quarantined := slices.Clone(st.Report.Quarantined) |
| 191 | for i := range quarantined { | 204 | for i := range quarantined { |
| 192 | quarantined[i].VMSpecJSON = bytes.Clone(quarantined[i].VMSpecJSON) | 205 | quarantined[i].VMSpecJSON = bytes.Clone(quarantined[i].VMSpecJSON) |
internal/server/store/exposures_test.go
| Old | New | ||
|---|---|---|---|
| @@ -304,9 +304,9 @@ func TestForceRemovingAHostDestroysItsExposures(t *testing.T) { | |||
| 304 | e, err := s.CreateExposure(vm.ID, 8080, 0, "tcp") | 304 | e, err := s.CreateExposure(vm.ID, 8080, 0, "tcp") |
| 305 | require.NoError(t, err) | 305 | require.NoError(t, err) |
| 306 | 306 | ||
| 307 | purged, err := s.ForceRemoveHost(h.ID) | 307 | gone, err := s.ForceRemoveHost(h.ID) |
| 308 | require.NoError(t, err) | 308 | require.NoError(t, err) |
| 309 | assert.Equal(t, 1, purged) | 309 | assert.Equal(t, 1, gone.VMsPurged) |
| 310 | 310 | ||
| 311 | _, err = s.GetExposure(e.ID) | 311 | _, err = s.GetExposure(e.ID) |
| 312 | assert.ErrorIs(t, err, sql.ErrNoRows) | 312 | assert.ErrorIs(t, err, sql.ErrNoRows) |
internal/server/store/store.go
| Old | New | ||
|---|---|---|---|
| @@ -31,6 +31,13 @@ var ErrHostNotFound = errors.New("host not found") | |||
| 31 | 31 | ||
| 32 | var ErrHostNotEnrolled = errors.New("host not accepting new VMs") | 32 | var ErrHostNotEnrolled = errors.New("host not accepting new VMs") |
| 33 | 33 | ||
| 34 | // ErrHostHoldsVolumes is returned by RemoveHost while volume rows remain on the | ||
| 35 | // host. It is a distinct error because it is a STUCK decommission, not a slow | ||
| 36 | // one: VMs drain themselves, volumes do not — a volume outlives every guest by | ||
| 37 | // design, so nothing releases it until its claim is deleted. The graceful sweep | ||
| 38 | // would otherwise retry forever in silence. Wrapped with the count. | ||
| 39 | var ErrHostHoldsVolumes = errors.New("host still holds volumes") | ||
| 40 | |||
| 34 | // SystemTenant is the audit scope for events with no resolvable tenant — a | 41 | // SystemTenant is the audit scope for events with no resolvable tenant — a |
| 35 | // denied enroll attempt, a reap ack racing its host's deletion. It is NOT a | 42 | // denied enroll attempt, a reap ack racing its host's deletion. It is NOT a |
| 36 | // tenant: no tenants row exists for it, no principal can ever hold it (JIT | 43 | // tenant: no tenants row exists for it, no principal can ever hold it (JIT |
| @@ -114,6 +121,14 @@ type VM struct { | |||
| 114 | // makes nil unambiguously "unrecorded" rather than "trusts nothing", which | 121 | // makes nil unambiguously "unrecorded" rather than "trusts nothing", which |
| 115 | // is the distinction every reader of this field depends on. | 122 | // is the distinction every reader of this field depends on. |
| 116 | TrustedCAs []TrustedCA | 123 | TrustedCAs []TrustedCA |
| 124 | // VolumeClaimIDs is INPUT TO CreateVM ONLY: the claims this VM asks to | ||
| 125 | // mount, already resolved from names to ids by the API. No read ever fills | ||
| 126 | // it — the attachment table is the record, and VolumeIDs is how it reads | ||
| 127 | // back. | ||
| 128 | VolumeClaimIDs []string | ||
| 129 | // VolumeIDs is the read side: the volumes attached to this VM, in the order | ||
| 130 | // it named their claims, which is the order the guest sees the devices in. | ||
| 131 | VolumeIDs []string | ||
| 117 | // The address the gate and every exposure target is AssignedIP. | 132 | // The address the gate and every exposure target is AssignedIP. |
| 118 | CreatedAt time.Time | 133 | CreatedAt time.Time |
| 119 | DeletedAt *time.Time | 134 | DeletedAt *time.Time |
| @@ -272,6 +287,35 @@ CREATE TABLE IF NOT EXISTS exposures ( | |||
| 272 | ); | 287 | ); |
| 273 | CREATE UNIQUE INDEX IF NOT EXISTS exposures_host_port_proto ON exposures(host_id, host_port, protocol); | 288 | CREATE UNIQUE INDEX IF NOT EXISTS exposures_host_port_proto ON exposures(host_id, host_port, protocol); |
| 274 | 289 | ||
| 290 | -- Volumes. A claim is the tenant's request for storage; a volume is the | ||
| 291 | -- fleet's placement of bytes on one host, created by the first VM that names | ||
| 292 | -- the claim. "Unbound" is the absence of a volumes row, never a NULL. | ||
| 293 | CREATE TABLE IF NOT EXISTS volume_claims ( | ||
| 294 | id TEXT PRIMARY KEY, | ||
| 295 | tenant TEXT NOT NULL REFERENCES tenants(id), | ||
| 296 | name TEXT NOT NULL, | ||
| 297 | size_gb INTEGER NOT NULL, | ||
| 298 | bound_volume_id TEXT, | ||
| 299 | created_at DATETIME NOT NULL, | ||
| 300 | deleted_at DATETIME | ||
| 301 | ); | ||
| 302 | CREATE UNIQUE INDEX IF NOT EXISTS volume_claims_tenant_name ON volume_claims(tenant, name) WHERE deleted_at IS NULL; | ||
| 303 | CREATE TABLE IF NOT EXISTS volumes ( | ||
| 304 | id TEXT PRIMARY KEY, | ||
| 305 | host_id TEXT NOT NULL REFERENCES hosts(id), | ||
| 306 | claim_id TEXT NOT NULL REFERENCES volume_claims(id), | ||
| 307 | size_gb INTEGER NOT NULL, | ||
| 308 | created_at DATETIME NOT NULL, | ||
| 309 | deleted_at DATETIME | ||
| 310 | ); | ||
| 311 | -- The database refuses a double attach; nothing above it has to remember to. | ||
| 312 | -- Hard-deleting a reaped VM takes its attachment with it, like exposures. | ||
| 313 | CREATE TABLE IF NOT EXISTS volume_attachments ( | ||
| 314 | claim_id TEXT NOT NULL REFERENCES volume_claims(id), | ||
| 315 | vm_id TEXT NOT NULL REFERENCES vms(id) ON DELETE CASCADE | ||
| 316 | ); | ||
| 317 | CREATE UNIQUE INDEX IF NOT EXISTS volume_attachments_claim ON volume_attachments(claim_id); | ||
| 318 | |||
| 275 | -- Console sessions. Server-side so revocation works and restarts keep | 319 | -- Console sessions. Server-side so revocation works and restarts keep |
| 276 | -- users signed in. id holds the SHA-256 of the session value, never the value | 320 | -- users signed in. id holds the SHA-256 of the session value, never the value |
| 277 | -- itself (see CreateSession); expiry enforced on read. | 321 | -- itself (see CreateSession); expiry enforced on read. |
| @@ -903,6 +947,14 @@ func (s *Store) CreateVM(vm VM) error { | |||
| 903 | return fmt.Errorf("insert vm: %w", err) | 947 | return fmt.Errorf("insert vm: %w", err) |
| 904 | } | 948 | } |
| 905 | 949 | ||
| 950 | // Claims bind HERE, in the same tx that places the VM: a claim the fleet | ||
| 951 | // cannot honour (another tenant's, already held, or already living on | ||
| 952 | // another host) takes the whole create down with it, so a refused VM | ||
| 953 | // leaves no row and no half-bound claim behind. | ||
| 954 | if err := bindClaims(tx, vm); err != nil { | ||
| 955 | return err | ||
| 956 | } | ||
| 957 | |||
| 906 | if err := bumpEpoch(tx); err != nil { | 958 | if err := bumpEpoch(tx); err != nil { |
| 907 | return fmt.Errorf("bump epoch: %w", err) | 959 | return fmt.Errorf("bump epoch: %w", err) |
| 908 | } | 960 | } |
| @@ -1076,6 +1128,11 @@ func (c Commitment) Held() Alloc { | |||
| 1076 | // only, so during a teardown Held() reads higher. Erring that way is the safe | 1128 | // only, so during a teardown Held() reads higher. Erring that way is the safe |
| 1077 | // direction for admission: refusing a VM for a bed that is being stripped costs | 1129 | // direction for admission: refusing a VM for a bed that is being stripped costs |
| 1078 | // a retry, admitting one into it costs a failed VM. | 1130 | // a retry, admitting one into it costs a failed VM. |
| 1131 | // | ||
| 1132 | // Live volumes on the host count into Live.DiskGB too. A bound volume is disk | ||
| 1133 | // the host has committed WITH OR WITHOUT A VM: the bytes outlive every guest | ||
| 1134 | // that mounts them, which is the point of a volume, so the space stays spoken | ||
| 1135 | // for between one VM's destroy and the next VM's create. | ||
| 1079 | func (s *Store) CommittedOnHost(hostID string) (Commitment, error) { | 1136 | func (s *Store) CommittedOnHost(hostID string) (Commitment, error) { |
| 1080 | var c Commitment | 1137 | var c Commitment |
| 1081 | err := s.db.QueryRow(` | 1138 | err := s.db.QueryRow(` |
| @@ -1090,7 +1147,21 @@ func (s *Store) CommittedOnHost(hostID string) (Commitment, error) { | |||
| 1090 | &c.Live.VCPUs, &c.Live.MemMB, &c.Live.DiskGB, | 1147 | &c.Live.VCPUs, &c.Live.MemMB, &c.Live.DiskGB, |
| 1091 | &c.Pending.VCPUs, &c.Pending.MemMB, &c.Pending.DiskGB, | 1148 | &c.Pending.VCPUs, &c.Pending.MemMB, &c.Pending.DiskGB, |
| 1092 | &c.PendingVMs) | 1149 | &c.PendingVMs) |
| 1093 | return c, err | 1150 | if err != nil { |
| 1151 | return c, err | ||
| 1152 | } | ||
| 1153 | // Only live volumes. A tombstoned one is a reclaim in flight and its file | ||
| 1154 | // may still be on the disk, exactly like a tombstoned VM's — the honest | ||
| 1155 | // place for it is Pending, and it is not counted at all until something | ||
| 1156 | // asks for that distinction. | ||
| 1157 | var vol int64 | ||
| 1158 | if err := s.db.QueryRow( | ||
| 1159 | `SELECT COALESCE(SUM(size_gb),0) FROM volumes WHERE host_id=? AND deleted_at IS NULL`, hostID, | ||
| 1160 | ).Scan(&vol); err != nil { | ||
| 1161 | return c, err | ||
| 1162 | } | ||
| 1163 | c.Live.DiskGB += vol | ||
| 1164 | return c, nil | ||
| 1094 | } | 1165 | } |
| 1095 | 1166 | ||
| 1096 | // AuditEntry is one row of the append-only audit trail. | 1167 | // AuditEntry is one row of the append-only audit trail. |
| @@ -1320,9 +1391,16 @@ func (s *Store) TenantForUserCA(pubkey string) (tenant string, ok bool, err erro | |||
| 1320 | } | 1391 | } |
| 1321 | 1392 | ||
| 1322 | // RemoveHost finalizes decommission: it deletes the host row, refusing | 1393 | // RemoveHost finalizes decommission: it deletes the host row, refusing |
| 1323 | // (in-transaction) while any VM rows remain for the host (not yet reaped). | 1394 | // (in-transaction) while any VM rows remain for the host (not yet reaped), and |
| 1395 | // while any volume rows do — live or tombstoned, because either way the bytes | ||
| 1396 | // are still on that disk. | ||
| 1324 | // Nothing is recycled — next_cidr_index only moves forward, so a removed host's | 1397 | // Nothing is recycled — next_cidr_index only moves forward, so a removed host's |
| 1325 | // subnet is retired with it rather than handed to the next host to enroll. | 1398 | // subnet is retired with it rather than handed to the next host to enroll. |
| 1399 | // | ||
| 1400 | // A held volume is a different refusal from an undrained VM and returns | ||
| 1401 | // ErrHostHoldsVolumes to say so. A VM drains on its own; a volume never does, | ||
| 1402 | // because outliving its guests is the whole point of one. The operator has to | ||
| 1403 | // delete the claims, let the agent reclaim them, and the sweep then succeeds. | ||
| 1326 | func (s *Store) RemoveHost(id string) error { | 1404 | func (s *Store) RemoveHost(id string) error { |
| 1327 | tx, err := s.db.Begin() | 1405 | tx, err := s.db.Begin() |
| 1328 | if err != nil { | 1406 | if err != nil { |
| @@ -1330,13 +1408,18 @@ func (s *Store) RemoveHost(id string) error { | |||
| 1330 | } | 1408 | } |
| 1331 | defer tx.Rollback() | 1409 | defer tx.Rollback() |
| 1332 | 1410 | ||
| 1333 | var n int | 1411 | var n, vols int |
| 1334 | if err := tx.QueryRow(`SELECT COUNT(*) FROM vms WHERE host_id=?`, id).Scan(&n); err != nil { | 1412 | if err := tx.QueryRow(`SELECT |
| 1413 | (SELECT COUNT(*) FROM vms WHERE host_id=?), | ||
| 1414 | (SELECT COUNT(*) FROM volumes WHERE host_id=?)`, id, id).Scan(&n, &vols); err != nil { | ||
| 1335 | return fmt.Errorf("count host vms: %w", err) | 1415 | return fmt.Errorf("count host vms: %w", err) |
| 1336 | } | 1416 | } |
| 1337 | if n > 0 { | 1417 | if n > 0 { |
| 1338 | return fmt.Errorf("host %s still has %d VM(s); not drained", id, n) | 1418 | return fmt.Errorf("host %s still has %d VM(s); not drained", id, n) |
| 1339 | } | 1419 | } |
| 1420 | if vols > 0 { | ||
| 1421 | return fmt.Errorf("host %s still has %d volume(s); delete their claims first: %w", id, vols, ErrHostHoldsVolumes) | ||
| 1422 | } | ||
| 1340 | 1423 | ||
| 1341 | res, err := tx.Exec(`DELETE FROM hosts WHERE id=?`, id) | 1424 | res, err := tx.Exec(`DELETE FROM hosts WHERE id=?`, id) |
| 1342 | if err != nil { | 1425 | if err != nil { |
| @@ -1351,16 +1434,43 @@ func (s *Store) RemoveHost(id string) error { | |||
| 1351 | return tx.Commit() | 1434 | return tx.Commit() |
| 1352 | } | 1435 | } |
| 1353 | 1436 | ||
| 1437 | // ForceRemoval is the tally a forced host removal returns: what it destroyed | ||
| 1438 | // and what it handed back. The one path that loses data on purpose has to be | ||
| 1439 | // able to say what it lost, so the audit row can say it too — "the host is | ||
| 1440 | // gone" is not an answer to "which volumes went with it". | ||
| 1441 | // | ||
| 1442 | // VolumeIDs names the destroyed volumes, in the order the store lists them | ||
| 1443 | // (by id), so the same removal reports the same way twice. VMsPurged counts VM | ||
| 1444 | // rows, live and tombstoned alike. ClaimsUnbound counts only the claims that | ||
| 1445 | // LIVE ON as Pending: a claim already tombstoned here is unbound and then | ||
| 1446 | // deleted outright, and nothing is waiting for it afterwards. | ||
| 1447 | type ForceRemoval struct { | ||
| 1448 | VMsPurged int | ||
| 1449 | VolumesDestroyed int | ||
| 1450 | ClaimsUnbound int | ||
| 1451 | VolumeIDs []string | ||
| 1452 | } | ||
| 1453 | |||
| 1354 | // ForceRemoveHost finalizes a host whose agent will never drain it (dead | 1454 | // ForceRemoveHost finalizes a host whose agent will never drain it (dead |
| 1355 | // hardware): it purges every VM row for the host and deletes the host row, both | 1455 | // hardware): it purges every VM row for the host and deletes the host row, both |
| 1356 | // in one transaction. Like RemoveHost it recycles nothing. Unlike the | 1456 | // in one transaction. Like RemoveHost it recycles nothing. Unlike the |
| 1357 | // graceful path it does NOT wait for the agent to ack destroys, so it must only | 1457 | // graceful path it does NOT wait for the agent to ack destroys, so it must only |
| 1358 | // be used when the host is known gone; any VMs still physically running are | 1458 | // be used when the host is known gone; any VMs still physically running are |
| 1359 | // orphaned with the hardware. Returns the number of VM rows purged. | 1459 | // orphaned with the hardware. Returns the tally of what went. |
| 1360 | func (s *Store) ForceRemoveHost(id string) (int, error) { | 1460 | // |
| 1461 | // THIS IS THE ONE PATH THAT LOSES DATA ON PURPOSE. The host's volumes go with | ||
| 1462 | // the host, because that is where the bytes were: a live claim placed here | ||
| 1463 | // returns to Pending and the next VM that names it places it somewhere else, | ||
| 1464 | // with nothing of the old contents. A claim already tombstoned here is deleted | ||
| 1465 | // outright — the reclaim it was waiting for can never be acked by a host that | ||
| 1466 | // is gone, so keeping the row would only leave an unreapable tombstone behind. | ||
| 1467 | // The operator asking for force has already decided the hardware is lost; | ||
| 1468 | // refusing to admit its disks went with it would help nobody. | ||
| 1469 | func (s *Store) ForceRemoveHost(id string) (ForceRemoval, error) { | ||
| 1470 | var out ForceRemoval | ||
| 1361 | tx, err := s.db.Begin() | 1471 | tx, err := s.db.Begin() |
| 1362 | if err != nil { | 1472 | if err != nil { |
| 1363 | return 0, err | 1473 | return out, err |
| 1364 | } | 1474 | } |
| 1365 | defer tx.Rollback() | 1475 | defer tx.Rollback() |
| 1366 | 1476 | ||
| @@ -1369,27 +1479,70 @@ func (s *Store) ForceRemoveHost(id string) (int, error) { | |||
| 1369 | var exists int | 1479 | var exists int |
| 1370 | switch err := tx.QueryRow(`SELECT 1 FROM hosts WHERE id=?`, id).Scan(&exists); { | 1480 | switch err := tx.QueryRow(`SELECT 1 FROM hosts WHERE id=?`, id).Scan(&exists); { |
| 1371 | case errors.Is(err, sql.ErrNoRows): | 1481 | case errors.Is(err, sql.ErrNoRows): |
| 1372 | return 0, sql.ErrNoRows | 1482 | return out, sql.ErrNoRows |
| 1373 | case err != nil: | 1483 | case err != nil: |
| 1374 | return 0, fmt.Errorf("lookup host: %w", err) | 1484 | return out, fmt.Errorf("lookup host: %w", err) |
| 1485 | } | ||
| 1486 | |||
| 1487 | // The volumes whose bytes lived on this host and the claims they were | ||
| 1488 | // placed for, read BEFORE anything is deleted and drained to completion | ||
| 1489 | // before the first write — the store runs one connection, so an open | ||
| 1490 | // result set blocks every statement below. | ||
| 1491 | volumeIDs, claimIDs, err := volumesOnHost(tx, id) | ||
| 1492 | if err != nil { | ||
| 1493 | return out, err | ||
| 1494 | } | ||
| 1495 | |||
| 1496 | // Unbind first, while the volumes rows this matches still exist. | ||
| 1497 | res, err := tx.Exec(`UPDATE volume_claims SET bound_volume_id=NULL | ||
| 1498 | WHERE bound_volume_id IN (SELECT id FROM volumes WHERE host_id=?)`, id) | ||
| 1499 | if err != nil { | ||
| 1500 | return out, fmt.Errorf("unbind host claims: %w", err) | ||
| 1375 | } | 1501 | } |
| 1502 | unbound, _ := res.RowsAffected() | ||
| 1376 | 1503 | ||
| 1377 | res, err := tx.Exec(`DELETE FROM vms WHERE host_id=?`, id) | 1504 | // VMs before volumes: deleting a VM cascades its attachments away, and a |
| 1505 | // volume_claims row cannot go while an attachment still references it. | ||
| 1506 | res, err = tx.Exec(`DELETE FROM vms WHERE host_id=?`, id) | ||
| 1378 | if err != nil { | 1507 | if err != nil { |
| 1379 | return 0, fmt.Errorf("purge host vms: %w", err) | 1508 | return out, fmt.Errorf("purge host vms: %w", err) |
| 1380 | } | 1509 | } |
| 1381 | purged, _ := res.RowsAffected() | 1510 | purged, _ := res.RowsAffected() |
| 1382 | 1511 | ||
| 1512 | res, err = tx.Exec(`DELETE FROM volumes WHERE host_id=?`, id) | ||
| 1513 | if err != nil { | ||
| 1514 | return out, fmt.Errorf("purge host volumes: %w", err) | ||
| 1515 | } | ||
| 1516 | destroyed, _ := res.RowsAffected() | ||
| 1517 | |||
| 1518 | // A claim already tombstoned here has nothing left to reclaim: its host is | ||
| 1519 | // gone and no ack is coming. Live claims survive as Pending, so a claim | ||
| 1520 | // deleted here is one the unbind count above must not claim to have freed. | ||
| 1521 | var tombstoned int64 | ||
| 1522 | for _, claimID := range claimIDs { | ||
| 1523 | res, err := tx.Exec(`DELETE FROM volume_claims WHERE id=? AND deleted_at IS NOT NULL`, claimID) | ||
| 1524 | if err != nil { | ||
| 1525 | return out, fmt.Errorf("purge tombstoned claim: %w", err) | ||
| 1526 | } | ||
| 1527 | n, _ := res.RowsAffected() | ||
| 1528 | tombstoned += n | ||
| 1529 | } | ||
| 1530 | |||
| 1383 | if _, err := tx.Exec(`DELETE FROM hosts WHERE id=?`, id); err != nil { | 1531 | if _, err := tx.Exec(`DELETE FROM hosts WHERE id=?`, id); err != nil { |
| 1384 | return 0, fmt.Errorf("delete host: %w", err) | 1532 | return out, fmt.Errorf("delete host: %w", err) |
| 1385 | } | 1533 | } |
| 1386 | if err := bumpEpoch(tx); err != nil { | 1534 | if err := bumpEpoch(tx); err != nil { |
| 1387 | return 0, fmt.Errorf("bump epoch: %w", err) | 1535 | return out, fmt.Errorf("bump epoch: %w", err) |
| 1388 | } | 1536 | } |
| 1389 | if err := tx.Commit(); err != nil { | 1537 | if err := tx.Commit(); err != nil { |
| 1390 | return 0, err | 1538 | return out, err |
| 1391 | } | 1539 | } |
| 1392 | return int(purged), nil | 1540 | return ForceRemoval{ |
| 1541 | VMsPurged: int(purged), | ||
| 1542 | VolumesDestroyed: int(destroyed), | ||
| 1543 | ClaimsUnbound: int(unbound - tombstoned), | ||
| 1544 | VolumeIDs: volumeIDs, | ||
| 1545 | }, nil | ||
| 1393 | } | 1546 | } |
| 1394 | 1547 | ||
| 1395 | // usableAddress reports whether ip is an address something could actually be | 1548 | // usableAddress reports whether ip is an address something could actually be |
| @@ -1569,6 +1722,27 @@ func scanVM(rows *sql.Rows) (VM, error) { | |||
| 1569 | // and SpecForHost all share this — they differ only in WHERE clause, | 1722 | // and SpecForHost all share this — they differ only in WHERE clause, |
| 1570 | // row-count expectations, and whether q is *sql.DB or an in-flight *sql.Tx. | 1723 | // row-count expectations, and whether q is *sql.DB or an in-flight *sql.Tx. |
| 1571 | func queryVMs(q querier, where string, args ...any) ([]VM, error) { | 1724 | func queryVMs(q querier, where string, args ...any) ([]VM, error) { |
| 1725 | // Scan every row and CLOSE before reading volumes: the store runs one | ||
| 1726 | // connection, so a second query issued while this result set is open would | ||
| 1727 | // deadlock. scanVMRows is a separate function so the close is a scope | ||
| 1728 | // boundary the compiler enforces rather than a rule to remember. | ||
| 1729 | vms, err := scanVMRows(q, where, args...) | ||
| 1730 | if err != nil { | ||
| 1731 | return nil, err | ||
| 1732 | } | ||
| 1733 | // A VM's volumes are a list, so they live in their own table and are | ||
| 1734 | // filled in here rather than being a column of the row above. | ||
| 1735 | for i := range vms { | ||
| 1736 | ids, err := volumeIDsForVM(q, vms[i].ID) | ||
| 1737 | if err != nil { | ||
| 1738 | return nil, fmt.Errorf("read vm volumes: %w", err) | ||
| 1739 | } | ||
| 1740 | vms[i].VolumeIDs = ids | ||
| 1741 | } | ||
| 1742 | return vms, nil | ||
| 1743 | } | ||
| 1744 | |||
| 1745 | func scanVMRows(q querier, where string, args ...any) ([]VM, error) { | ||
| 1572 | rows, err := q.Query(`SELECT `+vmColumns+` FROM vms`+where, args...) | 1746 | rows, err := q.Query(`SELECT `+vmColumns+` FROM vms`+where, args...) |
| 1573 | if err != nil { | 1747 | if err != nil { |
| 1574 | return nil, err | 1748 | return nil, err |
internal/server/store/store_test.go
| Old | New | ||
|---|---|---|---|
| @@ -843,9 +843,12 @@ func TestForceRemoveHostPurgesVMs(t *testing.T) { | |||
| 843 | // filter, so a tombstoned row is purged — and counted — like a live one). | 843 | // filter, so a tombstoned row is purged — and counted — like a live one). |
| 844 | require.NoError(t, s.TombstoneVM(vmB.ID)) | 844 | require.NoError(t, s.TombstoneVM(vmB.ID)) |
| 845 | 845 | ||
| 846 | purged, err := s.ForceRemoveHost(h.ID) | 846 | gone, err := s.ForceRemoveHost(h.ID) |
| 847 | require.NoError(t, err) | 847 | require.NoError(t, err) |
| 848 | assert.Equal(t, 2, purged, "both VM rows — live and tombstoned — must be purged") | 848 | assert.Equal(t, 2, gone.VMsPurged, "both VM rows — live and tombstoned — must be purged") |
| 849 | assert.Zero(t, gone.VolumesDestroyed, "these VMs held no volumes") | ||
| 850 | assert.Zero(t, gone.ClaimsUnbound) | ||
| 851 | assert.Empty(t, gone.VolumeIDs) | ||
| 849 | 852 | ||
| 850 | _, err = s.GetHost(h.ID) | 853 | _, err = s.GetHost(h.ID) |
| 851 | assert.Error(t, err, "host row must be gone") | 854 | assert.Error(t, err, "host row must be gone") |
internal/server/store/volumes.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,315 @@ | |||
| 1 | package store | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "database/sql" | ||
| 5 | "errors" | ||
| 6 | "fmt" | ||
| 7 | "time" | ||
| 8 | |||
| 9 | "github.com/a73x/eitri/internal/random" | ||
| 10 | sqlite "modernc.org/sqlite" | ||
| 11 | ) | ||
| 12 | |||
| 13 | // VolumeClaim is a tenant's request for durable storage. HostID and VMID are | ||
| 14 | // joined facts: where the bytes were placed, and which VM holds them now. | ||
| 15 | type VolumeClaim struct { | ||
| 16 | ID, Tenant, Name string | ||
| 17 | SizeGB int64 | ||
| 18 | BoundVolumeID string | ||
| 19 | HostID string | ||
| 20 | VMID string | ||
| 21 | CreatedAt time.Time | ||
| 22 | DeletedAt *time.Time | ||
| 23 | } | ||
| 24 | |||
| 25 | // Volume is the fleet's placement of one claim's bytes on one host. | ||
| 26 | type Volume struct { | ||
| 27 | ID, HostID, ClaimID string | ||
| 28 | SizeGB int64 | ||
| 29 | CreatedAt time.Time | ||
| 30 | DeletedAt *time.Time | ||
| 31 | } | ||
| 32 | |||
| 33 | var ( | ||
| 34 | ErrClaimNotFound = errors.New("volume claim not found") | ||
| 35 | ErrClaimNameTaken = errors.New("volume claim name already in use") | ||
| 36 | ErrClaimAttached = errors.New("volume claim is attached to a vm") | ||
| 37 | ErrClaimPinned = errors.New("volume claim is bound to another host") | ||
| 38 | ) | ||
| 39 | |||
| 40 | // ClaimAttachedError names the VM that holds the claim. | ||
| 41 | type ClaimAttachedError struct{ ClaimID, VMID string } | ||
| 42 | |||
| 43 | func (e *ClaimAttachedError) Error() string { | ||
| 44 | return fmt.Sprintf("volume claim %s is attached to vm %s", e.ClaimID, e.VMID) | ||
| 45 | } | ||
| 46 | func (e *ClaimAttachedError) Is(target error) bool { return target == ErrClaimAttached } | ||
| 47 | |||
| 48 | // ClaimPinnedError names the host the claim's data lives on. | ||
| 49 | type ClaimPinnedError struct{ ClaimID, HostID string } | ||
| 50 | |||
| 51 | func (e *ClaimPinnedError) Error() string { | ||
| 52 | return fmt.Sprintf("volume claim %s is bound to host %s", e.ClaimID, e.HostID) | ||
| 53 | } | ||
| 54 | func (e *ClaimPinnedError) Is(target error) bool { return target == ErrClaimPinned } | ||
| 55 | |||
| 56 | // claimColumns / claimFrom are one positional contract with scanClaim. | ||
| 57 | const claimColumns = `c.id, c.tenant, c.name, c.size_gb, COALESCE(c.bound_volume_id,''), | ||
| 58 | COALESCE(v.host_id,''), COALESCE(a.vm_id,''), c.created_at, c.deleted_at` | ||
| 59 | |||
| 60 | const claimFrom = ` FROM volume_claims c | ||
| 61 | LEFT JOIN volumes v ON v.id = c.bound_volume_id | ||
| 62 | LEFT JOIN volume_attachments a ON a.claim_id = c.id` | ||
| 63 | |||
| 64 | // scanner is satisfied by *sql.Row and *sql.Rows alike, so the single-row read | ||
| 65 | // and the list share one scan. | ||
| 66 | type scanner interface{ Scan(dest ...any) error } | ||
| 67 | |||
| 68 | func scanClaim(row scanner) (VolumeClaim, error) { | ||
| 69 | var c VolumeClaim | ||
| 70 | var createdAt string | ||
| 71 | var deletedAt sql.NullString | ||
| 72 | if err := row.Scan(&c.ID, &c.Tenant, &c.Name, &c.SizeGB, &c.BoundVolumeID, &c.HostID, &c.VMID, &createdAt, &deletedAt); err != nil { | ||
| 73 | return VolumeClaim{}, err | ||
| 74 | } | ||
| 75 | c.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) | ||
| 76 | if deletedAt.Valid { | ||
| 77 | t, _ := time.Parse(time.RFC3339, deletedAt.String) | ||
| 78 | c.DeletedAt = &t | ||
| 79 | } | ||
| 80 | return c, nil | ||
| 81 | } | ||
| 82 | |||
| 83 | // CreateVolumeClaim records a request for storage. It places nothing: a claim | ||
| 84 | // is Pending until the first VM that names it decides which host it lands on. | ||
| 85 | func (s *Store) CreateVolumeClaim(tenant, name string, sizeGB int64) (VolumeClaim, error) { | ||
| 86 | c := VolumeClaim{ID: random.Hex(16), Tenant: tenant, Name: name, SizeGB: sizeGB, CreatedAt: time.Now().UTC()} | ||
| 87 | _, err := s.db.Exec(`INSERT INTO volume_claims(id, tenant, name, size_gb, created_at) VALUES (?,?,?,?,?)`, | ||
| 88 | c.ID, c.Tenant, c.Name, c.SizeGB, c.CreatedAt.Format(time.RFC3339)) | ||
| 89 | if err != nil { | ||
| 90 | // SQLITE_CONSTRAINT_UNIQUE (2067): volume_claims_tenant_name. | ||
| 91 | if serr, ok := errors.AsType[*sqlite.Error](err); ok && serr.Code() == 2067 { | ||
| 92 | return VolumeClaim{}, ErrClaimNameTaken | ||
| 93 | } | ||
| 94 | return VolumeClaim{}, fmt.Errorf("insert volume claim: %w", err) | ||
| 95 | } | ||
| 96 | return c, nil | ||
| 97 | } | ||
| 98 | |||
| 99 | // ListVolumeClaims returns tenant's live claims, oldest first. | ||
| 100 | func (s *Store) ListVolumeClaims(tenant string) ([]VolumeClaim, error) { | ||
| 101 | rows, err := s.db.Query(`SELECT `+claimColumns+claimFrom+` WHERE c.tenant=? AND c.deleted_at IS NULL ORDER BY c.created_at, c.id`, tenant) | ||
| 102 | if err != nil { | ||
| 103 | return nil, err | ||
| 104 | } | ||
| 105 | defer rows.Close() | ||
| 106 | var out []VolumeClaim | ||
| 107 | for rows.Next() { | ||
| 108 | c, err := scanClaim(rows) | ||
| 109 | if err != nil { | ||
| 110 | return nil, err | ||
| 111 | } | ||
| 112 | out = append(out, c) | ||
| 113 | } | ||
| 114 | return out, rows.Err() | ||
| 115 | } | ||
| 116 | |||
| 117 | // GetVolumeClaim reads one live claim; a tombstoned one reads as sql.ErrNoRows. | ||
| 118 | func (s *Store) GetVolumeClaim(id string) (VolumeClaim, error) { | ||
| 119 | return scanClaim(s.db.QueryRow(`SELECT `+claimColumns+claimFrom+` WHERE c.id=? AND c.deleted_at IS NULL`, id)) | ||
| 120 | } | ||
| 121 | |||
| 122 | // GetVolumeClaimAny reads one claim whether live or tombstoned. It exists for | ||
| 123 | // the reap, which asks after a claim it is about to delete precisely because | ||
| 124 | // that claim is already tombstoned: GetVolumeClaim would answer ErrNoRows and | ||
| 125 | // the terminal audit row would lose its tenant. | ||
| 126 | func (s *Store) GetVolumeClaimAny(id string) (VolumeClaim, error) { | ||
| 127 | return scanClaim(s.db.QueryRow(`SELECT `+claimColumns+claimFrom+` WHERE c.id=?`, id)) | ||
| 128 | } | ||
| 129 | |||
| 130 | // TombstoneVolumeClaim marks a claim and its bound volume (if any) for | ||
| 131 | // reclaim. Refused while a VM holds the claim: the guest may be writing. | ||
| 132 | func (s *Store) TombstoneVolumeClaim(id string) error { | ||
| 133 | tx, err := s.db.Begin() | ||
| 134 | if err != nil { | ||
| 135 | return err | ||
| 136 | } | ||
| 137 | defer tx.Rollback() | ||
| 138 | var vmID, bound sql.NullString | ||
| 139 | err = tx.QueryRow(`SELECT a.vm_id, c.bound_volume_id FROM volume_claims c | ||
| 140 | LEFT JOIN volume_attachments a ON a.claim_id=c.id WHERE c.id=? AND c.deleted_at IS NULL`, id).Scan(&vmID, &bound) | ||
| 141 | if err != nil { | ||
| 142 | return err // sql.ErrNoRows passes through | ||
| 143 | } | ||
| 144 | if vmID.Valid { | ||
| 145 | return &ClaimAttachedError{ClaimID: id, VMID: vmID.String} | ||
| 146 | } | ||
| 147 | now := time.Now().UTC().Format(time.RFC3339) | ||
| 148 | if _, err := tx.Exec(`UPDATE volume_claims SET deleted_at=? WHERE id=?`, now, id); err != nil { | ||
| 149 | return err | ||
| 150 | } | ||
| 151 | if bound.Valid { | ||
| 152 | if _, err := tx.Exec(`UPDATE volumes SET deleted_at=? WHERE id=? AND deleted_at IS NULL`, now, bound.String); err != nil { | ||
| 153 | return err | ||
| 154 | } | ||
| 155 | // The host must see the tombstone; an unbound claim has no host. | ||
| 156 | if err := bumpEpoch(tx); err != nil { | ||
| 157 | return err | ||
| 158 | } | ||
| 159 | } | ||
| 160 | return tx.Commit() | ||
| 161 | } | ||
| 162 | |||
| 163 | // HardDeleteVolume removes a tombstoned volume row once its host has reported | ||
| 164 | // the file gone, and the tombstoned claim that owned it. sql.ErrNoRows when the | ||
| 165 | // volume is absent, or when EITHER the volume or its claim is still live. | ||
| 166 | // | ||
| 167 | // The claim's tombstone is part of the guard, not an assumption: reaping the | ||
| 168 | // bytes of a claim somebody still holds would leave that claim Pending and | ||
| 169 | // re-bindable, so the next VM naming it would silently get an empty disk where | ||
| 170 | // the tenant expects their data. Deleting both rows together, or neither, is | ||
| 171 | // the only pair of outcomes that cannot lie. | ||
| 172 | func (s *Store) HardDeleteVolume(id string) error { | ||
| 173 | tx, err := s.db.Begin() | ||
| 174 | if err != nil { | ||
| 175 | return err | ||
| 176 | } | ||
| 177 | defer tx.Rollback() | ||
| 178 | var claimID string | ||
| 179 | if err := tx.QueryRow(`SELECT v.claim_id FROM volumes v | ||
| 180 | JOIN volume_claims c ON c.id = v.claim_id | ||
| 181 | WHERE v.id=? AND v.deleted_at IS NOT NULL AND c.deleted_at IS NOT NULL`, id).Scan(&claimID); err != nil { | ||
| 182 | return err | ||
| 183 | } | ||
| 184 | // Volume first: volumes.claim_id references the row deleted below. | ||
| 185 | if _, err := tx.Exec(`DELETE FROM volumes WHERE id=?`, id); err != nil { | ||
| 186 | return err | ||
| 187 | } | ||
| 188 | if _, err := tx.Exec(`DELETE FROM volume_claims WHERE id=?`, claimID); err != nil { | ||
| 189 | return err | ||
| 190 | } | ||
| 191 | if err := bumpEpoch(tx); err != nil { | ||
| 192 | return err | ||
| 193 | } | ||
| 194 | return tx.Commit() | ||
| 195 | } | ||
| 196 | |||
| 197 | // volumesOnHost lists the volumes living on one host and the claims they are | ||
| 198 | // placed for, in id order so a caller reporting them reports them the same way | ||
| 199 | // twice. It fully drains its result set before returning, so the caller may | ||
| 200 | // write inside the same transaction — the store's single connection allows | ||
| 201 | // nothing else. | ||
| 202 | func volumesOnHost(q querier, hostID string) (volumeIDs, claimIDs []string, err error) { | ||
| 203 | rows, err := q.Query(`SELECT id, claim_id FROM volumes WHERE host_id=? ORDER BY id`, hostID) | ||
| 204 | if err != nil { | ||
| 205 | return nil, nil, fmt.Errorf("list host volumes: %w", err) | ||
| 206 | } | ||
| 207 | defer rows.Close() | ||
| 208 | for rows.Next() { | ||
| 209 | var volID, claimID string | ||
| 210 | if err := rows.Scan(&volID, &claimID); err != nil { | ||
| 211 | return nil, nil, fmt.Errorf("scan host volume: %w", err) | ||
| 212 | } | ||
| 213 | volumeIDs = append(volumeIDs, volID) | ||
| 214 | claimIDs = append(claimIDs, claimID) | ||
| 215 | } | ||
| 216 | if err := rows.Err(); err != nil { | ||
| 217 | return nil, nil, err | ||
| 218 | } | ||
| 219 | return volumeIDs, claimIDs, nil | ||
| 220 | } | ||
| 221 | |||
| 222 | // ListVolumesForHost is the host's full volume set, tombstoned rows included: | ||
| 223 | // the snapshot carries both so the agent can reclaim. | ||
| 224 | func (s *Store) ListVolumesForHost(hostID string) ([]Volume, error) { | ||
| 225 | rows, err := s.db.Query(`SELECT id, host_id, claim_id, size_gb, created_at, deleted_at | ||
| 226 | FROM volumes WHERE host_id=? ORDER BY created_at, id`, hostID) | ||
| 227 | if err != nil { | ||
| 228 | return nil, err | ||
| 229 | } | ||
| 230 | defer rows.Close() | ||
| 231 | var out []Volume | ||
| 232 | for rows.Next() { | ||
| 233 | var v Volume | ||
| 234 | var createdAt string | ||
| 235 | var deletedAt sql.NullString | ||
| 236 | if err := rows.Scan(&v.ID, &v.HostID, &v.ClaimID, &v.SizeGB, &createdAt, &deletedAt); err != nil { | ||
| 237 | return nil, err | ||
| 238 | } | ||
| 239 | v.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) | ||
| 240 | if deletedAt.Valid { | ||
| 241 | t, _ := time.Parse(time.RFC3339, deletedAt.String) | ||
| 242 | v.DeletedAt = &t | ||
| 243 | } | ||
| 244 | out = append(out, v) | ||
| 245 | } | ||
| 246 | return out, rows.Err() | ||
| 247 | } | ||
| 248 | |||
| 249 | // bindClaims runs inside CreateVM's transaction: every claim must be the VM's | ||
| 250 | // tenant's and live, unattached, and either Pending or already on this host. | ||
| 251 | // A Pending claim is bound HERE — a volumes row on vm.HostID — so binding is | ||
| 252 | // atomic with placement and never a background job. | ||
| 253 | func bindClaims(tx *sql.Tx, vm VM) error { | ||
| 254 | now := time.Now().UTC().Format(time.RFC3339) | ||
| 255 | for _, claimID := range vm.VolumeClaimIDs { | ||
| 256 | var sizeGB int64 | ||
| 257 | var bound, boundHost, holder sql.NullString | ||
| 258 | err := tx.QueryRow(`SELECT c.size_gb, c.bound_volume_id, v.host_id, a.vm_id FROM volume_claims c | ||
| 259 | LEFT JOIN volumes v ON v.id=c.bound_volume_id | ||
| 260 | LEFT JOIN volume_attachments a ON a.claim_id=c.id | ||
| 261 | WHERE c.id=? AND c.tenant=? AND c.deleted_at IS NULL`, claimID, vm.Tenant). | ||
| 262 | Scan(&sizeGB, &bound, &boundHost, &holder) | ||
| 263 | switch { | ||
| 264 | case errors.Is(err, sql.ErrNoRows): | ||
| 265 | return ErrClaimNotFound | ||
| 266 | case err != nil: | ||
| 267 | return fmt.Errorf("lookup claim: %w", err) | ||
| 268 | case holder.Valid: | ||
| 269 | return &ClaimAttachedError{ClaimID: claimID, VMID: holder.String} | ||
| 270 | case bound.Valid && boundHost.String != vm.HostID: | ||
| 271 | return &ClaimPinnedError{ClaimID: claimID, HostID: boundHost.String} | ||
| 272 | } | ||
| 273 | if !bound.Valid { | ||
| 274 | volID := random.Hex(16) | ||
| 275 | if _, err := tx.Exec(`INSERT INTO volumes(id, host_id, claim_id, size_gb, created_at) VALUES (?,?,?,?,?)`, | ||
| 276 | volID, vm.HostID, claimID, sizeGB, now); err != nil { | ||
| 277 | return fmt.Errorf("place volume: %w", err) | ||
| 278 | } | ||
| 279 | if _, err := tx.Exec(`UPDATE volume_claims SET bound_volume_id=? WHERE id=?`, volID, claimID); err != nil { | ||
| 280 | return fmt.Errorf("bind claim: %w", err) | ||
| 281 | } | ||
| 282 | } | ||
| 283 | if _, err := tx.Exec(`INSERT INTO volume_attachments(claim_id, vm_id) VALUES (?,?)`, claimID, vm.ID); err != nil { | ||
| 284 | // The unique index is the race's referee: the other create won. | ||
| 285 | // The store runs one connection, so this is belt-and-braces. | ||
| 286 | if serr, ok := errors.AsType[*sqlite.Error](err); ok && serr.Code() == 2067 { | ||
| 287 | return &ClaimAttachedError{ClaimID: claimID, VMID: "another vm"} | ||
| 288 | } | ||
| 289 | return fmt.Errorf("attach claim: %w", err) | ||
| 290 | } | ||
| 291 | } | ||
| 292 | return nil | ||
| 293 | } | ||
| 294 | |||
| 295 | // volumeIDsForVM is the attachment order, which is the order the VM named its | ||
| 296 | // claims (rowid of the attachment row). | ||
| 297 | func volumeIDsForVM(q querier, vmID string) ([]string, error) { | ||
| 298 | rows, err := q.Query(`SELECT c.bound_volume_id FROM volume_attachments a | ||
| 299 | JOIN volume_claims c ON c.id=a.claim_id WHERE a.vm_id=? ORDER BY a.rowid`, vmID) | ||
| 300 | if err != nil { | ||
| 301 | return nil, err | ||
| 302 | } | ||
| 303 | defer rows.Close() | ||
| 304 | var out []string | ||
| 305 | for rows.Next() { | ||
| 306 | var id sql.NullString | ||
| 307 | if err := rows.Scan(&id); err != nil { | ||
| 308 | return nil, err | ||
| 309 | } | ||
| 310 | if id.Valid { | ||
| 311 | out = append(out, id.String) | ||
| 312 | } | ||
| 313 | } | ||
| 314 | return out, rows.Err() | ||
| 315 | } | ||
internal/server/store/volumes_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,383 @@ | |||
| 1 | package store | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "database/sql" | ||
| 5 | "regexp" | ||
| 6 | "sort" | ||
| 7 | "testing" | ||
| 8 | |||
| 9 | "github.com/stretchr/testify/require" | ||
| 10 | ) | ||
| 11 | |||
| 12 | // enrollSecondHost gives the fleet a host that is not enrollHost's, so a claim | ||
| 13 | // bound on one host can be asked for from the other. | ||
| 14 | func enrollSecondHost(t *testing.T, s *Store) Host { | ||
| 15 | t.Helper() | ||
| 16 | tok, err := s.CreateEnrollmentToken(testTenant) | ||
| 17 | require.NoError(t, err) | ||
| 18 | h, err := s.RedeemEnrollmentToken(tok, EnrollFacts{Name: "host-b", OS: "linux", Arch: "amd64", Provisioner: "cloudhv", Remote: ""}) | ||
| 19 | require.NoError(t, err) | ||
| 20 | return h | ||
| 21 | } | ||
| 22 | |||
| 23 | func testVM(id, host string, claims ...string) VM { | ||
| 24 | return VM{ID: id, HostID: host, Name: id, ImageURL: "u", ImageSHA256: "s", | ||
| 25 | VCPUs: 1, MemMB: 1, DiskGB: 1, PowerState: "running", VolumeClaimIDs: claims} | ||
| 26 | } | ||
| 27 | |||
| 28 | func TestVolumeClaimLifecycle(t *testing.T) { | ||
| 29 | st := newStore(t) | ||
| 30 | hostID := enrollHost(t, st).ID | ||
| 31 | |||
| 32 | c, err := st.CreateVolumeClaim(testTenant, "data", 5) | ||
| 33 | require.NoError(t, err) | ||
| 34 | require.Equal(t, "", c.BoundVolumeID, "a fresh claim is Pending") | ||
| 35 | _, err = st.CreateVolumeClaim(testTenant, "data", 5) | ||
| 36 | require.ErrorIs(t, err, ErrClaimNameTaken) | ||
| 37 | |||
| 38 | // First VM naming the claim binds it on its host. | ||
| 39 | require.NoError(t, st.CreateVM(testVM("vm1", hostID, c.ID))) | ||
| 40 | got, err := st.GetVolumeClaim(c.ID) | ||
| 41 | require.NoError(t, err) | ||
| 42 | require.NotEmpty(t, got.BoundVolumeID) | ||
| 43 | require.Equal(t, hostID, got.HostID) | ||
| 44 | require.Equal(t, "vm1", got.VMID) | ||
| 45 | vm, err := st.GetVM("vm1") | ||
| 46 | require.NoError(t, err) | ||
| 47 | require.Equal(t, []string{got.BoundVolumeID}, vm.VolumeIDs) | ||
| 48 | |||
| 49 | // Second VM: attached → refused naming the holder; nothing of it remains. | ||
| 50 | err = st.CreateVM(testVM("vm2", hostID, c.ID)) | ||
| 51 | var attached *ClaimAttachedError | ||
| 52 | require.ErrorAs(t, err, &attached) | ||
| 53 | require.Equal(t, "vm1", attached.VMID) | ||
| 54 | require.ErrorIs(t, err, ErrClaimAttached) | ||
| 55 | _, err = st.GetVM("vm2") | ||
| 56 | require.ErrorIs(t, err, sql.ErrNoRows, "the refused create rolled back entirely") | ||
| 57 | |||
| 58 | // Delete while attached → refused. | ||
| 59 | require.ErrorIs(t, st.TombstoneVolumeClaim(c.ID), ErrClaimAttached) | ||
| 60 | |||
| 61 | // Destroy the VM: attachment gone, volume and claim untouched. | ||
| 62 | require.NoError(t, st.TombstoneVM("vm1")) | ||
| 63 | require.NoError(t, st.HardDeleteVM("vm1", hostID)) | ||
| 64 | got, err = st.GetVolumeClaim(c.ID) | ||
| 65 | require.NoError(t, err) | ||
| 66 | require.Equal(t, "", got.VMID) | ||
| 67 | require.Equal(t, hostID, got.HostID, "the data still lives where it was placed") | ||
| 68 | |||
| 69 | // A VM on another host cannot take a bound claim. | ||
| 70 | other := enrollSecondHost(t, st).ID | ||
| 71 | err = st.CreateVM(testVM("vm3", other, c.ID)) | ||
| 72 | var pinned *ClaimPinnedError | ||
| 73 | require.ErrorAs(t, err, &pinned) | ||
| 74 | require.Equal(t, hostID, pinned.HostID) | ||
| 75 | require.ErrorIs(t, err, ErrClaimPinned) | ||
| 76 | |||
| 77 | // Delete cascades claim → volume as tombstones; the host still sees it. | ||
| 78 | require.NoError(t, st.TombstoneVolumeClaim(c.ID)) | ||
| 79 | vols, err := st.ListVolumesForHost(hostID) | ||
| 80 | require.NoError(t, err) | ||
| 81 | require.Len(t, vols, 1) | ||
| 82 | require.NotNil(t, vols[0].DeletedAt) | ||
| 83 | _, err = st.GetVolumeClaim(c.ID) | ||
| 84 | require.ErrorIs(t, err, sql.ErrNoRows, "a tombstoned claim reads as gone") | ||
| 85 | |||
| 86 | // Reap after the host reports it gone: both rows go. | ||
| 87 | volID := vols[0].ID | ||
| 88 | require.NoError(t, st.HardDeleteVolume(volID)) | ||
| 89 | vols, err = st.ListVolumesForHost(hostID) | ||
| 90 | require.NoError(t, err) | ||
| 91 | require.Empty(t, vols) | ||
| 92 | require.ErrorIs(t, st.HardDeleteVolume(volID), sql.ErrNoRows) | ||
| 93 | } | ||
| 94 | |||
| 95 | // The reap needs the tenant of a claim that is already tombstoned, which is | ||
| 96 | // exactly the claim GetVolumeClaim refuses to answer for. | ||
| 97 | func TestGetVolumeClaimAnyReadsATombstonedClaim(t *testing.T) { | ||
| 98 | st := newStore(t) | ||
| 99 | c, err := st.CreateVolumeClaim(testTenant, "data", 5) | ||
| 100 | require.NoError(t, err) | ||
| 101 | require.NoError(t, st.TombstoneVolumeClaim(c.ID)) | ||
| 102 | _, err = st.GetVolumeClaim(c.ID) | ||
| 103 | require.ErrorIs(t, err, sql.ErrNoRows) | ||
| 104 | got, err := st.GetVolumeClaimAny(c.ID) | ||
| 105 | require.NoError(t, err) | ||
| 106 | require.Equal(t, testTenant, got.Tenant) | ||
| 107 | require.NotNil(t, got.DeletedAt) | ||
| 108 | _, err = st.GetVolumeClaimAny("nope") | ||
| 109 | require.ErrorIs(t, err, sql.ErrNoRows) | ||
| 110 | } | ||
| 111 | |||
| 112 | func TestCreateVMRefusesForeignOrMissingClaim(t *testing.T) { | ||
| 113 | st := newStore(t) | ||
| 114 | hostID := enrollHost(t, st).ID | ||
| 115 | other, err := st.CreateTenantForIdentity("https://test-issuer", "other-subject", "other@test.local") | ||
| 116 | require.NoError(t, err) | ||
| 117 | c, err := st.CreateVolumeClaim(other.ID, "data", 5) | ||
| 118 | require.NoError(t, err) | ||
| 119 | require.ErrorIs(t, st.CreateVM(testVM("vm1", hostID, c.ID)), ErrClaimNotFound, | ||
| 120 | "another tenant's claim is indistinguishable from none") | ||
| 121 | require.ErrorIs(t, st.CreateVM(testVM("vm1", hostID, "nope")), ErrClaimNotFound) | ||
| 122 | } | ||
| 123 | |||
| 124 | func TestCommittedOnHostCountsBoundVolumes(t *testing.T) { | ||
| 125 | st := newStore(t) | ||
| 126 | hostID := enrollHost(t, st).ID | ||
| 127 | c, err := st.CreateVolumeClaim(testTenant, "data", 7) | ||
| 128 | require.NoError(t, err) | ||
| 129 | vm := testVM("vm1", hostID, c.ID) | ||
| 130 | vm.DiskGB = 3 | ||
| 131 | require.NoError(t, st.CreateVM(vm)) | ||
| 132 | held, err := st.CommittedOnHost(hostID) | ||
| 133 | require.NoError(t, err) | ||
| 134 | require.EqualValues(t, 10, held.Live.DiskGB, "a bound volume is disk the host has committed, VM or no VM") | ||
| 135 | require.NoError(t, st.TombstoneVM("vm1")) | ||
| 136 | require.NoError(t, st.HardDeleteVM("vm1", hostID)) | ||
| 137 | held, err = st.CommittedOnHost(hostID) | ||
| 138 | require.NoError(t, err) | ||
| 139 | require.EqualValues(t, 7, held.Live.DiskGB) | ||
| 140 | } | ||
| 141 | |||
| 142 | // Attachment order is the order the VM named its claims; the guest's device | ||
| 143 | // letters follow it. | ||
| 144 | func TestVMVolumeIDsKeepAttachmentOrder(t *testing.T) { | ||
| 145 | st := newStore(t) | ||
| 146 | hostID := enrollHost(t, st).ID | ||
| 147 | b, err := st.CreateVolumeClaim(testTenant, "b", 1) | ||
| 148 | require.NoError(t, err) | ||
| 149 | a, err := st.CreateVolumeClaim(testTenant, "a", 1) | ||
| 150 | require.NoError(t, err) | ||
| 151 | require.NoError(t, st.CreateVM(testVM("vm1", hostID, b.ID, a.ID))) | ||
| 152 | vm, err := st.GetVM("vm1") | ||
| 153 | require.NoError(t, err) | ||
| 154 | cb, err := st.GetVolumeClaim(b.ID) | ||
| 155 | require.NoError(t, err) | ||
| 156 | ca, err := st.GetVolumeClaim(a.ID) | ||
| 157 | require.NoError(t, err) | ||
| 158 | require.Equal(t, []string{cb.BoundVolumeID, ca.BoundVolumeID}, vm.VolumeIDs) | ||
| 159 | } | ||
| 160 | |||
| 161 | func TestListVolumeClaimsIsTenantScoped(t *testing.T) { | ||
| 162 | st := newStore(t) | ||
| 163 | hostID := enrollHost(t, st).ID | ||
| 164 | c, err := st.CreateVolumeClaim(testTenant, "data", 5) | ||
| 165 | require.NoError(t, err) | ||
| 166 | other, err := st.CreateTenantForIdentity("https://test-issuer", "other-subject", "other@test.local") | ||
| 167 | require.NoError(t, err) | ||
| 168 | _, err = st.CreateVolumeClaim(other.ID, "data", 5) | ||
| 169 | require.NoError(t, err, "the same name in another tenant is a different claim") | ||
| 170 | |||
| 171 | claims, err := st.ListVolumeClaims(testTenant) | ||
| 172 | require.NoError(t, err) | ||
| 173 | require.Len(t, claims, 1) | ||
| 174 | require.Equal(t, c.ID, claims[0].ID) | ||
| 175 | require.Equal(t, "", claims[0].HostID, "an unbound claim names no host") | ||
| 176 | |||
| 177 | require.NoError(t, st.CreateVM(testVM("vm1", hostID, c.ID))) | ||
| 178 | claims, err = st.ListVolumeClaims(testTenant) | ||
| 179 | require.NoError(t, err) | ||
| 180 | require.Len(t, claims, 1) | ||
| 181 | require.Equal(t, hostID, claims[0].HostID) | ||
| 182 | require.Equal(t, "vm1", claims[0].VMID) | ||
| 183 | |||
| 184 | // A tombstoned claim leaves the tenant's list. | ||
| 185 | require.NoError(t, st.TombstoneVM("vm1")) | ||
| 186 | require.NoError(t, st.HardDeleteVM("vm1", hostID)) | ||
| 187 | require.NoError(t, st.TombstoneVolumeClaim(c.ID)) | ||
| 188 | claims, err = st.ListVolumeClaims(testTenant) | ||
| 189 | require.NoError(t, err) | ||
| 190 | require.Empty(t, claims) | ||
| 191 | } | ||
| 192 | |||
| 193 | // A Pending claim has no bytes anywhere, so deleting it is bookkeeping only. | ||
| 194 | func TestTombstoneVolumeClaimUnbound(t *testing.T) { | ||
| 195 | st := newStore(t) | ||
| 196 | c, err := st.CreateVolumeClaim(testTenant, "data", 5) | ||
| 197 | require.NoError(t, err) | ||
| 198 | before, err := st.Epoch() | ||
| 199 | require.NoError(t, err) | ||
| 200 | require.NoError(t, st.TombstoneVolumeClaim(c.ID)) | ||
| 201 | after, err := st.Epoch() | ||
| 202 | require.NoError(t, err) | ||
| 203 | require.Equal(t, before, after, "an unbound claim is on no host, so no agent has to re-snapshot") | ||
| 204 | require.ErrorIs(t, st.TombstoneVolumeClaim(c.ID), sql.ErrNoRows) | ||
| 205 | } | ||
| 206 | |||
| 207 | // countRows is the only way a test can prove a row is DELETED rather than | ||
| 208 | // tombstoned: every read above deliberately hides tombstones. | ||
| 209 | func countRows(t *testing.T, s *Store, query string, args ...any) int { | ||
| 210 | t.Helper() | ||
| 211 | var n int | ||
| 212 | require.NoError(t, s.db.QueryRow(query, args...).Scan(&n)) | ||
| 213 | return n | ||
| 214 | } | ||
| 215 | |||
| 216 | // A host holding volumes is not a decommission that needs more time — nothing | ||
| 217 | // releases a volume but its claim's deletion, so the graceful path refuses and | ||
| 218 | // says which volumes are in the way. | ||
| 219 | func TestRemoveHostRefusesWhileVolumesRemain(t *testing.T) { | ||
| 220 | st := newStore(t) | ||
| 221 | hostID := enrollHost(t, st).ID | ||
| 222 | c, err := st.CreateVolumeClaim(testTenant, "data", 5) | ||
| 223 | require.NoError(t, err) | ||
| 224 | require.NoError(t, st.CreateVM(testVM("vm1", hostID, c.ID))) | ||
| 225 | require.NoError(t, st.TombstoneVM("vm1")) | ||
| 226 | require.NoError(t, st.HardDeleteVM("vm1", hostID)) | ||
| 227 | |||
| 228 | // Live volume, no VMs left: drained of guests, still holding bytes. | ||
| 229 | err = st.RemoveHost(hostID) | ||
| 230 | require.ErrorIs(t, err, ErrHostHoldsVolumes) | ||
| 231 | require.Contains(t, err.Error(), "1 volume(s)") | ||
| 232 | |||
| 233 | // Tombstoning the claim does not release the host either: the file is on | ||
| 234 | // that disk until the agent acks the reclaim. | ||
| 235 | require.NoError(t, st.TombstoneVolumeClaim(c.ID)) | ||
| 236 | require.ErrorIs(t, st.RemoveHost(hostID), ErrHostHoldsVolumes) | ||
| 237 | |||
| 238 | // Reaped: the host is finally removable. | ||
| 239 | vols, err := st.ListVolumesForHost(hostID) | ||
| 240 | require.NoError(t, err) | ||
| 241 | require.Len(t, vols, 1) | ||
| 242 | require.NoError(t, st.HardDeleteVolume(vols[0].ID)) | ||
| 243 | require.NoError(t, st.RemoveHost(hostID)) | ||
| 244 | } | ||
| 245 | |||
| 246 | // The ids this store mints are a WIRE CONTRACT, not a local detail: the agent's | ||
| 247 | // validVolumeID (internal/agent/reconcile/volumes.go) refuses any volume id that | ||
| 248 | // is not exactly 32 lowercase hex characters, because the id is joined into a | ||
| 249 | // path the agent hands to os.RemoveAll. A shorter id, an uppercase one, or a | ||
| 250 | // uuid with dashes would be refused on every host in the fleet and the volume | ||
| 251 | // would never materialize — so the shape is pinned on the minting side too. | ||
| 252 | func TestMintedIDsAreThirtyTwoLowercaseHex(t *testing.T) { | ||
| 253 | hex32 := regexp.MustCompile(`^[0-9a-f]{32}$`) | ||
| 254 | st := newStore(t) | ||
| 255 | hostID := enrollHost(t, st).ID | ||
| 256 | |||
| 257 | c, err := st.CreateVolumeClaim(testTenant, "data", 5) | ||
| 258 | require.NoError(t, err) | ||
| 259 | require.Regexp(t, hex32, c.ID, "claim id shape the agent's validVolumeID demands") | ||
| 260 | |||
| 261 | // bindClaims mints the volume id when the first VM places the claim. | ||
| 262 | require.NoError(t, st.CreateVM(testVM("vm1", hostID, c.ID))) | ||
| 263 | vols, err := st.ListVolumesForHost(hostID) | ||
| 264 | require.NoError(t, err) | ||
| 265 | require.Len(t, vols, 1) | ||
| 266 | require.Regexp(t, hex32, vols[0].ID, "volume id shape the agent's validVolumeID demands") | ||
| 267 | require.Equal(t, c.ID, vols[0].ClaimID) | ||
| 268 | } | ||
| 269 | |||
| 270 | // Force is for hardware that is gone, and it takes the disks with it. | ||
| 271 | func TestForceRemoveHostLosesItsVolumes(t *testing.T) { | ||
| 272 | st := newStore(t) | ||
| 273 | hostID := enrollHost(t, st).ID | ||
| 274 | live, err := st.CreateVolumeClaim(testTenant, "live", 5) | ||
| 275 | require.NoError(t, err) | ||
| 276 | doomed, err := st.CreateVolumeClaim(testTenant, "doomed", 5) | ||
| 277 | require.NoError(t, err) | ||
| 278 | require.NoError(t, st.CreateVM(testVM("vm1", hostID, live.ID, doomed.ID))) | ||
| 279 | // The doomed claim is already deleted and waiting on a reclaim ack that | ||
| 280 | // will never come. | ||
| 281 | require.NoError(t, st.TombstoneVM("vm1")) | ||
| 282 | require.NoError(t, st.HardDeleteVM("vm1", hostID)) | ||
| 283 | require.NoError(t, st.TombstoneVolumeClaim(doomed.ID)) | ||
| 284 | |||
| 285 | // Read the doomed volumes' ids while they still exist: after the force they | ||
| 286 | // are nowhere but the tally, which is the whole point of reporting them. | ||
| 287 | before, err := st.ListVolumesForHost(hostID) | ||
| 288 | require.NoError(t, err) | ||
| 289 | require.Len(t, before, 2) | ||
| 290 | wantIDs := []string{before[0].ID, before[1].ID} | ||
| 291 | sort.Strings(wantIDs) | ||
| 292 | |||
| 293 | gone, err := st.ForceRemoveHost(hostID) | ||
| 294 | require.NoError(t, err) | ||
| 295 | require.Equal(t, 0, gone.VMsPurged, "the VM was already reaped; the volumes were not") | ||
| 296 | |||
| 297 | // The tally is the receipt for the loss: both volumes, named, and only the | ||
| 298 | // LIVE claim counted as handed back — the tombstoned one was deleted, not | ||
| 299 | // returned to Pending. | ||
| 300 | require.Equal(t, 2, gone.VolumesDestroyed) | ||
| 301 | require.Equal(t, wantIDs, gone.VolumeIDs, "the destroyed volumes, in id order") | ||
| 302 | require.Equal(t, 1, gone.ClaimsUnbound, "the tombstoned claim went; only the live one is Pending again") | ||
| 303 | |||
| 304 | // The live claim survives as Pending: the request stands, the bytes do not. | ||
| 305 | got, err := st.GetVolumeClaim(live.ID) | ||
| 306 | require.NoError(t, err) | ||
| 307 | require.Equal(t, "", got.BoundVolumeID, "a claim whose host died is Pending again") | ||
| 308 | require.Equal(t, "", got.HostID) | ||
| 309 | // The tombstoned one is gone outright — no host is left to ack its reclaim. | ||
| 310 | require.Zero(t, countRows(t, st, `SELECT COUNT(*) FROM volume_claims WHERE id=?`, doomed.ID)) | ||
| 311 | require.Zero(t, countRows(t, st, `SELECT COUNT(*) FROM volumes WHERE host_id=?`, hostID)) | ||
| 312 | |||
| 313 | // And the freed claim can be placed again, elsewhere. | ||
| 314 | other := enrollSecondHost(t, st).ID | ||
| 315 | require.NoError(t, st.CreateVM(testVM("vm2", other, live.ID))) | ||
| 316 | got, err = st.GetVolumeClaim(live.ID) | ||
| 317 | require.NoError(t, err) | ||
| 318 | require.Equal(t, other, got.HostID) | ||
| 319 | } | ||
| 320 | |||
| 321 | // Force must also work while the VMs are still there — the usual dead-hardware | ||
| 322 | // case, where nothing drained at all. | ||
| 323 | func TestForceRemoveHostPurgesVMsHoldingVolumes(t *testing.T) { | ||
| 324 | st := newStore(t) | ||
| 325 | hostID := enrollHost(t, st).ID | ||
| 326 | c, err := st.CreateVolumeClaim(testTenant, "data", 5) | ||
| 327 | require.NoError(t, err) | ||
| 328 | require.NoError(t, st.CreateVM(testVM("vm1", hostID, c.ID))) | ||
| 329 | |||
| 330 | vols, err := st.ListVolumesForHost(hostID) | ||
| 331 | require.NoError(t, err) | ||
| 332 | require.Len(t, vols, 1) | ||
| 333 | |||
| 334 | gone, err := st.ForceRemoveHost(hostID) | ||
| 335 | require.NoError(t, err) | ||
| 336 | require.Equal(t, 1, gone.VMsPurged) | ||
| 337 | require.Equal(t, 1, gone.VolumesDestroyed) | ||
| 338 | require.Equal(t, 1, gone.ClaimsUnbound, "the claim is Pending again, on no host") | ||
| 339 | require.Equal(t, []string{vols[0].ID}, gone.VolumeIDs) | ||
| 340 | got, err := st.GetVolumeClaim(c.ID) | ||
| 341 | require.NoError(t, err) | ||
| 342 | require.Equal(t, "", got.BoundVolumeID) | ||
| 343 | require.Equal(t, "", got.VMID, "the attachment went with the VM") | ||
| 344 | require.Zero(t, countRows(t, st, `SELECT COUNT(*) FROM volumes WHERE host_id=?`, hostID)) | ||
| 345 | } | ||
| 346 | |||
| 347 | // A refusal has to name what is in the way — the API turns these into the 409 | ||
| 348 | // the caller reads. | ||
| 349 | func TestClaimErrorsNameWhatIsInTheWay(t *testing.T) { | ||
| 350 | require.Equal(t, "volume claim c1 is attached to vm vm1", | ||
| 351 | (&ClaimAttachedError{ClaimID: "c1", VMID: "vm1"}).Error()) | ||
| 352 | require.Equal(t, "volume claim c1 is bound to host h1", | ||
| 353 | (&ClaimPinnedError{ClaimID: "c1", HostID: "h1"}).Error()) | ||
| 354 | } | ||
| 355 | |||
| 356 | // One bad claim takes the whole create down, including the claims already | ||
| 357 | // bound earlier in the same list: a VM either gets all its volumes or none. | ||
| 358 | func TestCreateVMBindsAllClaimsOrNone(t *testing.T) { | ||
| 359 | st := newStore(t) | ||
| 360 | hostID := enrollHost(t, st).ID | ||
| 361 | good, err := st.CreateVolumeClaim(testTenant, "good", 5) | ||
| 362 | require.NoError(t, err) | ||
| 363 | require.ErrorIs(t, st.CreateVM(testVM("vm1", hostID, good.ID, "nope")), ErrClaimNotFound) | ||
| 364 | |||
| 365 | got, err := st.GetVolumeClaim(good.ID) | ||
| 366 | require.NoError(t, err) | ||
| 367 | require.Equal(t, "", got.BoundVolumeID, "the first claim's binding rolled back with the create") | ||
| 368 | vols, err := st.ListVolumesForHost(hostID) | ||
| 369 | require.NoError(t, err) | ||
| 370 | require.Empty(t, vols, "no volume was placed on the host") | ||
| 371 | } | ||
| 372 | |||
| 373 | func TestHardDeleteVolumeRefusesLiveVolume(t *testing.T) { | ||
| 374 | st := newStore(t) | ||
| 375 | hostID := enrollHost(t, st).ID | ||
| 376 | c, err := st.CreateVolumeClaim(testTenant, "data", 5) | ||
| 377 | require.NoError(t, err) | ||
| 378 | require.NoError(t, st.CreateVM(testVM("vm1", hostID, c.ID))) | ||
| 379 | got, err := st.GetVolumeClaim(c.ID) | ||
| 380 | require.NoError(t, err) | ||
| 381 | require.ErrorIs(t, st.HardDeleteVolume(got.BoundVolumeID), sql.ErrNoRows, | ||
| 382 | "a live volume is not reapable; the host still has the file") | ||
| 383 | } | ||
internal/server/syncsvc/syncsvc.go
| Old | New | ||
|---|---|---|---|
| @@ -15,6 +15,7 @@ import ( | |||
| 15 | "github.com/a73x/eitri/internal/server/hosttoken" | 15 | "github.com/a73x/eitri/internal/server/hosttoken" |
| 16 | "github.com/a73x/eitri/internal/server/hub" | 16 | "github.com/a73x/eitri/internal/server/hub" |
| 17 | "github.com/a73x/eitri/internal/server/registry" | 17 | "github.com/a73x/eitri/internal/server/registry" |
| 18 | "github.com/a73x/eitri/internal/server/release" | ||
| 18 | "github.com/a73x/eitri/internal/server/store" | 19 | "github.com/a73x/eitri/internal/server/store" |
| 19 | "github.com/a73x/eitri/internal/transport" | 20 | "github.com/a73x/eitri/internal/transport" |
| 20 | "github.com/quic-go/quic-go" | 21 | "github.com/quic-go/quic-go" |
| @@ -362,6 +363,11 @@ func (s *Service) buildSnapshot(hostID string) (*pb.Snapshot, error) { | |||
| 362 | // advertises the name, so the agent is only being told what it | 363 | // advertises the name, so the agent is only being told what it |
| 363 | // said it could serve. | 364 | // said it could serve. |
| 364 | Network: v.Network, | 365 | Network: v.Network, |
| 366 | // The volumes this guest attaches, in the order it named their | ||
| 367 | // claims. The agent materialises each file before boot; an agent | ||
| 368 | // too old to read this field is kept away from such a VM by the | ||
| 369 | // snapshot floor below. | ||
| 370 | VolumeIds: v.VolumeIDs, | ||
| 365 | // With a CA in hand the fleet issues host certificates, so a guest | 371 | // With a CA in hand the fleet issues host certificates, so a guest |
| 366 | // must present one. The host generates the key, reports the public | 372 | // must present one. The host generates the key, reports the public |
| 367 | // half, and holds the guest at the gate until the certificate for | 373 | // half, and holds the guest at the gate until the certificate for |
| @@ -383,6 +389,22 @@ func (s *Service) buildSnapshot(hostID string) (*pb.Snapshot, error) { | |||
| 383 | Protocol: e.Protocol, | 389 | Protocol: e.Protocol, |
| 384 | }) | 390 | }) |
| 385 | } | 391 | } |
| 392 | // Tombstoned rows ride along with the live ones: reclaiming a file is work | ||
| 393 | // the agent can only do while it is still told the file exists. | ||
| 394 | vols, err := s.st.ListVolumesForHost(hostID) | ||
| 395 | if err != nil { | ||
| 396 | return nil, fmt.Errorf("list host volumes: %w", err) | ||
| 397 | } | ||
| 398 | for _, v := range vols { | ||
| 399 | snap.Volumes = append(snap.Volumes, &pb.VolumeSpec{ | ||
| 400 | VolumeId: v.ID, SizeGb: v.SizeGB, Tombstoned: v.DeletedAt != nil, | ||
| 401 | }) | ||
| 402 | } | ||
| 403 | // The floor is raised only by a snapshot that uses the feature: a host | ||
| 404 | // with no volumes keeps serving a pre-volumes agent. | ||
| 405 | if len(snap.Volumes) > 0 { | ||
| 406 | snap.MinAgentVersion = release.Volumes.Since | ||
| 407 | } | ||
| 386 | return snap, nil | 408 | return snap, nil |
| 387 | } | 409 | } |
| 388 | 410 | ||
| @@ -473,6 +495,7 @@ func (s *Service) applyReport(hostID string, rep *pb.Report) { | |||
| 473 | r.VMs = toRegistryVMs(rep.GetVms()) | 495 | r.VMs = toRegistryVMs(rep.GetVms()) |
| 474 | r.Quarantined = toRegistryQuarantined(rep.GetQuarantined()) | 496 | r.Quarantined = toRegistryQuarantined(rep.GetQuarantined()) |
| 475 | r.Exposures = toRegistryExposures(rep.GetExposures()) | 497 | r.Exposures = toRegistryExposures(rep.GetExposures()) |
| 498 | r.Volumes = toRegistryVolumes(rep.GetVolumes()) | ||
| 476 | r.Capacity = toRegistryCapacity(rep.GetCapacity()) | 499 | r.Capacity = toRegistryCapacity(rep.GetCapacity()) |
| 477 | r.Metrics = toRegistryMetrics(rep.GetMetrics()) | 500 | r.Metrics = toRegistryMetrics(rep.GetMetrics()) |
| 478 | 501 | ||
| @@ -563,6 +586,79 @@ func (s *Service) applyReport(hostID string, rep *pb.Report) { | |||
| 563 | if anyDeleted { | 586 | if anyDeleted { |
| 564 | s.hub.Poke(hostID) | 587 | s.hub.Poke(hostID) |
| 565 | } | 588 | } |
| 589 | |||
| 590 | // A fenced report is the agent saying it refused this snapshot, so nothing | ||
| 591 | // in it is an answer to what the snapshot asked for. It must not drive a | ||
| 592 | // delete: the reap reads an omitted volume as gone, and a report from a | ||
| 593 | // host that acted on nothing would reap every tombstoned row on it. | ||
| 594 | if !rep.GetFenceViolation() { | ||
| 595 | s.reapVolumes(hostID, rep.GetVolumes()) | ||
| 596 | } | ||
| 597 | } | ||
| 598 | |||
| 599 | // reapVolumes hard-deletes a tombstoned volume once its host reports the file | ||
| 600 | // gone: present=false, or absent from a report by an agent that reports | ||
| 601 | // volumes at all. A live volume is never touched here whatever the report | ||
| 602 | // says — the row is the truth the agent converges toward, and a file it has | ||
| 603 | // not made yet is a converge still owed, not a row to delete — and a | ||
| 604 | // pre-volumes agent's silence proves nothing, so it reaps nothing. A fenced | ||
| 605 | // report reaches none of this: see the caller. | ||
| 606 | func (s *Service) reapVolumes(hostID string, reported []*pb.VolumeStatus) { | ||
| 607 | hs, ok := s.reg.Get(hostID) | ||
| 608 | if !ok || !release.Volumes.SupportedBy(hs.AgentVersion) { | ||
| 609 | return | ||
| 610 | } | ||
| 611 | vols, err := s.st.ListVolumesForHost(hostID) | ||
| 612 | if err != nil { | ||
| 613 | slog.Warn("list host volumes", "host", hostID, "err", err) | ||
| 614 | return | ||
| 615 | } | ||
| 616 | present := map[string]bool{} | ||
| 617 | for _, v := range reported { | ||
| 618 | present[v.GetVolumeId()] = v.GetPresent() | ||
| 619 | } | ||
| 620 | reaped := false | ||
| 621 | for _, v := range vols { | ||
| 622 | if v.DeletedAt == nil || present[v.ID] { | ||
| 623 | continue | ||
| 624 | } | ||
| 625 | // The claim is tombstoned by now, so read it whatever its state: the | ||
| 626 | // terminal audit row belongs in the tenant's timeline, and the system | ||
| 627 | // scope is only for the race where the row has already gone. | ||
| 628 | tenant := store.SystemTenant | ||
| 629 | if c, err := s.st.GetVolumeClaimAny(v.ClaimID); err == nil { | ||
| 630 | tenant = c.Tenant | ||
| 631 | } | ||
| 632 | // Refused unless the claim is tombstoned too — the store's guard, not | ||
| 633 | // this loop's assumption. See HardDeleteVolume. | ||
| 634 | if err := s.st.HardDeleteVolume(v.ID); err != nil { | ||
| 635 | slog.Warn("reap volume", "volume", v.ID, "host", hostID, "err", err) | ||
| 636 | continue | ||
| 637 | } | ||
| 638 | reaped = true | ||
| 639 | detail, _ := json.Marshal(map[string]string{"volume_id": v.ID, "host_id": hostID, "claim_id": v.ClaimID}) | ||
| 640 | if err := s.st.AppendAudit(tenant, "volume.reap", string(detail)); err != nil { | ||
| 641 | slog.Warn("audit volume.reap failed", "volume", v.ID, "host", hostID, "err", err) | ||
| 642 | } | ||
| 643 | } | ||
| 644 | // The agent still holds a snapshot listing the tombstone and would re-report | ||
| 645 | // it every tick; poke it once so the next snapshot is free of it. | ||
| 646 | if reaped { | ||
| 647 | s.hub.Poke(hostID) | ||
| 648 | } | ||
| 649 | } | ||
| 650 | |||
| 651 | // toRegistryVolumes maps reported volume state to registry rows. Returns nil | ||
| 652 | // (not an empty slice) for empty input, matching append-into-nil behavior. | ||
| 653 | func toRegistryVolumes(in []*pb.VolumeStatus) []registry.VolumeStatus { | ||
| 654 | if len(in) == 0 { | ||
| 655 | return nil | ||
| 656 | } | ||
| 657 | out := make([]registry.VolumeStatus, 0, len(in)) | ||
| 658 | for _, v := range in { | ||
| 659 | out = append(out, registry.VolumeStatus{VolumeID: v.GetVolumeId(), Present: v.GetPresent(), SizeGB: v.GetSizeGb()}) | ||
| 660 | } | ||
| 661 | return out | ||
| 566 | } | 662 | } |
| 567 | 663 | ||
| 568 | // toRegistryVMs maps reported VMStatus rows to registry rows. Returns nil (not | 664 | // toRegistryVMs maps reported VMStatus rows to registry rows. Returns nil (not |
internal/server/syncsvc/volumes_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,164 @@ | |||
| 1 | package syncsvc | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "testing" | ||
| 5 | |||
| 6 | "github.com/a73x/eitri/internal/pb" | ||
| 7 | "github.com/a73x/eitri/internal/server/registry" | ||
| 8 | "github.com/a73x/eitri/internal/server/release" | ||
| 9 | "github.com/a73x/eitri/internal/server/store" | ||
| 10 | "github.com/stretchr/testify/require" | ||
| 11 | ) | ||
| 12 | |||
| 13 | // claimedVM gives the host one VM holding one 4GiB claim, and returns the | ||
| 14 | // claim as the store now reads it — bound to a volume, held by the VM. | ||
| 15 | func claimedVM(t *testing.T, st *store.Store, hostID, vmID string) store.VolumeClaim { | ||
| 16 | t.Helper() | ||
| 17 | c, err := st.CreateVolumeClaim(testTenant, vmID+"-data", 4) | ||
| 18 | require.NoError(t, err) | ||
| 19 | require.NoError(t, st.CreateVM(store.VM{ID: vmID, HostID: hostID, Name: vmID, ImageURL: "u", ImageSHA256: "s", | ||
| 20 | VCPUs: 1, MemMB: 1, DiskGB: 1, PowerState: "running", VolumeClaimIDs: []string{c.ID}})) | ||
| 21 | c, err = st.GetVolumeClaim(c.ID) | ||
| 22 | require.NoError(t, err) | ||
| 23 | return c | ||
| 24 | } | ||
| 25 | |||
| 26 | func TestSnapshotCarriesVolumesAndFloor(t *testing.T) { | ||
| 27 | f := setup(t) | ||
| 28 | c := claimedVM(t, f.st, f.host.ID, "vm1") | ||
| 29 | snap, err := f.svc.buildSnapshot(f.host.ID) | ||
| 30 | require.NoError(t, err) | ||
| 31 | require.Len(t, snap.Volumes, 1) | ||
| 32 | require.Equal(t, c.BoundVolumeID, snap.Volumes[0].VolumeId) | ||
| 33 | require.EqualValues(t, 4, snap.Volumes[0].SizeGb) | ||
| 34 | require.False(t, snap.Volumes[0].Tombstoned) | ||
| 35 | require.Equal(t, []string{c.BoundVolumeID}, snap.Vms[0].VolumeIds) | ||
| 36 | require.Equal(t, release.Volumes.Since, snap.MinAgentVersion) | ||
| 37 | } | ||
| 38 | |||
| 39 | func TestSnapshotWithoutVolumesHasNoFloor(t *testing.T) { | ||
| 40 | f := setup(t) | ||
| 41 | snap, err := f.svc.buildSnapshot(f.host.ID) | ||
| 42 | require.NoError(t, err) | ||
| 43 | require.Equal(t, "", snap.MinAgentVersion, "a pre-volumes agent must keep taking ordinary snapshots") | ||
| 44 | } | ||
| 45 | |||
| 46 | // tombstoneFreeClaim gets the claim to the only state a reap may act on: the | ||
| 47 | // VM that held it destroyed, the claim itself tombstoned. | ||
| 48 | func tombstoneFreeClaim(t *testing.T, st *store.Store, c store.VolumeClaim) { | ||
| 49 | t.Helper() | ||
| 50 | require.NoError(t, st.TombstoneVM(c.VMID)) | ||
| 51 | require.NoError(t, st.HardDeleteVM(c.VMID, c.HostID)) | ||
| 52 | require.NoError(t, st.TombstoneVolumeClaim(c.ID)) | ||
| 53 | } | ||
| 54 | |||
| 55 | func TestTombstonedVolumeIsReapedWhenHostReportsItGone(t *testing.T) { | ||
| 56 | f := setup(t) | ||
| 57 | f.reg.SetAgentVersion(f.host.ID, release.Volumes.Since) | ||
| 58 | c := claimedVM(t, f.st, f.host.ID, "vm1") | ||
| 59 | tombstoneFreeClaim(t, f.st, c) | ||
| 60 | |||
| 61 | f.svc.applyReport(f.host.ID, &pb.Report{Volumes: []*pb.VolumeStatus{{VolumeId: c.BoundVolumeID, Present: true, SizeGb: 4}}}) | ||
| 62 | vols, err := f.st.ListVolumesForHost(f.host.ID) | ||
| 63 | require.NoError(t, err) | ||
| 64 | require.Len(t, vols, 1, "the host still has the file") | ||
| 65 | |||
| 66 | f.svc.applyReport(f.host.ID, &pb.Report{Volumes: []*pb.VolumeStatus{{VolumeId: c.BoundVolumeID, Present: false}}}) | ||
| 67 | vols, err = f.st.ListVolumesForHost(f.host.ID) | ||
| 68 | require.NoError(t, err) | ||
| 69 | require.Empty(t, vols, "reported gone: row reaped") | ||
| 70 | |||
| 71 | // The claim goes with it, so its name is free again. | ||
| 72 | _, err = f.st.GetVolumeClaim(c.ID) | ||
| 73 | require.Error(t, err) | ||
| 74 | } | ||
| 75 | |||
| 76 | // A live volume the host does not list is NOT reaped: the row is the truth | ||
| 77 | // the agent converges toward, and absence on one tick is a file not yet made. | ||
| 78 | func TestLiveVolumeSurvivesAnEmptyReport(t *testing.T) { | ||
| 79 | f := setup(t) | ||
| 80 | f.reg.SetAgentVersion(f.host.ID, release.Volumes.Since) | ||
| 81 | claimedVM(t, f.st, f.host.ID, "vm1") | ||
| 82 | f.svc.applyReport(f.host.ID, &pb.Report{}) | ||
| 83 | vols, err := f.st.ListVolumesForHost(f.host.ID) | ||
| 84 | require.NoError(t, err) | ||
| 85 | require.Len(t, vols, 1) | ||
| 86 | } | ||
| 87 | |||
| 88 | // Even told outright the file is gone, a live volume stays: the tenant still | ||
| 89 | // holds the claim, and the agent's next converge is what makes the file again. | ||
| 90 | func TestLiveVolumeSurvivesAPresentFalseReport(t *testing.T) { | ||
| 91 | f := setup(t) | ||
| 92 | f.reg.SetAgentVersion(f.host.ID, release.Volumes.Since) | ||
| 93 | c := claimedVM(t, f.st, f.host.ID, "vm1") | ||
| 94 | f.svc.applyReport(f.host.ID, &pb.Report{Volumes: []*pb.VolumeStatus{{VolumeId: c.BoundVolumeID, Present: false}}}) | ||
| 95 | vols, err := f.st.ListVolumesForHost(f.host.ID) | ||
| 96 | require.NoError(t, err) | ||
| 97 | require.Len(t, vols, 1) | ||
| 98 | } | ||
| 99 | |||
| 100 | // A pre-volumes agent never reports volumes, so its silence proves nothing | ||
| 101 | // about the file; the tombstoned row waits for an agent that can answer. | ||
| 102 | func TestTombstonedVolumeIsNotReapedOnAPreVolumesAgentReport(t *testing.T) { | ||
| 103 | f := setup(t) | ||
| 104 | f.reg.SetAgentVersion(f.host.ID, "v0.0.6") | ||
| 105 | c := claimedVM(t, f.st, f.host.ID, "vm1") | ||
| 106 | tombstoneFreeClaim(t, f.st, c) | ||
| 107 | f.svc.applyReport(f.host.ID, &pb.Report{}) | ||
| 108 | vols, err := f.st.ListVolumesForHost(f.host.ID) | ||
| 109 | require.NoError(t, err) | ||
| 110 | require.Len(t, vols, 1) | ||
| 111 | } | ||
| 112 | |||
| 113 | // A fenced report is the agent refusing a stale snapshot: it acted on nothing, | ||
| 114 | // so nothing in it answers what the snapshot asked for. Reaping off it would | ||
| 115 | // delete every tombstoned row on the host for a report that never looked. | ||
| 116 | func TestTombstonedVolumeIsNotReapedOnAFencedReport(t *testing.T) { | ||
| 117 | f := setup(t) | ||
| 118 | f.reg.SetAgentVersion(f.host.ID, release.Volumes.Since) | ||
| 119 | c := claimedVM(t, f.st, f.host.ID, "vm1") | ||
| 120 | tombstoneFreeClaim(t, f.st, c) | ||
| 121 | |||
| 122 | f.svc.applyReport(f.host.ID, &pb.Report{FenceViolation: true}) | ||
| 123 | vols, err := f.st.ListVolumesForHost(f.host.ID) | ||
| 124 | require.NoError(t, err) | ||
| 125 | require.Len(t, vols, 1, "an omission in a fenced report is not a report that the file is gone") | ||
| 126 | |||
| 127 | // The same omission from an accepted report still reaps: the guard is the | ||
| 128 | // fence, not a new reluctance. | ||
| 129 | f.svc.applyReport(f.host.ID, &pb.Report{}) | ||
| 130 | vols, err = f.st.ListVolumesForHost(f.host.ID) | ||
| 131 | require.NoError(t, err) | ||
| 132 | require.Empty(t, vols) | ||
| 133 | } | ||
| 134 | |||
| 135 | // The reap is a tenant event, not a system one: the claim is gone by the time | ||
| 136 | // it lands, so the tenant is read from the tombstoned row. | ||
| 137 | func TestReapIsAuditedUnderTheClaimsTenant(t *testing.T) { | ||
| 138 | f := setup(t) | ||
| 139 | f.reg.SetAgentVersion(f.host.ID, release.Volumes.Since) | ||
| 140 | c := claimedVM(t, f.st, f.host.ID, "vm1") | ||
| 141 | tombstoneFreeClaim(t, f.st, c) | ||
| 142 | f.svc.applyReport(f.host.ID, &pb.Report{Volumes: []*pb.VolumeStatus{{VolumeId: c.BoundVolumeID, Present: false}}}) | ||
| 143 | |||
| 144 | events, err := f.st.ListAudit(testTenant, 50) | ||
| 145 | require.NoError(t, err) | ||
| 146 | var found bool | ||
| 147 | for _, e := range events { | ||
| 148 | if e.Action == "volume.reap" { | ||
| 149 | found = true | ||
| 150 | require.Contains(t, e.Detail, c.BoundVolumeID) | ||
| 151 | } | ||
| 152 | } | ||
| 153 | require.True(t, found, "volume.reap audited under %s", testTenant) | ||
| 154 | } | ||
| 155 | |||
| 156 | // The report reaches the registry, so the console can say what the host found. | ||
| 157 | func TestReportedVolumesReachTheRegistry(t *testing.T) { | ||
| 158 | f := setup(t) | ||
| 159 | c := claimedVM(t, f.st, f.host.ID, "vm1") | ||
| 160 | f.svc.applyReport(f.host.ID, &pb.Report{Volumes: []*pb.VolumeStatus{{VolumeId: c.BoundVolumeID, Present: true, SizeGb: 4}}}) | ||
| 161 | hs, ok := f.reg.Get(f.host.ID) | ||
| 162 | require.True(t, ok) | ||
| 163 | require.Equal(t, []registry.VolumeStatus{{VolumeID: c.BoundVolumeID, Present: true, SizeGB: 4}}, hs.Volumes) | ||
| 164 | } | ||
internal/site/site.go
| Old | New | ||
|---|---|---|---|
| @@ -17,6 +17,7 @@ var pages = []string{ | |||
| 17 | "joining", | 17 | "joining", |
| 18 | "connecting", | 18 | "connecting", |
| 19 | "networking", | 19 | "networking", |
| 20 | "volumes", | ||
| 20 | "self-hosting", | 21 | "self-hosting", |
| 21 | "byo-idp", | 22 | "byo-idp", |
| 22 | "mcp", | 23 | "mcp", |
internal/smoke/gatecheck.go
| Old | New | ||
|---|---|---|---|
| @@ -20,6 +20,9 @@ func realGateHooks(cfg Config, tenant string, ca gateclient.CertAuthority, userC | |||
| 20 | return &gateHooks{ | 20 | return &gateHooks{ |
| 21 | register: func(ctx context.Context) error { return auth.Register(ctx) }, | 21 | register: func(ctx context.Context) error { return auth.Register(ctx) }, |
| 22 | exec: func(ctx context.Context, vmName string) error { return gateExec(ctx, cfg, auth, vmName, now, sleep) }, | 22 | exec: func(ctx context.Context, vmName string) error { return gateExec(ctx, cfg, auth, vmName, now, sleep) }, |
| 23 | run: func(ctx context.Context, vmName, cmd string) (string, error) { | ||
| 24 | return gateRun(ctx, cfg, auth, vmName, cmd, now, sleep) | ||
| 25 | }, | ||
| 23 | } | 26 | } |
| 24 | } | 27 | } |
| 25 | 28 | ||
| @@ -83,6 +86,39 @@ func gateExec(ctx context.Context, cfg Config, auth gateclient.Credentials, vmNa | |||
| 83 | return nil | 86 | return nil |
| 84 | } | 87 | } |
| 85 | 88 | ||
| 89 | // gateRun runs one command inside a guest through the SSH-CA gate and returns | ||
| 90 | // what it printed. Like gateExec it retries the DIAL over the guest's pre-sshd | ||
| 91 | // boot window, since a leg that reaches a fresh guest is racing sshd's start. | ||
| 92 | // The command itself is not retried: what the callers run is not idempotent — | ||
| 93 | // a second mount of an already-mounted volume fails on its own — so a command | ||
| 94 | // that ran and failed is the answer, not a reason to try again. | ||
| 95 | func gateRun(ctx context.Context, cfg Config, auth gateclient.Credentials, vmName, cmd string, now func() time.Time, sleep func(time.Duration)) (string, error) { | ||
| 96 | var out string | ||
| 97 | var lastErr error | ||
| 98 | err := pollLoop(ctx, now, sleep, 120*time.Second, 5*time.Second, func() (bool, error) { | ||
| 99 | client, dialErr := gateclient.Dial(ctx, gateclient.DialConfig{ | ||
| 100 | Gate: cfg.SmokeGate, | ||
| 101 | Auth: auth, | ||
| 102 | }, vmName) | ||
| 103 | if dialErr != nil { | ||
| 104 | lastErr = dialErr | ||
| 105 | return false, nil | ||
| 106 | } | ||
| 107 | defer client.Close() | ||
| 108 | |||
| 109 | got, runErr := runGuestCommand(client, cmd) | ||
| 110 | if runErr != nil { | ||
| 111 | return false, runErr | ||
| 112 | } | ||
| 113 | out = got | ||
| 114 | return true, nil | ||
| 115 | }) | ||
| 116 | if errors.Is(err, errPollTimeout) { | ||
| 117 | return "", fmt.Errorf("could not reach guest %q through the gate within 120s: %w", vmName, lastErr) | ||
| 118 | } | ||
| 119 | return out, err | ||
| 120 | } | ||
| 121 | |||
| 86 | // runGuestCommand runs cmd in a new session on client and returns its stdout. | 122 | // runGuestCommand runs cmd in a new session on client and returns its stdout. |
| 87 | func runGuestCommand(client *ssh.Client, cmd string) (string, error) { | 123 | func runGuestCommand(client *ssh.Client, cmd string) (string, error) { |
| 88 | session, err := client.NewSession() | 124 | session, err := client.NewSession() |
internal/smoke/scenario.go
| Old | New | ||
|---|---|---|---|
| @@ -55,6 +55,8 @@ type vmAPI interface { | |||
| 55 | PatchVM(ctx context.Context, id, powerState string) error | 55 | PatchVM(ctx context.Context, id, powerState string) error |
| 56 | CreateExposure(ctx context.Context, vmID string, guestPort, hostPort int64, protocol string) (client.Exposure, error) | 56 | CreateExposure(ctx context.Context, vmID string, guestPort, hostPort int64, protocol string) (client.Exposure, error) |
| 57 | DeleteExposure(ctx context.Context, id string) error | 57 | DeleteExposure(ctx context.Context, id string) error |
| 58 | CreateVolumeClaim(ctx context.Context, name string, sizeGB int64) (client.VolumeClaim, error) | ||
| 59 | DeleteVolumeClaim(ctx context.Context, id string) error | ||
| 58 | } | 60 | } |
| 59 | 61 | ||
| 60 | // getVM finds the VM with the given id in the current listing. The bool | 62 | // getVM finds the VM with the given id in the current listing. The bool |
| @@ -108,6 +110,10 @@ func proveHostRelease(h client.Host, want string) error { | |||
| 108 | type gateHooks struct { | 110 | type gateHooks struct { |
| 109 | register func(ctx context.Context) error // upload the smoke user CA to the tenant (before create) | 111 | register func(ctx context.Context) error // upload the smoke user CA to the tenant (before create) |
| 110 | exec func(ctx context.Context, vmName string) error // reach the guest through the gate (after boot) | 112 | exec func(ctx context.Context, vmName string) error // reach the guest through the gate (after boot) |
| 113 | // run reaches a guest the same way exec does and hands back what the | ||
| 114 | // command printed. It is how a leg proves something INSIDE a guest — the | ||
| 115 | // volume leg's marker — rather than proving the gate itself. | ||
| 116 | run func(ctx context.Context, vmName, cmd string) (string, error) | ||
| 111 | } | 117 | } |
| 112 | 118 | ||
| 113 | // mcpLeg is the remote-MCP proof: one full cycle driven entirely through the | 119 | // mcpLeg is the remote-MCP proof: one full cycle driven entirely through the |
| @@ -299,8 +305,15 @@ func runScenario(ctx context.Context, vmName string, c vmAPI, dialConsole consol | |||
| 299 | } | 305 | } |
| 300 | return "", err | 306 | return "", err |
| 301 | } | 307 | } |
| 302 | if err := proveBoot(ctx, reboot, now, sleep, "after power cycle"); err != nil { | 308 | rebootErr := proveBoot(ctx, reboot, now, sleep, "after power cycle") |
| 303 | return "", err | 309 | // The tail's last use, so it is detached HERE rather than left to the |
| 310 | // deferred close: the volume leg below runs for minutes after this VM is | ||
| 311 | // reaped, and a tail still open would spend them re-dialing a console that | ||
| 312 | // no longer has a VM behind it. close is idempotent, so the defer above | ||
| 313 | // stays as the backstop for the early returns between attach and here. | ||
| 314 | reboot.close() | ||
| 315 | if rebootErr != nil { | ||
| 316 | return "", rebootErr | ||
| 304 | } | 317 | } |
| 305 | if gate != nil { | 318 | if gate != nil { |
| 306 | if err := gate.exec(ctx, vmName); err != nil { | 319 | if err := gate.exec(ctx, vmName); err != nil { |
| @@ -330,6 +343,23 @@ func runScenario(ctx context.Context, vmName string, c vmAPI, dialConsole consol | |||
| 330 | return "", err | 343 | return "", err |
| 331 | } | 344 | } |
| 332 | 345 | ||
| 346 | // Durable storage: a claim that outlives the VM it was attached to. It runs | ||
| 347 | // on its own pair of VMs — the proof needs one guest to die while another | ||
| 348 | // picks the volume up — and only where the gate is configured, because the | ||
| 349 | // marker is written and read INSIDE the guests. | ||
| 350 | // | ||
| 351 | // It runs here, after this scenario's own VM is reaped, so the smoke never | ||
| 352 | // asks the host for more than two guests at once. Its VMs are serial by | ||
| 353 | // nature (the second cannot exist until the first is destroyed), so the | ||
| 354 | // host sees one smoke VM at a time from this point on. | ||
| 355 | volumeOK := false | ||
| 356 | if gate != nil { | ||
| 357 | if err := proveVolume(ctx, c, hostID, vmName, gate, readPubKey, now, sleep); err != nil { | ||
| 358 | return "", err | ||
| 359 | } | ||
| 360 | volumeOK = true | ||
| 361 | } | ||
| 362 | |||
| 333 | msg := fmt.Sprintf("SMOKE COMPLETE — booted under UEFI, cold_start=%ds, reboot: ok, reaped OK", int64(coldStart.Seconds())) | 363 | msg := fmt.Sprintf("SMOKE COMPLETE — booted under UEFI, cold_start=%ds, reboot: ok, reaped OK", int64(coldStart.Seconds())) |
| 334 | if gateOK { | 364 | if gateOK { |
| 335 | // One clause per thing the gate leg proved: reaching the guest with a | 365 | // One clause per thing the gate leg proved: reaching the guest with a |
| @@ -340,6 +370,9 @@ func runScenario(ctx context.Context, vmName string, c vmAPI, dialConsole consol | |||
| 340 | if exposureOK { | 370 | if exposureOK { |
| 341 | msg += ", exposed port: ok" | 371 | msg += ", exposed port: ok" |
| 342 | } | 372 | } |
| 373 | if volumeOK { | ||
| 374 | msg += ", volume outlived its VM: ok" | ||
| 375 | } | ||
| 343 | if mcpOK { | 376 | if mcpOK { |
| 344 | msg += ", remote MCP: ok, published UDP port: ok" | 377 | msg += ", remote MCP: ok, published UDP port: ok" |
| 345 | } | 378 | } |
internal/smoke/scenario_test.go
| Old | New | ||
|---|---|---|---|
| @@ -3,6 +3,7 @@ package smoke | |||
| 3 | import ( | 3 | import ( |
| 4 | "context" | 4 | "context" |
| 5 | "errors" | 5 | "errors" |
| 6 | "fmt" | ||
| 6 | "io" | 7 | "io" |
| 7 | "strings" | 8 | "strings" |
| 8 | "testing" | 9 | "testing" |
| @@ -62,16 +63,19 @@ func (c *fakeClock) sleep(d time.Duration) { | |||
| 62 | } | 63 | } |
| 63 | 64 | ||
| 64 | // testAPI implements vmAPI by delegating to per-test closures. patchVMFunc, | 65 | // testAPI implements vmAPI by delegating to per-test closures. patchVMFunc, |
| 65 | // createExposureFunc, and deleteExposureFunc default to a benign answer when | 66 | // createExposureFunc, deleteExposureFunc, createVolumeClaimFunc, and |
| 66 | // nil, so a test whose subject is elsewhere need not supply them. | 67 | // deleteVolumeClaimFunc default to a benign answer when nil, so a test whose |
| 68 | // subject is elsewhere need not supply them. | ||
| 67 | type testAPI struct { | 69 | type testAPI struct { |
| 68 | listHostsFunc func(ctx context.Context) ([]client.Host, error) | 70 | listHostsFunc func(ctx context.Context) ([]client.Host, error) |
| 69 | createVMFunc func(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) | 71 | createVMFunc func(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) |
| 70 | listVMsFunc func(ctx context.Context) ([]client.VM, error) | 72 | listVMsFunc func(ctx context.Context) ([]client.VM, error) |
| 71 | deleteVMFunc func(ctx context.Context, id string) error | 73 | deleteVMFunc func(ctx context.Context, id string) error |
| 72 | patchVMFunc func(ctx context.Context, id, powerState string) error | 74 | patchVMFunc func(ctx context.Context, id, powerState string) error |
| 73 | createExposureFunc func(ctx context.Context, vmID string, guestPort, hostPort int64, protocol string) (client.Exposure, error) | 75 | createExposureFunc func(ctx context.Context, vmID string, guestPort, hostPort int64, protocol string) (client.Exposure, error) |
| 74 | deleteExposureFunc func(ctx context.Context, id string) error | 76 | deleteExposureFunc func(ctx context.Context, id string) error |
| 77 | createVolumeClaimFunc func(ctx context.Context, name string, sizeGB int64) (client.VolumeClaim, error) | ||
| 78 | deleteVolumeClaimFunc func(ctx context.Context, id string) error | ||
| 75 | } | 79 | } |
| 76 | 80 | ||
| 77 | func (a *testAPI) ListHosts(ctx context.Context) ([]client.Host, error) { | 81 | func (a *testAPI) ListHosts(ctx context.Context) ([]client.Host, error) { |
| @@ -104,6 +108,20 @@ func (a *testAPI) DeleteExposure(ctx context.Context, id string) error { | |||
| 104 | return a.deleteExposureFunc(ctx, id) | 108 | return a.deleteExposureFunc(ctx, id) |
| 105 | } | 109 | } |
| 106 | 110 | ||
| 111 | func (a *testAPI) CreateVolumeClaim(ctx context.Context, name string, sizeGB int64) (client.VolumeClaim, error) { | ||
| 112 | if a.createVolumeClaimFunc == nil { | ||
| 113 | return client.VolumeClaim{ID: "claim-fake", Name: name, SizeGB: sizeGB, Status: "pending"}, nil | ||
| 114 | } | ||
| 115 | return a.createVolumeClaimFunc(ctx, name, sizeGB) | ||
| 116 | } | ||
| 117 | |||
| 118 | func (a *testAPI) DeleteVolumeClaim(ctx context.Context, id string) error { | ||
| 119 | if a.deleteVolumeClaimFunc == nil { | ||
| 120 | return nil | ||
| 121 | } | ||
| 122 | return a.deleteVolumeClaimFunc(ctx, id) | ||
| 123 | } | ||
| 124 | |||
| 107 | func noopReadPubKey() string { return "ssh-ed25519 AAAAfake test@smoke" } | 125 | func noopReadPubKey() string { return "ssh-ed25519 AAAAfake test@smoke" } |
| 108 | 126 | ||
| 109 | // bootedGuest is what a guest that reached userspace has on its console. | 127 | // bootedGuest is what a guest that reached userspace has on its console. |
| @@ -413,42 +431,63 @@ func TestRunScenarioNoHosts(t *testing.T) { | |||
| 413 | // --- runScenario: gate hooks ----------------------------------------------- | 431 | // --- runScenario: gate hooks ----------------------------------------------- |
| 414 | 432 | ||
| 415 | // happyPathAPI returns a testAPI that succeeds all the way through reap, | 433 | // happyPathAPI returns a testAPI that succeeds all the way through reap, |
| 416 | // tracking call order in calls (a shared slice each hook also appends to). | 434 | // tracking call order in calls (a shared slice each hook also appends to). It |
| 435 | // keeps a registry rather than one hard-coded VM, because the legs that run | ||
| 436 | // under a gate (the volume leg) create VMs of their own alongside the | ||
| 437 | // scenario's: each one boots after a couple of polls and vanishes when deleted. | ||
| 417 | func happyPathAPI(t *testing.T, calls *[]string) *testAPI { | 438 | func happyPathAPI(t *testing.T, calls *[]string) *testAPI { |
| 418 | t.Helper() | 439 | t.Helper() |
| 419 | listVMsCalls := 0 | 440 | type fakeVM struct { |
| 420 | power := "running" | 441 | polls int |
| 421 | deleted := false | 442 | power string |
| 443 | } | ||
| 444 | vms := map[string]*fakeVM{} | ||
| 445 | created := 0 | ||
| 422 | return &testAPI{ | 446 | return &testAPI{ |
| 423 | listHostsFunc: func(ctx context.Context) ([]client.Host, error) { | 447 | listHostsFunc: func(ctx context.Context) ([]client.Host, error) { |
| 424 | return []client.Host{{ID: "host-1", UplinkAddr: "192.168.0.190"}}, nil | 448 | return []client.Host{{ID: "host-1", UplinkAddr: "192.168.0.190"}}, nil |
| 425 | }, | 449 | }, |
| 426 | createVMFunc: func(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) { | 450 | createVMFunc: func(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) { |
| 427 | *calls = append(*calls, "createVM") | 451 | *calls = append(*calls, "createVM") |
| 428 | return client.CreateVMResponse{ID: "vm-1"}, nil | 452 | created++ |
| 453 | id := fmt.Sprintf("vm-%d", created) | ||
| 454 | vms[id] = &fakeVM{power: "running"} | ||
| 455 | return client.CreateVMResponse{ID: id}, nil | ||
| 429 | }, | 456 | }, |
| 430 | listVMsFunc: func(ctx context.Context) ([]client.VM, error) { | 457 | listVMsFunc: func(ctx context.Context) ([]client.VM, error) { |
| 431 | listVMsCalls++ | 458 | var out []client.VM |
| 432 | switch { | 459 | for id, vm := range vms { |
| 433 | case deleted: | 460 | vm.polls++ |
| 434 | return nil, nil | 461 | if vm.polls < 3 { |
| 435 | case listVMsCalls < 3: | 462 | out = append(out, client.VM{ID: id, Phase: "booting"}) |
| 436 | return []client.VM{{ID: "vm-1", Phase: "booting"}}, nil | 463 | continue |
| 437 | default: | 464 | } |
| 438 | return []client.VM{{ID: "vm-1", Phase: "ready", AssignedIP: "10.0.0.9", ActualPower: power}}, nil | 465 | out = append(out, client.VM{ID: id, Phase: "ready", AssignedIP: "10.0.0.9", ActualPower: vm.power}) |
| 439 | } | 466 | } |
| 467 | return out, nil | ||
| 440 | }, | 468 | }, |
| 441 | patchVMFunc: func(ctx context.Context, id, powerState string) error { | 469 | patchVMFunc: func(ctx context.Context, id, powerState string) error { |
| 442 | power = powerState | 470 | if vm, ok := vms[id]; ok { |
| 471 | vm.power = powerState | ||
| 472 | } | ||
| 443 | return nil | 473 | return nil |
| 444 | }, | 474 | }, |
| 445 | deleteVMFunc: func(ctx context.Context, id string) error { | 475 | deleteVMFunc: func(ctx context.Context, id string) error { |
| 446 | deleted = true | 476 | delete(vms, id) |
| 447 | return nil | 477 | return nil |
| 448 | }, | 478 | }, |
| 449 | } | 479 | } |
| 450 | } | 480 | } |
| 451 | 481 | ||
| 482 | // okGateRun answers the volume leg the way a working fleet does: the write | ||
| 483 | // command prints nothing, and the read hands back the marker that was written. | ||
| 484 | func okGateRun(ctx context.Context, vmName, cmd string) (string, error) { | ||
| 485 | if strings.Contains(cmd, "cat /mnt/marker") { | ||
| 486 | return volumeMarker + "\n", nil | ||
| 487 | } | ||
| 488 | return "", nil | ||
| 489 | } | ||
| 490 | |||
| 452 | func TestRunScenarioGateRegistersBeforeCreateAndExecsAfterBoot(t *testing.T) { | 491 | func TestRunScenarioGateRegistersBeforeCreateAndExecsAfterBoot(t *testing.T) { |
| 453 | var calls []string | 492 | var calls []string |
| 454 | api := happyPathAPI(t, &calls) | 493 | api := happyPathAPI(t, &calls) |
| @@ -464,6 +503,7 @@ func TestRunScenarioGateRegistersBeforeCreateAndExecsAfterBoot(t *testing.T) { | |||
| 464 | calls = append(calls, "exec") | 503 | calls = append(calls, "exec") |
| 465 | return nil | 504 | return nil |
| 466 | }, | 505 | }, |
| 506 | run: okGateRun, | ||
| 467 | } | 507 | } |
| 468 | 508 | ||
| 469 | clock := &fakeClock{t: time.Unix(0, 0)} | 509 | clock := &fakeClock{t: time.Unix(0, 0)} |
| @@ -480,10 +520,16 @@ func TestRunScenarioGateRegistersBeforeCreateAndExecsAfterBoot(t *testing.T) { | |||
| 480 | if !strings.Contains(msg, "BYO cloud-init merge: ok") { | 520 | if !strings.Contains(msg, "BYO cloud-init merge: ok") { |
| 481 | t.Errorf("message = %q, want it to mention BYO cloud-init merge: ok", msg) | 521 | t.Errorf("message = %q, want it to mention BYO cloud-init merge: ok", msg) |
| 482 | } | 522 | } |
| 523 | if !strings.Contains(msg, "volume outlived its VM: ok") { | ||
| 524 | t.Errorf("message = %q, want it to mention the volume leg", msg) | ||
| 525 | } | ||
| 483 | 526 | ||
| 484 | // exec appears twice: once after the first boot proof, once after the | 527 | // exec appears twice: once after the first boot proof, once after the |
| 485 | // power-cycle proof — SSH through the gate must survive a reboot too. | 528 | // power-cycle proof — SSH through the gate must survive a reboot too. The |
| 486 | want := []string{"register", "createVM", "exec", "exec"} | 529 | // two creates at the end are the volume leg's own pair of VMs: it only |
| 530 | // runs where the gate can reach inside a guest, and it waits until this | ||
| 531 | // scenario's VM is reaped so the host is never asked for a third. | ||
| 532 | want := []string{"register", "createVM", "exec", "exec", "createVM", "createVM"} | ||
| 487 | if len(calls) != len(want) { | 533 | if len(calls) != len(want) { |
| 488 | t.Fatalf("call order = %v, want %v", calls, want) | 534 | t.Fatalf("call order = %v, want %v", calls, want) |
| 489 | } | 535 | } |
| @@ -502,9 +548,10 @@ func TestRunScenarioCreatesItsVMWithATenantCloudInit(t *testing.T) { | |||
| 502 | var calls []string | 548 | var calls []string |
| 503 | api := happyPathAPI(t, &calls) | 549 | api := happyPathAPI(t, &calls) |
| 504 | var got client.CreateVMRequest | 550 | var got client.CreateVMRequest |
| 551 | create := api.createVMFunc | ||
| 505 | api.createVMFunc = func(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) { | 552 | api.createVMFunc = func(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) { |
| 506 | got = req | 553 | got = req |
| 507 | return client.CreateVMResponse{ID: "vm-1"}, nil | 554 | return create(ctx, req) |
| 508 | } | 555 | } |
| 509 | 556 | ||
| 510 | clock := &fakeClock{t: time.Unix(0, 0)} | 557 | clock := &fakeClock{t: time.Unix(0, 0)} |
internal/smoke/volume.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,214 @@ | |||
| 1 | package smoke | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "context" | ||
| 5 | "errors" | ||
| 6 | "fmt" | ||
| 7 | "os" | ||
| 8 | "strings" | ||
| 9 | "time" | ||
| 10 | |||
| 11 | "github.com/a73x/eitri/internal/server/api/client" | ||
| 12 | ) | ||
| 13 | |||
| 14 | // volumeMarker is what the first VM writes to the volume and the second one | ||
| 15 | // must read back. Nothing about it is special except that an empty disk cannot | ||
| 16 | // produce it: a freshly created volume answers with nothing, a recycled one | ||
| 17 | // with somebody else's bytes, and both are failures. | ||
| 18 | const volumeMarker = "eitri-smoke" | ||
| 19 | |||
| 20 | // volumeClaimSizeGB is the smallest claim worth making. The leg proves the | ||
| 21 | // data survives its VM, not that any particular size can be allocated. | ||
| 22 | const volumeClaimSizeGB = 1 | ||
| 23 | |||
| 24 | // volumeDevice is where the first attached claim lands in the guest — after | ||
| 25 | // the root and seed disks, so the first (and here only) claim is /dev/vdc. | ||
| 26 | const volumeDevice = "/dev/vdc" | ||
| 27 | |||
| 28 | // writeMarkerCmd formats the volume and leaves the marker on it, unmounting | ||
| 29 | // before it returns so every byte is on the disk and not in the guest's page | ||
| 30 | // cache when the VM that wrote it is destroyed. | ||
| 31 | const writeMarkerCmd = "sudo mkfs.ext4 -q -F " + volumeDevice + | ||
| 32 | " && sudo mount " + volumeDevice + " /mnt" + | ||
| 33 | " && echo " + volumeMarker + " | sudo tee /mnt/marker >/dev/null" + | ||
| 34 | " && sudo umount /mnt" | ||
| 35 | |||
| 36 | // readMarkerCmd mounts the same volume in a VM that never wrote to it and | ||
| 37 | // reads back what the previous one left. | ||
| 38 | const readMarkerCmd = "sudo mount " + volumeDevice + " /mnt && cat /mnt/marker" | ||
| 39 | |||
| 40 | // proveVolume proves durable storage outlives the VM it was attached to: claim | ||
| 41 | // a volume, attach it to a VM that writes a marker on it, destroy that VM | ||
| 42 | // entirely, attach the same claim to a second VM, and read the marker back. A | ||
| 43 | // volume that answers with anything else — an empty disk, somebody else's data | ||
| 44 | // — fails the leg, because a claim that cannot carry a byte past the guest | ||
| 45 | // that wrote it is not durable storage. | ||
| 46 | // | ||
| 47 | // The guest commands go through the SSH-CA gate, so the leg only runs where the | ||
| 48 | // gate is configured. Whatever the verdict, both VMs and the claim are gone | ||
| 49 | // before it returns: this runs against a live fleet. | ||
| 50 | func proveVolume(ctx context.Context, c vmAPI, hostID, vmName string, gate *gateHooks, readPubKey func() string, now func() time.Time, sleep func(time.Duration)) error { | ||
| 51 | claim, err := c.CreateVolumeClaim(ctx, vmName+"-vol", volumeClaimSizeGB) | ||
| 52 | if err != nil { | ||
| 53 | return fmt.Errorf("create volume claim: %w", err) | ||
| 54 | } | ||
| 55 | |||
| 56 | // VMs this leg still has to clean up. A VM holds its claim until the agent | ||
| 57 | // hard-destroys it — minutes after the delete is accepted, once the | ||
| 58 | // tombstone grace runs out — so every path here waits that reap out before | ||
| 59 | // it tries to delete the claim. A cleanup that gave up sooner would leave | ||
| 60 | // the claim, and the storage behind it, on the fleet on every run. | ||
| 61 | var holders []volumeVM | ||
| 62 | defer func() { | ||
| 63 | // Cleanup outlives a cancelled ctx: leaving a VM and a paid-for volume | ||
| 64 | // on a live plane is worse than the failure that got us here. A cleanup | ||
| 65 | // that itself fails says so on stderr — the leg's own verdict, above, | ||
| 66 | // stands either way. | ||
| 67 | out := context.WithoutCancel(ctx) | ||
| 68 | for _, h := range holders { | ||
| 69 | if _, err := deleteVMAndWaitReaped(out, c, h.id, h.name, claim.ID, now, sleep); err != nil { | ||
| 70 | fmt.Fprintf(os.Stderr, "eitri-smoke: volume cleanup: %v\n", err) | ||
| 71 | } | ||
| 72 | } | ||
| 73 | if err := deleteClaimWhenReleased(out, c, claim.ID, now, sleep); err != nil { | ||
| 74 | fmt.Fprintf(os.Stderr, "eitri-smoke: volume claim %s left behind: %v\n", claim.ID, err) | ||
| 75 | } | ||
| 76 | }() | ||
| 77 | |||
| 78 | writerName := vmName + "-v1" | ||
| 79 | writerID, err := createVolumeVM(ctx, c, hostID, writerName, claim.ID, readPubKey, now, sleep) | ||
| 80 | if writerID != "" { | ||
| 81 | holders = append(holders, volumeVM{id: writerID, name: writerName}) | ||
| 82 | } | ||
| 83 | if err != nil { | ||
| 84 | return err | ||
| 85 | } | ||
| 86 | if _, err := gate.run(ctx, writerName, writeMarkerCmd); err != nil { | ||
| 87 | return fmt.Errorf("FAIL: could not write the marker to the volume on %s: %w", writerName, err) | ||
| 88 | } | ||
| 89 | |||
| 90 | // Destroy the writer outright — not a reboot, not a detach. The claim is | ||
| 91 | // only proven durable if it survives the guest, the disk image, and the | ||
| 92 | // record of the VM that made it. | ||
| 93 | deleted, err := deleteVMAndWaitReaped(ctx, c, writerID, writerName, claim.ID, now, sleep) | ||
| 94 | if deleted { | ||
| 95 | holders = drop(holders, writerID) | ||
| 96 | } | ||
| 97 | if err != nil { | ||
| 98 | return err | ||
| 99 | } | ||
| 100 | |||
| 101 | readerName := vmName + "-v2" | ||
| 102 | readerID, err := createVolumeVM(ctx, c, hostID, readerName, claim.ID, readPubKey, now, sleep) | ||
| 103 | if readerID != "" { | ||
| 104 | holders = append(holders, volumeVM{id: readerID, name: readerName}) | ||
| 105 | } | ||
| 106 | if err != nil { | ||
| 107 | return err | ||
| 108 | } | ||
| 109 | out, err := gate.run(ctx, readerName, readMarkerCmd) | ||
| 110 | if err != nil { | ||
| 111 | return fmt.Errorf("FAIL: could not read the marker back from the volume on %s: %w", readerName, err) | ||
| 112 | } | ||
| 113 | if got := strings.TrimSpace(out); got != volumeMarker { | ||
| 114 | return fmt.Errorf("FAIL: the volume reattached to %s answered %q, want %q — the data did not survive the VM that wrote it", | ||
| 115 | readerName, truncateBanner(got), volumeMarker) | ||
| 116 | } | ||
| 117 | |||
| 118 | // The reader goes here rather than in the cleanup above only so a failure | ||
| 119 | // deleting it is the leg's verdict rather than a line on stderr; either way | ||
| 120 | // the claim cannot be deleted until this reap finishes. | ||
| 121 | deleted, err = deleteVMAndWaitReaped(ctx, c, readerID, readerName, claim.ID, now, sleep) | ||
| 122 | if deleted { | ||
| 123 | holders = drop(holders, readerID) | ||
| 124 | } | ||
| 125 | return err | ||
| 126 | } | ||
| 127 | |||
| 128 | // volumeVM is one of the leg's VMs: the id to act on, the name to report. | ||
| 129 | type volumeVM struct{ id, name string } | ||
| 130 | |||
| 131 | // createVolumeVM creates one VM with the claim attached and waits for it to be | ||
| 132 | // ready. It returns the VM's id even when the wait fails, so the caller can | ||
| 133 | // still clean up a VM the fleet did create. | ||
| 134 | func createVolumeVM(ctx context.Context, c vmAPI, hostID, name, claimID string, readPubKey func() string, now func() time.Time, sleep func(time.Duration)) (string, error) { | ||
| 135 | created, err := c.CreateVM(ctx, client.CreateVMRequest{ | ||
| 136 | HostID: hostID, | ||
| 137 | Name: name, | ||
| 138 | SSHAuthorizedKey: readPubKey(), | ||
| 139 | VolumeClaims: []string{claimID}, | ||
| 140 | }) | ||
| 141 | if err != nil { | ||
| 142 | return "", fmt.Errorf("create vm %s with claim %s: %w", name, claimID, err) | ||
| 143 | } | ||
| 144 | |||
| 145 | var lastPhase string | ||
| 146 | err = pollLoop(ctx, now, sleep, 600*time.Second, 5*time.Second, func() (bool, error) { | ||
| 147 | vm, _, err := getVM(ctx, c, created.ID) | ||
| 148 | if err != nil { | ||
| 149 | return false, fmt.Errorf("poll vm ready: %w", err) | ||
| 150 | } | ||
| 151 | lastPhase = vm.Phase | ||
| 152 | return vm.Phase == "ready" && vm.AssignedIP != "", nil | ||
| 153 | }) | ||
| 154 | if errors.Is(err, errPollTimeout) { | ||
| 155 | return created.ID, fmt.Errorf("FAIL: VM %s (with a volume attached) not ready within 600s (phase=%s)", name, lastPhase) | ||
| 156 | } | ||
| 157 | return created.ID, err | ||
| 158 | } | ||
| 159 | |||
| 160 | // deleteVMAndWaitReaped deletes a VM and waits until the fleet stops listing | ||
| 161 | // it — the moment the claim it holds becomes attachable again. The window | ||
| 162 | // matches the scenario's reap poll: it must outlast the agent's tombstone | ||
| 163 | // grace, which a fielded plane runs at five minutes. | ||
| 164 | // | ||
| 165 | // deleted reports whether the DELETE itself was accepted, and it is true even | ||
| 166 | // when the reap then times out, so a caller never re-issues a delete that | ||
| 167 | // worked and never blames one that did. | ||
| 168 | func deleteVMAndWaitReaped(ctx context.Context, c vmAPI, vmID, name, claimID string, now func() time.Time, sleep func(time.Duration)) (deleted bool, err error) { | ||
| 169 | if err := c.DeleteVM(ctx, vmID); err != nil { | ||
| 170 | return false, fmt.Errorf("delete vm %s: %w", name, err) | ||
| 171 | } | ||
| 172 | err = pollLoop(ctx, now, sleep, 7*time.Minute, 5*time.Second, func() (bool, error) { | ||
| 173 | _, present, err := getVM(ctx, c, vmID) | ||
| 174 | if err != nil { | ||
| 175 | return false, fmt.Errorf("poll vm reaped: %w", err) | ||
| 176 | } | ||
| 177 | return !present, nil | ||
| 178 | }) | ||
| 179 | if errors.Is(err, errPollTimeout) { | ||
| 180 | return true, fmt.Errorf("FAIL: VM %s (%s) was deleted but not reaped within 7m; it still holds claim %s", | ||
| 181 | name, vmID, claimID) | ||
| 182 | } | ||
| 183 | return true, err | ||
| 184 | } | ||
| 185 | |||
| 186 | // deleteClaimWhenReleased deletes a claim once nothing holds it. Its callers | ||
| 187 | // have already waited for the reap of every VM that did, so this is a short | ||
| 188 | // retry over the tick between a row disappearing and the plane agreeing it has | ||
| 189 | // — NOT the wait for the reap itself, which no 30-second poll could outlast. | ||
| 190 | func deleteClaimWhenReleased(ctx context.Context, c vmAPI, claimID string, now func() time.Time, sleep func(time.Duration)) error { | ||
| 191 | var lastErr error | ||
| 192 | err := pollLoop(ctx, now, sleep, 30*time.Second, 3*time.Second, func() (bool, error) { | ||
| 193 | if derr := c.DeleteVolumeClaim(ctx, claimID); derr != nil { | ||
| 194 | lastErr = derr | ||
| 195 | return false, nil | ||
| 196 | } | ||
| 197 | return true, nil | ||
| 198 | }) | ||
| 199 | if errors.Is(err, errPollTimeout) { | ||
| 200 | return fmt.Errorf("still refused 30s after its VMs were reaped: %w", lastErr) | ||
| 201 | } | ||
| 202 | return err | ||
| 203 | } | ||
| 204 | |||
| 205 | // drop removes the VM with this id, keeping the rest in order. | ||
| 206 | func drop(vms []volumeVM, id string) []volumeVM { | ||
| 207 | kept := vms[:0] | ||
| 208 | for _, vm := range vms { | ||
| 209 | if vm.id != id { | ||
| 210 | kept = append(kept, vm) | ||
| 211 | } | ||
| 212 | } | ||
| 213 | return kept | ||
| 214 | } | ||
internal/smoke/volume_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,325 @@ | |||
| 1 | package smoke | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "context" | ||
| 5 | "errors" | ||
| 6 | "fmt" | ||
| 7 | "strings" | ||
| 8 | "testing" | ||
| 9 | "time" | ||
| 10 | |||
| 11 | "github.com/a73x/eitri/internal/server/api/client" | ||
| 12 | ) | ||
| 13 | |||
| 14 | // volumeFleet is a small stateful fake fleet for the volume leg. It models the | ||
| 15 | // one property the leg has to get right: a deleted VM is NOT gone. It keeps | ||
| 16 | // its row — and with it the claim it holds — for reapAfter, the agent's | ||
| 17 | // tombstone grace, and the claim is refused for every second of that. A fake | ||
| 18 | // that reaped instantly would call a leg that leaks storage on a live fleet | ||
| 19 | // green. | ||
| 20 | type volumeFleet struct { | ||
| 21 | t *testing.T | ||
| 22 | clock *fakeClock | ||
| 23 | events []string // what the leg asked for, in order | ||
| 24 | created []client.CreateVMRequest // every VM create, in order | ||
| 25 | live map[string]string // live vm id -> name | ||
| 26 | dying map[string]dyingVM // deleted, still listed until reaped | ||
| 27 | next int | ||
| 28 | claimID string | ||
| 29 | // refusals is how many delete-claim attempts are refused after the last VM | ||
| 30 | // row is gone — the tick between the reap and the plane agreeing to it. | ||
| 31 | refusals int | ||
| 32 | claimDeletes int | ||
| 33 | // reapAfter is how long a deleted VM keeps its row. A fielded plane runs | ||
| 34 | // the agent's five-minute tombstone grace, so the fake does too. | ||
| 35 | reapAfter time.Duration | ||
| 36 | } | ||
| 37 | |||
| 38 | // dyingVM is a deleted VM still in its tombstone grace. | ||
| 39 | type dyingVM struct { | ||
| 40 | name string | ||
| 41 | destroyAt time.Time | ||
| 42 | } | ||
| 43 | |||
| 44 | func newVolumeFleet(t *testing.T, clock *fakeClock) *volumeFleet { | ||
| 45 | t.Helper() | ||
| 46 | return &volumeFleet{ | ||
| 47 | t: t, | ||
| 48 | clock: clock, | ||
| 49 | live: map[string]string{}, | ||
| 50 | dying: map[string]dyingVM{}, | ||
| 51 | claimID: "claim-1", | ||
| 52 | reapAfter: 5 * time.Minute, | ||
| 53 | } | ||
| 54 | } | ||
| 55 | |||
| 56 | // reap drops the rows whose tombstone grace has run out. It is what every | ||
| 57 | // listing and every claim delete consults, so "is it gone yet" has exactly one | ||
| 58 | // answer in this fake. | ||
| 59 | func (f *volumeFleet) reap() { | ||
| 60 | for id, vm := range f.dying { | ||
| 61 | if !f.clock.now().Before(vm.destroyAt) { | ||
| 62 | delete(f.dying, id) | ||
| 63 | } | ||
| 64 | } | ||
| 65 | } | ||
| 66 | |||
| 67 | func (f *volumeFleet) api() *testAPI { | ||
| 68 | return &testAPI{ | ||
| 69 | createVolumeClaimFunc: func(ctx context.Context, name string, sizeGB int64) (client.VolumeClaim, error) { | ||
| 70 | f.events = append(f.events, "claim:"+name) | ||
| 71 | return client.VolumeClaim{ID: f.claimID, Name: name, SizeGB: sizeGB}, nil | ||
| 72 | }, | ||
| 73 | deleteVolumeClaimFunc: func(ctx context.Context, id string) error { | ||
| 74 | if id != f.claimID { | ||
| 75 | f.t.Errorf("DeleteVolumeClaim id = %q, want %q", id, f.claimID) | ||
| 76 | } | ||
| 77 | f.claimDeletes++ | ||
| 78 | f.reap() | ||
| 79 | // A VM in its tombstone grace still holds the claim: the row is | ||
| 80 | // there, the file behind it is still attached to that VM. | ||
| 81 | if len(f.live)+len(f.dying) > 0 { | ||
| 82 | return fmt.Errorf("409: claim %s is attached to a VM", id) | ||
| 83 | } | ||
| 84 | if f.claimDeletes <= f.refusals { | ||
| 85 | return fmt.Errorf("409: claim %s is still attached", id) | ||
| 86 | } | ||
| 87 | f.events = append(f.events, "claim-delete") | ||
| 88 | return nil | ||
| 89 | }, | ||
| 90 | createVMFunc: func(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) { | ||
| 91 | f.next++ | ||
| 92 | id := fmt.Sprintf("vm-%d", f.next) | ||
| 93 | f.created = append(f.created, req) | ||
| 94 | f.live[id] = req.Name | ||
| 95 | f.events = append(f.events, "vm:"+req.Name) | ||
| 96 | return client.CreateVMResponse{ID: id}, nil | ||
| 97 | }, | ||
| 98 | listVMsFunc: func(ctx context.Context) ([]client.VM, error) { | ||
| 99 | f.reap() | ||
| 100 | var out []client.VM | ||
| 101 | for id, name := range f.live { | ||
| 102 | out = append(out, client.VM{ID: id, Name: name, Phase: "ready", AssignedIP: "10.0.0.9"}) | ||
| 103 | } | ||
| 104 | for id, vm := range f.dying { | ||
| 105 | out = append(out, client.VM{ID: id, Name: vm.name, Phase: "ready", AssignedIP: "10.0.0.9", Deleted: true}) | ||
| 106 | } | ||
| 107 | return out, nil | ||
| 108 | }, | ||
| 109 | deleteVMFunc: func(ctx context.Context, id string) error { | ||
| 110 | name, ok := f.live[id] | ||
| 111 | if !ok { | ||
| 112 | // A delete re-issued against a row that is already deleted is | ||
| 113 | // recorded before it is refused, so a test can see the wasted | ||
| 114 | // call and the 404 it earns. | ||
| 115 | if vm, dying := f.dying[id]; dying { | ||
| 116 | f.events = append(f.events, "delete:"+vm.name) | ||
| 117 | return fmt.Errorf("404: VM %s is already deleted", id) | ||
| 118 | } | ||
| 119 | return fmt.Errorf("404: no VM %s", id) | ||
| 120 | } | ||
| 121 | f.events = append(f.events, "delete:"+name) | ||
| 122 | delete(f.live, id) | ||
| 123 | f.dying[id] = dyingVM{name: name, destroyAt: f.clock.now().Add(f.reapAfter)} | ||
| 124 | return nil | ||
| 125 | }, | ||
| 126 | } | ||
| 127 | } | ||
| 128 | |||
| 129 | // markerGate answers the way a working guest does: the write command says | ||
| 130 | // nothing, and the read command hands the marker back. | ||
| 131 | func markerGate(record *[]string, marker string, writeErr error) *gateHooks { | ||
| 132 | return &gateHooks{ | ||
| 133 | run: func(ctx context.Context, vmName, cmd string) (string, error) { | ||
| 134 | *record = append(*record, vmName) | ||
| 135 | if strings.Contains(cmd, "mkfs") { | ||
| 136 | return "", writeErr | ||
| 137 | } | ||
| 138 | return marker + "\n", nil | ||
| 139 | }, | ||
| 140 | } | ||
| 141 | } | ||
| 142 | |||
| 143 | func TestProveVolumeCarriesAMarkerPastTheVMThatWroteIt(t *testing.T) { | ||
| 144 | clock := &fakeClock{t: time.Unix(0, 0)} | ||
| 145 | fleet := newVolumeFleet(t, clock) | ||
| 146 | var ran []string | ||
| 147 | gate := markerGate(&ran, volumeMarker, nil) | ||
| 148 | |||
| 149 | if err := proveVolume(context.Background(), fleet.api(), "host-1", "smoke-test", gate, noopReadPubKey, clock.now, clock.sleep); err != nil { | ||
| 150 | t.Fatalf("proveVolume: %v", err) | ||
| 151 | } | ||
| 152 | |||
| 153 | // The claim is storage the VMs attach to, so it exists before either of | ||
| 154 | // them: a claim made after the fact could not be the disk they booted with. | ||
| 155 | want := []string{ | ||
| 156 | "claim:smoke-test-vol", | ||
| 157 | "vm:smoke-test-v1", | ||
| 158 | "delete:smoke-test-v1", | ||
| 159 | "vm:smoke-test-v2", | ||
| 160 | "delete:smoke-test-v2", | ||
| 161 | "claim-delete", | ||
| 162 | } | ||
| 163 | if strings.Join(fleet.events, ",") != strings.Join(want, ",") { | ||
| 164 | t.Fatalf("fleet calls = %v, want %v", fleet.events, want) | ||
| 165 | } | ||
| 166 | if len(fleet.created) != 2 { | ||
| 167 | t.Fatalf("VM creates = %d, want 2", len(fleet.created)) | ||
| 168 | } | ||
| 169 | for i, req := range fleet.created { | ||
| 170 | if len(req.VolumeClaims) != 1 || req.VolumeClaims[0] != "claim-1" { | ||
| 171 | t.Errorf("VM %d VolumeClaims = %v, want [claim-1] — both VMs must name the same claim", i+1, req.VolumeClaims) | ||
| 172 | } | ||
| 173 | if req.HostID != "host-1" { | ||
| 174 | t.Errorf("VM %d HostID = %q, want host-1 — the claim is bound to one host", i+1, req.HostID) | ||
| 175 | } | ||
| 176 | if req.SSHAuthorizedKey != noopReadPubKey() { | ||
| 177 | t.Errorf("VM %d SSHAuthorizedKey = %q, want the local key", i+1, req.SSHAuthorizedKey) | ||
| 178 | } | ||
| 179 | } | ||
| 180 | if len(ran) != 2 || ran[0] != "smoke-test-v1" || ran[1] != "smoke-test-v2" { | ||
| 181 | t.Errorf("guest commands ran on %v, want [smoke-test-v1 smoke-test-v2]", ran) | ||
| 182 | } | ||
| 183 | } | ||
| 184 | |||
| 185 | // TestProveVolumeFailsWhenTheMarkerComesBackWrong is the leg's whole point: a | ||
| 186 | // volume that reattaches but answers with something else did not carry the | ||
| 187 | // data across, and the failure quotes what it did answer with. | ||
| 188 | func TestProveVolumeFailsWhenTheMarkerComesBackWrong(t *testing.T) { | ||
| 189 | clock := &fakeClock{t: time.Unix(0, 0)} | ||
| 190 | fleet := newVolumeFleet(t, clock) | ||
| 191 | var ran []string | ||
| 192 | gate := markerGate(&ran, "a-fresh-empty-disk", nil) | ||
| 193 | |||
| 194 | err := proveVolume(context.Background(), fleet.api(), "host-1", "smoke-test", gate, noopReadPubKey, clock.now, clock.sleep) | ||
| 195 | if err == nil { | ||
| 196 | t.Fatal("proveVolume: want an error when the marker came back wrong, got nil") | ||
| 197 | } | ||
| 198 | if !strings.Contains(err.Error(), "a-fresh-empty-disk") { | ||
| 199 | t.Errorf("error = %q, want it to quote what the volume answered with", err.Error()) | ||
| 200 | } | ||
| 201 | if !strings.Contains(err.Error(), volumeMarker) { | ||
| 202 | t.Errorf("error = %q, want it to name the marker it wanted", err.Error()) | ||
| 203 | } | ||
| 204 | // The failing VM and the storage behind it are still cleaned up. | ||
| 205 | if len(fleet.live) != 0 { | ||
| 206 | t.Errorf("live VMs = %v, want none — the leg deletes its VMs on the failure path too", fleet.live) | ||
| 207 | } | ||
| 208 | if fleet.claimDeletes == 0 { | ||
| 209 | t.Error("the claim was never deleted; a failed leg must not leave storage behind") | ||
| 210 | } | ||
| 211 | } | ||
| 212 | |||
| 213 | // TestProveVolumeCleansUpWhenTheGuestCommandFails covers the earlier failure | ||
| 214 | // point: the first VM is up but cannot write, so that VM and the claim both go. | ||
| 215 | func TestProveVolumeCleansUpWhenTheGuestCommandFails(t *testing.T) { | ||
| 216 | clock := &fakeClock{t: time.Unix(0, 0)} | ||
| 217 | fleet := newVolumeFleet(t, clock) | ||
| 218 | var ran []string | ||
| 219 | gate := markerGate(&ran, volumeMarker, errors.New("mkfs: no such device")) | ||
| 220 | |||
| 221 | err := proveVolume(context.Background(), fleet.api(), "host-1", "smoke-test", gate, noopReadPubKey, clock.now, clock.sleep) | ||
| 222 | if err == nil { | ||
| 223 | t.Fatal("proveVolume: want an error when the guest could not write the marker, got nil") | ||
| 224 | } | ||
| 225 | if !strings.Contains(err.Error(), "no such device") { | ||
| 226 | t.Errorf("error = %q, want it to carry the guest's own complaint", err.Error()) | ||
| 227 | } | ||
| 228 | if len(fleet.created) != 1 { | ||
| 229 | t.Errorf("VM creates = %d, want 1 — the second VM proves nothing once the first never wrote", len(fleet.created)) | ||
| 230 | } | ||
| 231 | want := []string{"claim:smoke-test-vol", "vm:smoke-test-v1", "delete:smoke-test-v1", "claim-delete"} | ||
| 232 | if strings.Join(fleet.events, ",") != strings.Join(want, ",") { | ||
| 233 | t.Errorf("fleet calls = %v, want %v", fleet.events, want) | ||
| 234 | } | ||
| 235 | } | ||
| 236 | |||
| 237 | // TestProveVolumeFreesTheClaimAfterTheTombstoneGrace is the leak guard. A VM | ||
| 238 | // holds its claim until the agent hard-destroys it, minutes after the delete | ||
| 239 | // is accepted, so a leg that deletes the claim on the way out without waiting | ||
| 240 | // that grace out leaves a claim and the storage behind it on a live fleet — | ||
| 241 | // every single run. The proof that it waited is the clock: two full graces of | ||
| 242 | // virtual time passed, one per VM. | ||
| 243 | func TestProveVolumeFreesTheClaimAfterTheTombstoneGrace(t *testing.T) { | ||
| 244 | clock := &fakeClock{t: time.Unix(0, 0)} | ||
| 245 | fleet := newVolumeFleet(t, clock) | ||
| 246 | start := clock.now() | ||
| 247 | var ran []string | ||
| 248 | gate := markerGate(&ran, volumeMarker, nil) | ||
| 249 | |||
| 250 | if err := proveVolume(context.Background(), fleet.api(), "host-1", "smoke-test", gate, noopReadPubKey, clock.now, clock.sleep); err != nil { | ||
| 251 | t.Fatalf("proveVolume: %v", err) | ||
| 252 | } | ||
| 253 | if fleet.events[len(fleet.events)-1] != "claim-delete" { | ||
| 254 | t.Fatalf("fleet calls = %v, want the claim actually deleted at the end", fleet.events) | ||
| 255 | } | ||
| 256 | if len(fleet.live) != 0 || len(fleet.dying) != 0 { | ||
| 257 | t.Errorf("rows left = %v / %v, want none", fleet.live, fleet.dying) | ||
| 258 | } | ||
| 259 | if waited := clock.now().Sub(start); waited < 2*fleet.reapAfter { | ||
| 260 | t.Errorf("leg waited %s, want at least %s — one tombstone grace per VM before the claim can go", | ||
| 261 | waited, 2*fleet.reapAfter) | ||
| 262 | } | ||
| 263 | } | ||
| 264 | |||
| 265 | // TestProveVolumeRetriesTheClaimDeleteAfterTheReap: the plane can still refuse | ||
| 266 | // for a tick after the row is gone, so the delete is retried rather than | ||
| 267 | // reported as storage the leg never actually left behind. | ||
| 268 | func TestProveVolumeRetriesTheClaimDeleteAfterTheReap(t *testing.T) { | ||
| 269 | clock := &fakeClock{t: time.Unix(0, 0)} | ||
| 270 | fleet := newVolumeFleet(t, clock) | ||
| 271 | fleet.refusals = 2 | ||
| 272 | var ran []string | ||
| 273 | gate := markerGate(&ran, volumeMarker, nil) | ||
| 274 | |||
| 275 | if err := proveVolume(context.Background(), fleet.api(), "host-1", "smoke-test", gate, noopReadPubKey, clock.now, clock.sleep); err != nil { | ||
| 276 | t.Fatalf("proveVolume: %v", err) | ||
| 277 | } | ||
| 278 | if fleet.claimDeletes != 3 { | ||
| 279 | t.Errorf("DeleteVolumeClaim attempts = %d, want 3 — two refusals then the delete that lands", fleet.claimDeletes) | ||
| 280 | } | ||
| 281 | if fleet.events[len(fleet.events)-1] != "claim-delete" { | ||
| 282 | t.Errorf("fleet calls = %v, want the claim deleted last", fleet.events) | ||
| 283 | } | ||
| 284 | } | ||
| 285 | |||
| 286 | // TestProveVolumeFailsWhenTheFirstVMIsNeverReaped: the second VM cannot attach | ||
| 287 | // a claim the first one still holds, so a VM that will not go away fails the | ||
| 288 | // leg here rather than as a mystery 409 on the next create. The delete itself | ||
| 289 | // was accepted — the fleet simply never finished it — so the message says | ||
| 290 | // that, and the cleanup does not re-issue a delete that already worked. | ||
| 291 | func TestProveVolumeFailsWhenTheFirstVMIsNeverReaped(t *testing.T) { | ||
| 292 | clock := &fakeClock{t: time.Unix(0, 0)} | ||
| 293 | fleet := newVolumeFleet(t, clock) | ||
| 294 | fleet.reapAfter = 24 * time.Hour // accepted, never finished | ||
| 295 | var ran []string | ||
| 296 | gate := markerGate(&ran, volumeMarker, nil) | ||
| 297 | |||
| 298 | err := proveVolume(context.Background(), fleet.api(), "host-1", "smoke-test", gate, noopReadPubKey, clock.now, clock.sleep) | ||
| 299 | if err == nil { | ||
| 300 | t.Fatal("proveVolume: want an error when the first VM was never reaped, got nil") | ||
| 301 | } | ||
| 302 | if !strings.Contains(err.Error(), "smoke-test-v1") { | ||
| 303 | t.Errorf("error = %q, want it to name the VM that would not go away", err.Error()) | ||
| 304 | } | ||
| 305 | if !strings.Contains(err.Error(), "deleted but not reaped") { | ||
| 306 | t.Errorf("error = %q, want it to say the delete was accepted and the reap never finished", err.Error()) | ||
| 307 | } | ||
| 308 | if !strings.Contains(err.Error(), "claim-1") { | ||
| 309 | t.Errorf("error = %q, want it to name the claim the VM is still holding", err.Error()) | ||
| 310 | } | ||
| 311 | if len(fleet.created) != 1 { | ||
| 312 | t.Errorf("VM creates = %d, want 1 — the second VM is never created", len(fleet.created)) | ||
| 313 | } | ||
| 314 | // One delete, not two: the cleanup must not re-issue a delete that was | ||
| 315 | // accepted, or the 404 it earns would blame a delete that worked. | ||
| 316 | deletes := 0 | ||
| 317 | for _, e := range fleet.events { | ||
| 318 | if strings.HasPrefix(e, "delete:") { | ||
| 319 | deletes++ | ||
| 320 | } | ||
| 321 | } | ||
| 322 | if deletes != 1 { | ||
| 323 | t.Errorf("DeleteVM calls = %d, want 1 — the accepted delete is not re-issued on the way out", deletes) | ||
| 324 | } | ||
| 325 | } | ||
internal/transport/fieldnumbers_test.go
| Old | New | ||
|---|---|---|---|
| @@ -108,6 +108,7 @@ var wireSchema = map[string]map[string]protoreflect.FieldNumber{ | |||
| 108 | "guest_cidr": 8, | 108 | "guest_cidr": 8, |
| 109 | "exposures": 9, | 109 | "exposures": 9, |
| 110 | "host_uplink_addr": 10, | 110 | "host_uplink_addr": 10, |
| 111 | "volumes": 11, | ||
| 111 | }, | 112 | }, |
| 112 | "VMSpec": { | 113 | "VMSpec": { |
| 113 | "vm_id": 1, | 114 | "vm_id": 1, |
| @@ -126,12 +127,14 @@ var wireSchema = map[string]map[string]protoreflect.FieldNumber{ | |||
| 126 | "ssh_user_ca_authorized_keys": 18, | 127 | "ssh_user_ca_authorized_keys": 18, |
| 127 | "host_cert_required": 19, | 128 | "host_cert_required": 19, |
| 128 | "network": 20, | 129 | "network": 20, |
| 130 | "volume_ids": 21, | ||
| 129 | }, | 131 | }, |
| 130 | "Snapshot": { | 132 | "Snapshot": { |
| 131 | "epoch": 1, | 133 | "epoch": 1, |
| 132 | "vms": 2, | 134 | "vms": 2, |
| 133 | "agent_upgrade": 3, | 135 | "agent_upgrade": 3, |
| 134 | "exposures": 4, | 136 | "exposures": 4, |
| 137 | "volumes": 5, | ||
| 135 | "min_agent_version": 6, | 138 | "min_agent_version": 6, |
| 136 | }, | 139 | }, |
| 137 | "AgentUpgrade": { | 140 | "AgentUpgrade": { |
| @@ -172,6 +175,16 @@ var wireSchema = map[string]map[string]protoreflect.FieldNumber{ | |||
| 172 | "refused": 2, | 175 | "refused": 2, |
| 173 | "dropped": 3, | 176 | "dropped": 3, |
| 174 | }, | 177 | }, |
| 178 | "VolumeSpec": { | ||
| 179 | "volume_id": 1, | ||
| 180 | "size_gb": 2, | ||
| 181 | "tombstoned": 3, | ||
| 182 | }, | ||
| 183 | "VolumeStatus": { | ||
| 184 | "volume_id": 1, | ||
| 185 | "present": 2, | ||
| 186 | "size_gb": 3, | ||
| 187 | }, | ||
| 175 | } | 188 | } |
| 176 | 189 | ||
| 177 | // TestWireFieldNumbersAreLocked walks every message in the compiled wire schema | 190 | // TestWireFieldNumbersAreLocked walks every message in the compiled wire schema |
proto/eitri/v1/sync.proto
| Old | New | ||
|---|---|---|---|
| @@ -142,6 +142,7 @@ message Report { | |||
| 142 | // report rather than Hello for the same reason guest_cidr is: it can change | 142 | // report rather than Hello for the same reason guest_cidr is: it can change |
| 143 | // while an agent stays connected. | 143 | // while an agent stays connected. |
| 144 | string host_uplink_addr = 10; | 144 | string host_uplink_addr = 10; |
| 145 | repeated VolumeStatus volumes = 11; | ||
| 145 | } | 146 | } |
| 146 | 147 | ||
| 147 | // VMSpec is the half of a VM the control plane owns. See VMStatus. | 148 | // VMSpec is the half of a VM the control plane owns. See VMStatus. |
| @@ -192,6 +193,10 @@ message VMSpec { | |||
| 192 | // it (see Hello.host_networks); an agent that has the name but no longer | 193 | // it (see Hello.host_networks); an agent that has the name but no longer |
| 193 | // the configuration fails the VM legibly rather than silently NAT-ing it. | 194 | // the configuration fails the VM legibly rather than silently NAT-ing it. |
| 194 | string network = 20; | 195 | string network = 20; |
| 196 | // volume_ids are the bound volumes to attach after root and seed, in this | ||
| 197 | // order — the first is /dev/vdc. Every id names a VolumeSpec in the same | ||
| 198 | // snapshot. | ||
| 199 | repeated string volume_ids = 21; | ||
| 195 | } | 200 | } |
| 196 | 201 | ||
| 197 | // Snapshot is the FULL spec for one host; the agent converges toward it. | 202 | // Snapshot is the FULL spec for one host; the agent converges toward it. |
| @@ -200,7 +205,7 @@ message Snapshot { | |||
| 200 | repeated VMSpec vms = 2; // FULL set for this host, including tombstoned | 205 | repeated VMSpec vms = 2; // FULL set for this host, including tombstoned |
| 201 | AgentUpgrade agent_upgrade = 3; // optional operator-initiated agent self-upgrade | 206 | AgentUpgrade agent_upgrade = 3; // optional operator-initiated agent self-upgrade |
| 202 | repeated ExposureSpec exposures = 4; // FULL set for this host | 207 | repeated ExposureSpec exposures = 4; // FULL set for this host |
| 203 | // 5 is taken by volumes in the next change. | 208 | repeated VolumeSpec volumes = 5; // FULL set for this host |
| 204 | // min_agent_version is the lowest agent release that understands every | 209 | // min_agent_version is the lowest agent release that understands every |
| 205 | // field in this snapshot. An agent below it fails every VM here with a | 210 | // field in this snapshot. An agent below it fails every VM here with a |
| 206 | // legible reason rather than materialising a spec it only half-reads. | 211 | // legible reason rather than materialising a spec it only half-reads. |
| @@ -301,3 +306,21 @@ message ExposureSessions { | |||
| 301 | // — as opposed to ones the cap refused. | 306 | // — as opposed to ones the cap refused. |
| 302 | int64 dropped = 3; | 307 | int64 dropped = 3; |
| 303 | } | 308 | } |
| 309 | |||
| 310 | // VolumeSpec is one volume the control plane has placed on this host. The | ||
| 311 | // agent keeps a sparse raw file for it at volumes/<volume_id>/disk.raw, beside | ||
| 312 | // vms/ and never inside a VM's directory: a VM's directory dies with the VM, | ||
| 313 | // a volume's does not. Snapshot.volumes is the FULL set for the host. | ||
| 314 | message VolumeSpec { | ||
| 315 | string volume_id = 1; | ||
| 316 | int64 size_gb = 2; | ||
| 317 | bool tombstoned = 3; // delete the file after grace; set only once no VM references it | ||
| 318 | } | ||
| 319 | |||
| 320 | // VolumeStatus is what the host found on its disk for one volume id — | ||
| 321 | // including ids the snapshot did not name, which are reported and kept. | ||
| 322 | message VolumeStatus { | ||
| 323 | string volume_id = 1; | ||
| 324 | bool present = 2; | ||
| 325 | int64 size_gb = 3; // as found, not as asked: a guest filesystem sits on it, so it is never resized | ||
| 326 | } | ||
scripts/coverage.sh
| Old | New | ||
|---|---|---|---|
| @@ -83,7 +83,7 @@ declare -A FLOOR=( | |||
| 83 | [internal/transport]=83 | 83 | [internal/transport]=83 |
| 84 | [internal/shape]=92 | 84 | [internal/shape]=92 |
| 85 | [internal/site]=87 | 85 | [internal/site]=87 |
| 86 | [internal/smoke]=57 | 86 | [internal/smoke]=58 |
| 87 | [internal/cli]=72 | 87 | [internal/cli]=72 |
| 88 | [internal/mcpserver]=77 | 88 | [internal/mcpserver]=77 |
| 89 | [internal/oidcprovider]=81 | 89 | [internal/oidcprovider]=81 |
site/docs.md
| Old | New | ||
|---|---|---|---|
| @@ -11,6 +11,8 @@ | |||
| 11 | 11 | ||
| 12 | - [networking](networking.md)—publishing a port a guest serves, or giving a VM | 12 | - [networking](networking.md)—publishing a port a guest serves, or giving a VM |
| 13 | an address on your own LAN | 13 | an address on your own LAN |
| 14 | - [volumes](volumes.md)—durable disks a VM attaches at create and keeps after | ||
| 15 | it dies | ||
| 14 | - [mcp](mcp.md)—let an AI agent create and drive VMs | 16 | - [mcp](mcp.md)—let an AI agent create and drive VMs |
| 15 | 17 | ||
| 16 | **Run a fleet** | 18 | **Run a fleet** |
web/src/lib/api-types.ts
| Old | New | ||
|---|---|---|---|
| @@ -1480,6 +1480,160 @@ export interface paths { | |||
| 1480 | patch?: never; | 1480 | patch?: never; |
| 1481 | trace?: never; | 1481 | trace?: never; |
| 1482 | }; | 1482 | }; |
| 1483 | "/api/v1/volume-claims": { | ||
| 1484 | parameters: { | ||
| 1485 | query?: never; | ||
| 1486 | header?: never; | ||
| 1487 | path?: never; | ||
| 1488 | cookie?: never; | ||
| 1489 | }; | ||
| 1490 | /** List the tenant's claims: where each is bound, which VM holds it, and whether its host has the file. */ | ||
| 1491 | get: { | ||
| 1492 | parameters: { | ||
| 1493 | query?: never; | ||
| 1494 | header?: never; | ||
| 1495 | path?: never; | ||
| 1496 | cookie?: never; | ||
| 1497 | }; | ||
| 1498 | requestBody?: never; | ||
| 1499 | responses: { | ||
| 1500 | /** @description success */ | ||
| 1501 | 200: { | ||
| 1502 | headers: { | ||
| 1503 | [name: string]: unknown; | ||
| 1504 | }; | ||
| 1505 | content: { | ||
| 1506 | "application/json": components["schemas"]["VolumeClaim"][]; | ||
| 1507 | }; | ||
| 1508 | }; | ||
| 1509 | /** @description error (plain text) */ | ||
| 1510 | default: { | ||
| 1511 | headers: { | ||
| 1512 | [name: string]: unknown; | ||
| 1513 | }; | ||
| 1514 | content: { | ||
| 1515 | "text/plain": string; | ||
| 1516 | }; | ||
| 1517 | }; | ||
| 1518 | }; | ||
| 1519 | }; | ||
| 1520 | put?: never; | ||
| 1521 | /** Claim durable storage. Pending until the first VM naming it is created; that VM's host then holds the bytes, and every later VM using the claim is placed there. */ | ||
| 1522 | post: { | ||
| 1523 | parameters: { | ||
| 1524 | query?: never; | ||
| 1525 | header?: never; | ||
| 1526 | path?: never; | ||
| 1527 | cookie?: never; | ||
| 1528 | }; | ||
| 1529 | requestBody: { | ||
| 1530 | content: { | ||
| 1531 | "application/json": components["schemas"]["CreateVolumeClaimRequest"]; | ||
| 1532 | }; | ||
| 1533 | }; | ||
| 1534 | responses: { | ||
| 1535 | /** @description success */ | ||
| 1536 | 201: { | ||
| 1537 | headers: { | ||
| 1538 | [name: string]: unknown; | ||
| 1539 | }; | ||
| 1540 | content: { | ||
| 1541 | "application/json": components["schemas"]["VolumeClaim"]; | ||
| 1542 | }; | ||
| 1543 | }; | ||
| 1544 | /** @description error (plain text) */ | ||
| 1545 | default: { | ||
| 1546 | headers: { | ||
| 1547 | [name: string]: unknown; | ||
| 1548 | }; | ||
| 1549 | content: { | ||
| 1550 | "text/plain": string; | ||
| 1551 | }; | ||
| 1552 | }; | ||
| 1553 | }; | ||
| 1554 | }; | ||
| 1555 | delete?: never; | ||
| 1556 | options?: never; | ||
| 1557 | head?: never; | ||
| 1558 | patch?: never; | ||
| 1559 | trace?: never; | ||
| 1560 | }; | ||
| 1561 | "/api/v1/volume-claims/{id}": { | ||
| 1562 | parameters: { | ||
| 1563 | query?: never; | ||
| 1564 | header?: never; | ||
| 1565 | path?: never; | ||
| 1566 | cookie?: never; | ||
| 1567 | }; | ||
| 1568 | /** One claim. */ | ||
| 1569 | get: { | ||
| 1570 | parameters: { | ||
| 1571 | query?: never; | ||
| 1572 | header?: never; | ||
| 1573 | path: { | ||
| 1574 | id: string; | ||
| 1575 | }; | ||
| 1576 | cookie?: never; | ||
| 1577 | }; | ||
| 1578 | requestBody?: never; | ||
| 1579 | responses: { | ||
| 1580 | /** @description success */ | ||
| 1581 | 200: { | ||
| 1582 | headers: { | ||
| 1583 | [name: string]: unknown; | ||
| 1584 | }; | ||
| 1585 | content: { | ||
| 1586 | "application/json": components["schemas"]["VolumeClaim"]; | ||
| 1587 | }; | ||
| 1588 | }; | ||
| 1589 | /** @description error (plain text) */ | ||
| 1590 | default: { | ||
| 1591 | headers: { | ||
| 1592 | [name: string]: unknown; | ||
| 1593 | }; | ||
| 1594 | content: { | ||
| 1595 | "text/plain": string; | ||
| 1596 | }; | ||
| 1597 | }; | ||
| 1598 | }; | ||
| 1599 | }; | ||
| 1600 | put?: never; | ||
| 1601 | post?: never; | ||
| 1602 | /** Delete a claim and the data behind it. Refused (409) while a VM holds it. */ | ||
| 1603 | delete: { | ||
| 1604 | parameters: { | ||
| 1605 | query?: never; | ||
| 1606 | header?: never; | ||
| 1607 | path: { | ||
| 1608 | id: string; | ||
| 1609 | }; | ||
| 1610 | cookie?: never; | ||
| 1611 | }; | ||
| 1612 | requestBody?: never; | ||
| 1613 | responses: { | ||
| 1614 | /** @description success */ | ||
| 1615 | 204: { | ||
| 1616 | headers: { | ||
| 1617 | [name: string]: unknown; | ||
| 1618 | }; | ||
| 1619 | content?: never; | ||
| 1620 | }; | ||
| 1621 | /** @description error (plain text) */ | ||
| 1622 | default: { | ||
| 1623 | headers: { | ||
| 1624 | [name: string]: unknown; | ||
| 1625 | }; | ||
| 1626 | content: { | ||
| 1627 | "text/plain": string; | ||
| 1628 | }; | ||
| 1629 | }; | ||
| 1630 | }; | ||
| 1631 | }; | ||
| 1632 | options?: never; | ||
| 1633 | head?: never; | ||
| 1634 | patch?: never; | ||
| 1635 | trace?: never; | ||
| 1636 | }; | ||
| 1483 | } | 1637 | } |
| 1484 | export type webhooks = Record<string, never>; | 1638 | export type webhooks = Record<string, never>; |
| 1485 | export interface components { | 1639 | export interface components { |
| @@ -1530,11 +1684,16 @@ export interface components { | |||
| 1530 | power_state?: string; | 1684 | power_state?: string; |
| 1531 | ssh_authorized_key?: string; | 1685 | ssh_authorized_key?: string; |
| 1532 | vcpus?: number; | 1686 | vcpus?: number; |
| 1687 | volume_claims?: string[]; | ||
| 1533 | }; | 1688 | }; |
| 1534 | CreateVMResponse: { | 1689 | CreateVMResponse: { |
| 1535 | id: string; | 1690 | id: string; |
| 1536 | name: string; | 1691 | name: string; |
| 1537 | }; | 1692 | }; |
| 1693 | CreateVolumeClaimRequest: { | ||
| 1694 | name?: string; | ||
| 1695 | size_gb?: number; | ||
| 1696 | }; | ||
| 1538 | Delegation: { | 1697 | Delegation: { |
| 1539 | ca_fingerprint: string; | 1698 | ca_fingerprint: string; |
| 1540 | expires_at: string; | 1699 | expires_at: string; |
| @@ -1711,6 +1870,17 @@ export interface components { | |||
| 1711 | trusted_cas?: components["schemas"]["TrustedCA"][] | null; | 1870 | trusted_cas?: components["schemas"]["TrustedCA"][] | null; |
| 1712 | vcpus: number; | 1871 | vcpus: number; |
| 1713 | }; | 1872 | }; |
| 1873 | VolumeClaim: { | ||
| 1874 | /** Format: date-time */ | ||
| 1875 | created_at: string; | ||
| 1876 | host_id: string; | ||
| 1877 | id: string; | ||
| 1878 | name: string; | ||
| 1879 | present?: boolean | null; | ||
| 1880 | size_gb: number; | ||
| 1881 | status: string; | ||
| 1882 | vm_id: string; | ||
| 1883 | }; | ||
| 1714 | }; | 1884 | }; |
| 1715 | responses: never; | 1885 | responses: never; |
| 1716 | parameters: never; | 1886 | parameters: never; |