a73x

70f0a1d0

feat(api): every VM is persistent

a73x   2026-08-10 06:55

Commit message
feat(api): every VM is persistent

`persistent` is a restart policy: a guest lost to a host reboot or a dead
hypervisor is booted again if it is set, and marked failed forever if it is
not. It was the zero value of a create body and an unchecked box in the console
dialog, so the casual path — one click, no fields — produced VMs that died with
their host. Nobody chose that; they inherited it. The MCP path already pinned
the field true for exactly this reason. So it stops being a choice: a create
takes no persistence field, and the server records every VM as persistent.

A body that still carries `persistent` is refused with the sentence that says
what changed, rather than accepted and contradicted — a create asking for false
and getting a persistent VM would be the API agreeing to something it does not
do. The refusal is a decode-time field on the create body alone
(api.createVMBody), not DisallowUnknownFields: no endpoint here is strict about
unknown keys, and making the whole surface strict to catch one retired field
would break callers over keys the API has always tolerated. Existing rows are
backfilled on open, because a fleet upgrading into this must not keep the trap
in the VMs it already has; the schema's own default follows them. The VM
response still reports `persistent`, now always true — clients read it, and it
costs nothing to keep telling them.

The agent is untouched. It still reads the field off the wire and still has the
branch that fails an ephemeral guest; the server simply never sends false
again, so the branch goes quiet on its own. That is what makes the upgrade safe
in both directions — an old agent against a new server sees the policy it
already understands, and a new server against an old agent sends a value that
agent has always accepted. The column, the proto field and the dead branch go
together in a later release, when no supported agent needs them.

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>