70f0a1d0
feat(api): every VM is persistent
a73x 2026-08-10 06:55
Commit message
docs/assumptions.md
| Old | New | ||
|---|---|---|---|
| @@ -29,14 +29,14 @@ with the same addresses. Underpins rolling agents to a live fleet at all. | |||
| 29 | **Proven** 2026-08-01 on bare metal: a guest created by the old agent survived | 29 | **Proven** 2026-08-01 on bare metal: a guest created by the old agent survived |
| 30 | the swap, kept its address, and stayed reachable through the gate. | 30 | the swap, kept its address, and stayed reachable through the gate. |
| 31 | 31 | ||
| 32 | ### A host reboot restores persistent guests, with their addresses | 32 | ### A host reboot restores guests, with their addresses |
| 33 | 33 | ||
| 34 | The boot identifier changes, every guest is detected lost, and persistent ones | 34 | The boot identifier changes, every guest is detected lost, and every one of them |
| 35 | are restarted while ephemeral ones are failed and never resurrected. Underpins | 35 | is booted again—the restart policy is not a per-VM choice. Underpins running |
| 36 | running anything you care about on a machine that reboots. | 36 | anything you care about on a machine that reboots. |
| 37 | **Proven** 2026-08-01 on bare metal: a host rebooted, and within a minute of | 37 | **Proven** 2026-08-01 on bare metal: a host rebooted, and within a minute of |
| 38 | boot a persistent guest was running again on the same address it held before, | 38 | boot a guest was running again on the same address it held before, its disk |
| 39 | its disk intact—while an ephemeral guest on the same host was failed by design. | 39 | intact. |
| 40 | Re-proven 2026-08-02 on the nested gate host after pidfiles became boot-scoped: | 40 | Re-proven 2026-08-02 on the nested gate host after pidfiles became boot-scoped: |
| 41 | same address, same disk, and the agent correctly read the pre-reboot pidfile as | 41 | same address, same disk, and the agent correctly read the pre-reboot pidfile as |
| 42 | naming nothing of its own. | 42 | naming nothing of its own. |
docs/mcp.md
| Old | New | ||
|---|---|---|---|
| @@ -16,7 +16,7 @@ Both serve the same tools. | |||
| 16 | 16 | ||
| 17 | | Tool | Purpose | | 17 | | Tool | Purpose | |
| 18 | |---|---| | 18 | |---|---| |
| 19 | | `vm_create` | Create a persistent VM; by default waits for `lifecycle=ready` + IP, then for guest SSH and `cloud-init status --wait` to finish. | | 19 | | `vm_create` | Create a VM; by default waits for `lifecycle=ready` + IP, then for guest SSH and `cloud-init status --wait` to finish. | |
| 20 | | `vm_list` | List all VMs on the fleet (id, name, lifecycle, IP, size). | | 20 | | `vm_list` | List all VMs on the fleet (id, name, lifecycle, IP, size). | |
| 21 | | `vm_info` | Show one VM's state plus a ready-to-use `ssh` command. | | 21 | | `vm_info` | Show one VM's state plus a ready-to-use `ssh` command. | |
| 22 | | `vm_exec` | Run a shell command in a VM over SSH; returns stdout, stderr, exit code. | | 22 | | `vm_exec` | Run a shell command in a VM over SSH; returns stdout, stderr, exit code. | |
| @@ -166,8 +166,9 @@ result or error. | |||
| 166 | 166 | ||
| 167 | ## Semantics | 167 | ## Semantics |
| 168 | 168 | ||
| 169 | - Every VM is created with `persistent: true`. There is **no TTL and no | 169 | - Every VM is persistent: a guest lost to a host reboot or a dead hypervisor |
| 170 | reaper**—VMs live until something explicitly destroys them. `vm_destroy` | 170 | is booted again. There is **no TTL and no reaper**—VMs live until something |
| 171 | explicitly destroys them. `vm_destroy` | ||
| 171 | (exact id or name, no wildcards, no bulk) is the only kill path, and Claude | 172 | (exact id or name, no wildcards, no bulk) is the only kill path, and Claude |
| 172 | is instructed to treat it as explicit-only, never automatic cleanup. | 173 | is instructed to treat it as explicit-only, never automatic cleanup. |
| 173 | - The tool surface has no host or fleet operations by design—see the tools | 174 | - The tool surface has no host or fleet operations by design—see the tools |
docs/openapi.json
| Old | New | ||
|---|---|---|---|
| @@ -140,9 +140,6 @@ | |||
| 140 | "name": { | 140 | "name": { |
| 141 | "type": "string" | 141 | "type": "string" |
| 142 | }, | 142 | }, |
| 143 | "persistent": { | ||
| 144 | "type": "boolean" | ||
| 145 | }, | ||
| 146 | "power_state": { | 143 | "power_state": { |
| 147 | "type": "string" | 144 | "type": "string" |
| 148 | }, | 145 | }, |
internal/mcpserver/api_test.go
| Old | New | ||
|---|---|---|---|
| @@ -44,14 +44,14 @@ func TestCreateVMSendsRequestAndParsesID(t *testing.T) { | |||
| 44 | assert.Equal(t, "/api/v1/vms", r.URL.Path) | 44 | assert.Equal(t, "/api/v1/vms", r.URL.Path) |
| 45 | var req map[string]any | 45 | var req map[string]any |
| 46 | require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) | 46 | require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) |
| 47 | assert.Equal(t, true, req["persistent"]) | 47 | assert.NotContains(t, req, "persistent", "the retired field must not go on the wire — the server refuses a body carrying it") |
| 48 | assert.Equal(t, "h1", req["host_id"]) | 48 | assert.Equal(t, "h1", req["host_id"]) |
| 49 | assert.Equal(t, "ssh-ed25519 AAA test", req["ssh_authorized_key"]) | 49 | assert.Equal(t, "ssh-ed25519 AAA test", req["ssh_authorized_key"]) |
| 50 | json.NewEncoder(w).Encode(map[string]string{"id": "new1", "name": req["name"].(string)}) | 50 | json.NewEncoder(w).Encode(map[string]string{"id": "new1", "name": req["name"].(string)}) |
| 51 | }) | 51 | }) |
| 52 | got, err := c.CreateVM(t.Context(), client.CreateVMRequest{ | 52 | got, err := c.CreateVM(t.Context(), client.CreateVMRequest{ |
| 53 | HostID: "h1", Name: "claude-abc123", VCPUs: 2, MemMB: 2048, DiskGB: 20, | 53 | HostID: "h1", Name: "claude-abc123", VCPUs: 2, MemMB: 2048, DiskGB: 20, |
| 54 | SSHAuthorizedKey: "ssh-ed25519 AAA test", Persistent: true, | 54 | SSHAuthorizedKey: "ssh-ed25519 AAA test", |
| 55 | }) | 55 | }) |
| 56 | require.NoError(t, err) | 56 | require.NoError(t, err) |
| 57 | assert.Equal(t, "new1", got.ID) | 57 | assert.Equal(t, "new1", got.ID) |
internal/mcpserver/server.go
| Old | New | ||
|---|---|---|---|
| @@ -54,7 +54,7 @@ type DelegateCompleteIn struct { | |||
| 54 | func NewServer(t *Tools, opts Options) *mcp.Server { | 54 | func NewServer(t *Tools, opts Options) *mcp.Server { |
| 55 | s := mcp.NewServer(&mcp.Implementation{Name: "eitri", Version: "0.1.0"}, | 55 | s := mcp.NewServer(&mcp.Implementation{Name: "eitri", Version: "0.1.0"}, |
| 56 | &mcp.ServerOptions{SchemaCache: opts.SchemaCache}) | 56 | &mcp.ServerOptions{SchemaCache: opts.SchemaCache}) |
| 57 | register(s, "vm_create", "Create an eitri VM (persistent). Waits for ready+cloud-init by default.", t.VMCreate) | 57 | register(s, "vm_create", "Create an eitri VM. Waits for ready+cloud-init by default.", t.VMCreate) |
| 58 | register(s, "vm_list", "List all VMs on the eitri fleet.", t.VMList) | 58 | register(s, "vm_list", "List all VMs on the eitri fleet.", t.VMList) |
| 59 | register(s, "vm_info", "Show one VM's state and how to reach it.", t.VMInfo) | 59 | register(s, "vm_info", "Show one VM's state and how to reach it.", t.VMInfo) |
| 60 | register(s, "vm_exec", "Run a shell command in a VM over SSH; returns stdout/stderr/exit code.", t.VMExec) | 60 | register(s, "vm_exec", "Run a shell command in a VM over SSH; returns stdout/stderr/exit code.", t.VMExec) |
internal/mcpserver/tools.go
| Old | New | ||
|---|---|---|---|
| @@ -157,12 +157,11 @@ type VMCreateOut struct { | |||
| 157 | 157 | ||
| 158 | func (t *Tools) VMCreate(ctx context.Context, in VMCreateIn) (VMCreateOut, error) { | 158 | func (t *Tools) VMCreate(ctx context.Context, in VMCreateIn) (VMCreateOut, error) { |
| 159 | req := client.CreateVMRequest{ | 159 | req := client.CreateVMRequest{ |
| 160 | Name: in.Name, | 160 | Name: in.Name, |
| 161 | CloudInit: in.CloudInit, | 161 | CloudInit: in.CloudInit, |
| 162 | VCPUs: in.VCPUs, | 162 | VCPUs: in.VCPUs, |
| 163 | MemMB: in.MemMB, | 163 | MemMB: in.MemMB, |
| 164 | DiskGB: in.DiskGB, | 164 | DiskGB: in.DiskGB, |
| 165 | Persistent: true, // spec: long-lived VMs are first-class; no reaper | ||
| 166 | } | 165 | } |
| 167 | if req.Name == "" { | 166 | if req.Name == "" { |
| 168 | req.Name = "claude-" + random.Hex(3) | 167 | req.Name = "claude-" + random.Hex(3) |
internal/mcpserver/tools_test.go
| Old | New | ||
|---|---|---|---|
| @@ -205,7 +205,6 @@ func TestCreateWaitsForReadyAndCloudInit(t *testing.T) { | |||
| 205 | 205 | ||
| 206 | require.Len(t, api.created, 1) | 206 | require.Len(t, api.created, 1) |
| 207 | req := api.created[0] | 207 | req := api.created[0] |
| 208 | assert.True(t, req.Persistent, "spec: persistent always true") | ||
| 209 | assert.Equal(t, int64(2), req.VCPUs) | 208 | assert.Equal(t, int64(2), req.VCPUs) |
| 210 | assert.Equal(t, int64(2048), req.MemMB) | 209 | assert.Equal(t, int64(2048), req.MemMB) |
| 211 | assert.Equal(t, int64(20), req.DiskGB) | 210 | assert.Equal(t, int64(20), req.DiskGB) |
internal/server/api/api.go
| Old | New | ||
|---|---|---|---|
| @@ -730,11 +730,28 @@ func validateCreateVM(req *types.CreateVMRequest) (string, int) { | |||
| 730 | return "", 0 | 730 | return "", 0 |
| 731 | } | 731 | } |
| 732 | 732 | ||
| 733 | // createVMBody is what a create decodes into: the contract request, plus the | ||
| 734 | // one field the contract dropped. decodeJSON is lenient everywhere — no | ||
| 735 | // endpoint sets DisallowUnknownFields, so an unrecognized key is ignored — and | ||
| 736 | // under that discipline a body carrying "persistent": false would be accepted | ||
| 737 | // and then contradicted, the VM created with the opposite policy to the one it | ||
| 738 | // asked for. Naming the field here costs one 400 that says what changed, | ||
| 739 | // without making every other endpoint strict about keys it has always taken. | ||
| 740 | type createVMBody struct { | ||
| 741 | types.CreateVMRequest | ||
| 742 | Persistent *bool `json:"persistent"` | ||
| 743 | } | ||
| 744 | |||
| 733 | func (a *API) handleCreateVM(w http.ResponseWriter, r *http.Request) { | 745 | func (a *API) handleCreateVM(w http.ResponseWriter, r *http.Request) { |
| 734 | var req types.CreateVMRequest | 746 | var body createVMBody |
| 735 | if !decodeJSON(w, r, &req) { | 747 | if !decodeJSON(w, r, &body) { |
| 748 | return | ||
| 749 | } | ||
| 750 | if body.Persistent != nil { | ||
| 751 | http.Error(w, "every VM is persistent now; drop the persistent field", http.StatusBadRequest) | ||
| 736 | return | 752 | return |
| 737 | } | 753 | } |
| 754 | req := body.CreateVMRequest | ||
| 738 | if req.HostID == "" { | 755 | if req.HostID == "" { |
| 739 | http.Error(w, "host_id required", http.StatusBadRequest) | 756 | http.Error(w, "host_id required", http.StatusBadRequest) |
| 740 | return | 757 | return |
| @@ -853,8 +870,10 @@ func (a *API) handleCreateVM(w http.ResponseWriter, r *http.Request) { | |||
| 853 | VCPUs: req.VCPUs, | 870 | VCPUs: req.VCPUs, |
| 854 | MemMB: req.MemMB, | 871 | MemMB: req.MemMB, |
| 855 | DiskGB: req.DiskGB, | 872 | DiskGB: req.DiskGB, |
| 856 | Persistent: req.Persistent, | 873 | // A lost guest is booted again, always: the restart policy is not a |
| 857 | PowerState: req.PowerState, | 874 | // choice a create gets to make, and the agent reads this field to decide. |
| 875 | Persistent: true, | ||
| 876 | PowerState: req.PowerState, | ||
| 858 | } | 877 | } |
| 859 | 878 | ||
| 860 | // The row carries no host key. A guest's host key is generated by the host | 879 | // The row carries no host key. A guest's host key is generated by the host |
internal/server/api/api_test.go
| Old | New | ||
|---|---|---|---|
| @@ -504,7 +504,7 @@ func TestOneClickCreateFillsDefaultsAndPokesHub(t *testing.T) { | |||
| 504 | assert.Equal(t, int64(2), vms[0].VCPUs) | 504 | assert.Equal(t, int64(2), vms[0].VCPUs) |
| 505 | assert.Equal(t, int64(2048), vms[0].MemMB) | 505 | assert.Equal(t, int64(2048), vms[0].MemMB) |
| 506 | assert.Equal(t, int64(10), vms[0].DiskGB) | 506 | assert.Equal(t, int64(10), vms[0].DiskGB) |
| 507 | assert.False(t, vms[0].Persistent, "one-click default is ephemeral") | 507 | assert.True(t, vms[0].Persistent, "every VM is persistent: a lost guest is booted again") |
| 508 | assert.Equal(t, "running", vms[0].PowerState) | 508 | assert.Equal(t, "running", vms[0].PowerState) |
| 509 | assert.NotEmpty(t, vms[0].Name) | 509 | assert.NotEmpty(t, vms[0].Name) |
| 510 | assert.Contains(t, vms[0].ImageURL, "ubuntu") | 510 | assert.Contains(t, vms[0].ImageURL, "ubuntu") |
| @@ -515,6 +515,28 @@ func TestOneClickCreateFillsDefaultsAndPokesHub(t *testing.T) { | |||
| 515 | } | 515 | } |
| 516 | } | 516 | } |
| 517 | 517 | ||
| 518 | // TestCreateVMRefusesTheRetiredPersistentField pins the one thing a client | ||
| 519 | // carrying the old field must not get: silence. Persistence is no longer a | ||
| 520 | // choice, so a body still asking for one is refused with the sentence that says | ||
| 521 | // so — accepting "persistent": false and then creating a persistent VM anyway | ||
| 522 | // would be the API agreeing to something it does not do. Both values are | ||
| 523 | // refused: sending true is just as stale as sending false. | ||
| 524 | func TestCreateVMRefusesTheRetiredPersistentField(t *testing.T) { | ||
| 525 | ts, st, _ := testServer(t) | ||
| 526 | out := enroll(t, ts) | ||
| 527 | |||
| 528 | for _, want := range []bool{false, true} { | ||
| 529 | resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT, | ||
| 530 | map[string]any{"host_id": out["host_id"], "persistent": want}) | ||
| 531 | require.Equal(t, 400, resp.StatusCode) | ||
| 532 | body, _ := io.ReadAll(resp.Body) | ||
| 533 | assert.Contains(t, string(body), "every VM is persistent now; drop the persistent field") | ||
| 534 | } | ||
| 535 | |||
| 536 | vms, _ := st.ListVMs() | ||
| 537 | assert.Empty(t, vms, "a refused create must not have made a VM") | ||
| 538 | } | ||
| 539 | |||
| 518 | // TestCreateVMRefusesAHostThatCannotCertifyItsGuest pins the create-time half | 540 | // TestCreateVMRefusesAHostThatCannotCertifyItsGuest pins the create-time half |
| 519 | // of the certified-host-key story. A guest whose host key nothing signed cannot | 541 | // of the certified-host-key story. A guest whose host key nothing signed cannot |
| 520 | // be verified and is unreachable through the gate for the rest of its life, so | 542 | // be verified and is unreachable through the gate for the rest of its life, so |
internal/server/api/testdata/create-vm-request.golden.json
| Old | New | ||
|---|---|---|---|
| @@ -8,6 +8,5 @@ | |||
| 8 | "power_state": "running", | 8 | "power_state": "running", |
| 9 | "vcpus": 4, | 9 | "vcpus": 4, |
| 10 | "mem_mb": 4096, | 10 | "mem_mb": 4096, |
| 11 | "disk_gb": 20, | 11 | "disk_gb": 20 |
| 12 | "persistent": true | ||
| 13 | } | 12 | } |
internal/server/api/types/types.go
| Old | New | ||
|---|---|---|---|
| @@ -88,6 +88,10 @@ type Host struct { | |||
| 88 | // GET /api/v1/vms and the SSE snapshot. Write-only fields — image_sha256, | 88 | // GET /api/v1/vms and the SSE snapshot. Write-only fields — image_sha256, |
| 89 | // cloud_init, ssh_authorized_key — are deliberately excluded. Every field is | 89 | // cloud_init, ssh_authorized_key — are deliberately excluded. Every field is |
| 90 | // spelled out — no struct embedding. | 90 | // spelled out — no struct embedding. |
| 91 | // | ||
| 92 | // persistent is true for every VM — a guest lost to a host reboot or a dead | ||
| 93 | // hypervisor is always booted again. It is reported because clients read it, | ||
| 94 | // not because it varies. | ||
| 91 | type VM struct { | 95 | type VM struct { |
| 92 | ID string `json:"id"` | 96 | ID string `json:"id"` |
| 93 | HostID string `json:"host_id"` | 97 | HostID string `json:"host_id"` |
| @@ -186,6 +190,11 @@ type EnrollRequest struct { | |||
| 186 | // CreateVMRequest is the POST /api/v1/vms body. Every field except host_id is | 190 | // CreateVMRequest is the POST /api/v1/vms body. Every field except host_id is |
| 187 | // optional: the server fills one-click defaults (name, image pair, sizes, | 191 | // optional: the server fills one-click defaults (name, image pair, sizes, |
| 188 | // power state) before validating. | 192 | // power state) before validating. |
| 193 | // | ||
| 194 | // There is no persistence knob. `persistent` is a restart policy — whether a | ||
| 195 | // guest lost to a host reboot or a dead hypervisor is booted again — and every | ||
| 196 | // VM gets the answer that keeps it alive. A body that still sets the field is | ||
| 197 | // refused rather than quietly upgraded (see api.createVMBody). | ||
| 189 | type CreateVMRequest struct { | 198 | type CreateVMRequest struct { |
| 190 | HostID string `json:"host_id"` | 199 | HostID string `json:"host_id"` |
| 191 | Name string `json:"name"` | 200 | Name string `json:"name"` |
| @@ -197,7 +206,6 @@ type CreateVMRequest struct { | |||
| 197 | VCPUs int64 `json:"vcpus"` | 206 | VCPUs int64 `json:"vcpus"` |
| 198 | MemMB int64 `json:"mem_mb"` | 207 | MemMB int64 `json:"mem_mb"` |
| 199 | DiskGB int64 `json:"disk_gb"` | 208 | DiskGB int64 `json:"disk_gb"` |
| 200 | Persistent bool `json:"persistent"` | ||
| 201 | } | 209 | } |
| 202 | 210 | ||
| 203 | // PatchVMRequest is the PATCH /api/v1/vms/{id} body: the desired power state, | 211 | // PatchVMRequest is the PATCH /api/v1/vms/{id} body: the desired power state, |
internal/server/api/wire_golden_test.go
| Old | New | ||
|---|---|---|---|
| @@ -159,7 +159,6 @@ func TestWireGolden(t *testing.T) { | |||
| 159 | VCPUs: 4, | 159 | VCPUs: 4, |
| 160 | MemMB: 4096, | 160 | MemMB: 4096, |
| 161 | DiskGB: 20, | 161 | DiskGB: 20, |
| 162 | Persistent: true, | ||
| 163 | }) | 162 | }) |
| 164 | 163 | ||
| 165 | goldenCheck(t, "patch-vm-request", types.PatchVMRequest{ | 164 | goldenCheck(t, "patch-vm-request", types.PatchVMRequest{ |
internal/server/store/store.go
| Old | New | ||
|---|---|---|---|
| @@ -156,7 +156,8 @@ CREATE TABLE IF NOT EXISTS vms ( | |||
| 156 | vcpus INTEGER NOT NULL, | 156 | vcpus INTEGER NOT NULL, |
| 157 | mem_mb INTEGER NOT NULL, | 157 | mem_mb INTEGER NOT NULL, |
| 158 | disk_gb INTEGER NOT NULL, | 158 | disk_gb INTEGER NOT NULL, |
| 159 | persistent INTEGER NOT NULL DEFAULT 0, | 159 | -- Restart policy, and the only one there is: a lost guest is booted again. |
| 160 | persistent INTEGER NOT NULL DEFAULT 1, | ||
| 160 | power_state TEXT NOT NULL, | 161 | power_state TEXT NOT NULL, |
| 161 | status TEXT NOT NULL DEFAULT 'pending', | 162 | status TEXT NOT NULL DEFAULT 'pending', |
| 162 | last_error TEXT NOT NULL DEFAULT '', | 163 | last_error TEXT NOT NULL DEFAULT '', |
| @@ -354,6 +355,16 @@ func Open(path, cidrPool string) (*Store, error) { | |||
| 354 | return nil, err | 355 | return nil, err |
| 355 | } | 356 | } |
| 356 | 357 | ||
| 358 | // Every VM is persistent: a guest lost to a host reboot or a dead hypervisor | ||
| 359 | // is booted again. Rows written when the policy was a create-time choice can | ||
| 360 | // still say otherwise, and a false one is a guest its agent would mark failed | ||
| 361 | // forever the first time its host went down. Backfill them — the fleet's | ||
| 362 | // existing VMs get the same promise a new one does. | ||
| 363 | if _, err := db.Exec(`UPDATE vms SET persistent = 1 WHERE persistent = 0`); err != nil { | ||
| 364 | db.Close() | ||
| 365 | return nil, fmt.Errorf("backfill vms.persistent: %w", err) | ||
| 366 | } | ||
| 367 | |||
| 357 | // One identity binds at most one tenant (per issuer). Partial index so | 368 | // One identity binds at most one tenant (per issuer). Partial index so |
| 358 | // unbound rows (empty issuer+subject) don't collide. | 369 | // unbound rows (empty issuer+subject) don't collide. |
| 359 | if _, err := db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS tenants_identity | 370 | if _, err := db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS tenants_identity |
internal/server/store/store_test.go
| Old | New | ||
|---|---|---|---|
| @@ -198,6 +198,41 @@ func TestOpenDropsTheEscrowedHostKeyColumn(t *testing.T) { | |||
| 198 | assert.Equal(t, "cert-line", vm.SSHHostCert) | 198 | assert.Equal(t, "cert-line", vm.SSHHostCert) |
| 199 | } | 199 | } |
| 200 | 200 | ||
| 201 | // TestOpenMakesEveryExistingVMPersistent is the upgrade for a fleet created | ||
| 202 | // when persistence was a create-time choice. A row that says otherwise is a | ||
| 203 | // guest its host would mark failed forever the first time it went down, so | ||
| 204 | // every one of them is flipped — including the deleted rows, which can be | ||
| 205 | // restored back into service. | ||
| 206 | func TestOpenMakesEveryExistingVMPersistent(t *testing.T) { | ||
| 207 | path := t.TempDir() + "/eitri.db" | ||
| 208 | s, err := Open(path, "10.77.0.0/16") | ||
| 209 | require.NoError(t, err) | ||
| 210 | _, err = s.CreateTenantForIdentity("https://test-issuer", "test-subject", testTenant+"@test.local") | ||
| 211 | require.NoError(t, err) | ||
| 212 | h := enrollHost(t, s) | ||
| 213 | for _, name := range []string{"inherited", "deleted"} { | ||
| 214 | require.NoError(t, s.CreateVM(VM{ | ||
| 215 | ID: name, HostID: h.ID, Name: name, ImageURL: "u", ImageSHA256: "abc", | ||
| 216 | VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running", | ||
| 217 | })) | ||
| 218 | } | ||
| 219 | require.NoError(t, s.TombstoneVM("deleted")) | ||
| 220 | // The shape an earlier release left: rows carrying the zero value nobody chose. | ||
| 221 | _, err = s.db.Exec(`UPDATE vms SET persistent = 0`) | ||
| 222 | require.NoError(t, err) | ||
| 223 | require.NoError(t, s.Close()) | ||
| 224 | |||
| 225 | up, err := Open(path, "10.77.0.0/16") | ||
| 226 | require.NoError(t, err) | ||
| 227 | t.Cleanup(func() { up.Close() }) | ||
| 228 | |||
| 229 | for _, id := range []string{"inherited", "deleted"} { | ||
| 230 | vm, err := up.GetVM(id) | ||
| 231 | require.NoError(t, err) | ||
| 232 | assert.True(t, vm.Persistent, "%s must have been made persistent", id) | ||
| 233 | } | ||
| 234 | } | ||
| 235 | |||
| 201 | func TestOpenReplacesTheProtocolBlindHostPortIndex(t *testing.T) { | 236 | func TestOpenReplacesTheProtocolBlindHostPortIndex(t *testing.T) { |
| 202 | path := t.TempDir() + "/eitri.db" | 237 | path := t.TempDir() + "/eitri.db" |
| 203 | s, err := Open(path, "10.77.0.0/16") | 238 | s, err := Open(path, "10.77.0.0/16") |
web/src/lib/api-types.ts
| Old | New | ||
|---|---|---|---|
| @@ -1526,7 +1526,6 @@ export interface components { | |||
| 1526 | image_url?: string; | 1526 | image_url?: string; |
| 1527 | mem_mb?: number; | 1527 | mem_mb?: number; |
| 1528 | name?: string; | 1528 | name?: string; |
| 1529 | persistent?: boolean; | ||
| 1530 | power_state?: string; | 1529 | power_state?: string; |
| 1531 | ssh_authorized_key?: string; | 1530 | ssh_authorized_key?: string; |
| 1532 | vcpus?: number; | 1531 | vcpus?: number; |
web/src/lib/fleet.svelte.ts
| Old | New | ||
|---|---|---|---|
| @@ -418,10 +418,11 @@ export function vmStatus(vm: VM): string { | |||
| 418 | * | 418 | * |
| 419 | * Only a VM the plane has settled — `ready` or `stopped` — has a power flip | 419 | * Only a VM the plane has settled — `ready` or `stopped` — has a power flip |
| 420 | * that means anything. `creating` and `deleting` are mid-flight, and the plane | 420 | * that means anything. `creating` and `deleting` are mid-flight, and the plane |
| 421 | * is already driving them somewhere. `failed` cannot be started: an ephemeral | 421 | * is already driving them somewhere. `failed` cannot be started: it is the |
| 422 | * guest that died is never restarted, and a create the host's budget refused | 422 | * state a host reports when it has given up on a guest, and a create the |
| 423 | * reconsiders only on a spec change, which power is not. An unrecognized state | 423 | * host's budget refused reconsiders only on a spec change, which power is not. |
| 424 | * is one this console does not understand well enough to act on. | 424 | * An unrecognized state is one this console does not understand well enough to |
| 425 | * act on. | ||
| 425 | * | 426 | * |
| 426 | * For the two that do get a control, it reads the DESIRED power (power_state), | 427 | * For the two that do get a control, it reads the DESIRED power (power_state), |
| 427 | * not the observed one: the control sets desired state, so a VM already on its | 428 | * not the observed one: the control sets desired state, so a VM already on its |
web/src/routes/+page.svelte
| Old | New | ||
|---|---|---|---|
| @@ -361,7 +361,6 @@ | |||
| 361 | >cloud-init<textarea bind:value={form.cloud_init} rows="3" placeholder="#cloud-config …" | 361 | >cloud-init<textarea bind:value={form.cloud_init} rows="3" placeholder="#cloud-config …" |
| 362 | ></textarea></label | 362 | ></textarea></label |
| 363 | > | 363 | > |
| 364 | <label class="checkbox"><input type="checkbox" bind:checked={form.persistent} /> Persistent</label> | ||
| 365 | {/if} | 364 | {/if} |
| 366 | 365 | ||
| 367 | <div class="row end"> | 366 | <div class="row end"> |
web/src/routes/vms/[id]/+page.svelte
| Old | New | ||
|---|---|---|---|
| @@ -209,7 +209,6 @@ | |||
| 209 | <tr><th>Power</th><td>{vmPower(vm)} (desired: {vm.power_state})</td></tr> | 209 | <tr><th>Power</th><td>{vmPower(vm)} (desired: {vm.power_state})</td></tr> |
| 210 | <tr><th>IP</th><td>{vmIP(vm)} <span class="hint">(host bridge, NAT—not reachable off-host)</span></td></tr> | 210 | <tr><th>IP</th><td>{vmIP(vm)} <span class="hint">(host bridge, NAT—not reachable off-host)</span></td></tr> |
| 211 | <tr><th>Resources</th><td>{vm.vcpus}c / {vm.mem_mb}MB / {vm.disk_gb}GB</td></tr> | 211 | <tr><th>Resources</th><td>{vm.vcpus}c / {vm.mem_mb}MB / {vm.disk_gb}GB</td></tr> |
| 212 | <tr><th>Persistent</th><td>{vm.persistent}</td></tr> | ||
| 213 | <tr><th>Image</th><td class="wrap">{vm.image_url}</td></tr> | 212 | <tr><th>Image</th><td class="wrap">{vm.image_url}</td></tr> |
| 214 | <tr> | 213 | <tr> |
| 215 | <th>Injected key</th> | 214 | <th>Injected key</th> |