a73x

f986d9b8

feat(fleet): persistence is not a column

a73x   2026-08-11 10:24

Commit message
feat(fleet): persistence is not a column

Every VM is persistent — the previous release made it so, refused a create that
asked otherwise, and backfilled the rows that disagreed. What was left is a
column every row agrees on, a response field that never varies, and a branch in
the agent that cannot be reached. None of those are facts about a VM, so they
go: vms.persistent is dropped on Open, store.VM and types.VM lose the field, and
converge boots a lost guest without consulting a policy first.

The proto field stays, and this is the constraint that keeps it. A v0.0.5 agent
is fielded; it reads VMDesired.persistent as the restart policy it always was,
and proto3 hands an absent bool the value false. False means ephemeral, an
ephemeral guest found lost is marked failed forever and never booted again, and
every guest is found lost when its host reboots. A server that stopped sending
the field would strand every VM on the first host running an older agent to come
back up. So the snapshot sets it true unconditionally, from nowhere — there is
no stored value left to read, which is the point. It comes out in the first
release that cannot meet a v0.0.5 agent, and its number is reserved when it
does: the same fielded-version reasoning internal/server/release applies to the
pre-CSR floor. A test in syncsvc fails if someone finishes the job early.

The create-time refusal stays too. It is the API's memory of the decision, and
its sentence tells a caller still carrying the field what changed — worth more
than ignoring a key that used to mean something.

Dropping the ephemeral branch orphaned the only caller of Prov.FailureReason,
which existed so the one report a doomed guest produced would name a cause.
Booting is spawning a process, so a guest handed an image its host cannot
execute still starts cleanly and dies with its complaint in a log nobody reads.
That question is now asked in failConverge, where a converge that cannot get a
guest running is the report needing the answer.

docs/openapi.json
Old New
@@ -744,9 +744,6 @@
744 "name": { 744 "name": {
745 "type": "string" 745 "type": "string"
746 }, 746 },
747 "persistent": {
748 "type": "boolean"
749 },
750 "phase": { 747 "phase": {
751 "type": "string" 748 "type": "string"
752 }, 749 },
@@ -783,7 +780,6 @@
783 "lifecycle", 780 "lifecycle",
784 "mem_mb", 781 "mem_mb",
785 "name", 782 "name",
786 "persistent",
787 "phase", 783 "phase",
788 "power_state", 784 "power_state",
789 "status", 785 "status",
internal/agent/reconcile/reconcile.go
Old New
@@ -93,9 +93,9 @@ type Provisioner interface {
93 Running(vmID string) bool 93 Running(vmID string) bool
94 94
95 // FailureReason returns what the hypervisor said before it stopped running, 95 // FailureReason returns what the hypervisor said before it stopped running,
96 // or "" when the backend has nothing to add. It is asked only once a VM has 96 // or "" when the backend has nothing to add. It is asked when a guest that
97 // been found lost, to give that report a cause: the process table can say a 97 // should be running is not, to give that report a cause: the process table
98 // guest is gone but never why, and every backend already keeps its 98 // can say a guest is gone but never why, and every backend already keeps its
99 // hypervisor's own output on disk. 99 // hypervisor's own output on disk.
100 // 100 //
101 // Empty means "nothing to add", NOT "nothing went wrong" — a guest killed 101 // Empty means "nothing to add", NOT "nothing went wrong" — a guest killed
@@ -837,8 +837,16 @@ func (e *Engine) failCreate(ctx context.Context, rec state.Record, err error, re
837 // persist err on the record and report it stopped/failed. Unlike failCreate 837 // persist err on the record and report it stopped/failed. Unlike failCreate
838 // there is no create-attempt budget — a converge (already-created VM) failure 838 // there is no create-attempt budget — a converge (already-created VM) failure
839 // is terminal-for-this-tick and reported failed immediately. 839 // is terminal-for-this-tick and reported failed immediately.
840 //
841 // The report carries whatever the hypervisor said on its way out, because the
842 // error alone rarely names the cause: booting is spawning a process, so a guest
843 // handed an image its host cannot execute starts cleanly and dies with its
844 // complaint in a log file nobody reads.
840 func (e *Engine) failConverge(rec state.Record, err error, res *vmResult) { 845 func (e *Engine) failConverge(rec state.Record, err error, res *vmResult) {
841 rec.LastError = err.Error() 846 rec.LastError = err.Error()
847 if why := e.Prov.FailureReason(rec.Spec.VMID); why != "" {
848 rec.LastError += ": " + why
849 }
842 _ = e.St.SaveVM(rec) 850 _ = e.St.SaveVM(rec)
843 res.report(rec.Spec.VMID, rec.IP, "stopped", "failed", rec.LastError) 851 res.report(rec.Spec.VMID, rec.IP, "stopped", "failed", rec.LastError)
844 } 852 }
@@ -866,23 +874,8 @@ func (e *Engine) converge(ctx context.Context, d *pb.VMDesired, rec state.Record
866 lost := rec.BootID != bootID || (!running && !rec.StopRequested) 874 lost := rec.BootID != bootID || (!running && !rec.StopRequested)
867 875
868 if lost { 876 if lost {
869 if !d.Persistent { 877 // Every VM is persistent — a lost guest is booted again, and there is no
870 // Ephemeral lost VMs are reported failed and NEVER restarted. This 878 // longer a policy to consult before doing it.
871 // is the only report the operator gets, so it carries whatever the
872 // hypervisor said on its way out — a guest handed an image its host
873 // cannot execute dies here, and "ephemeral VM lost" alone names
874 // neither the image nor the architecture.
875 errMsg := "ephemeral VM lost"
876 if why := e.Prov.FailureReason(d.VmId); why != "" {
877 errMsg += ": " + why
878 }
879 rec.LastError = errMsg
880 _ = e.St.SaveVM(rec)
881 res.report(d.VmId, rec.IP, "stopped", "failed", errMsg)
882 return
883 }
884
885 // Persistent lost VM.
886 if d.PowerState == "running" { 879 if d.PowerState == "running" {
887 // Restart: the backend re-attaches the VM to the host network as 880 // Restart: the backend re-attaches the VM to the host network as
888 // part of Boot, which is what rebuilds a tap that did not survive 881 // part of Boot, which is what rebuilds a tap that did not survive
@@ -898,7 +891,7 @@ func (e *Engine) converge(ctx context.Context, d *pb.VMDesired, rec state.Record
898 _ = e.St.SaveVM(rec) 891 _ = e.St.SaveVM(rec)
899 res.report(d.VmId, rec.IP, "running", "ready", "") 892 res.report(d.VmId, rec.IP, "running", "ready", "")
900 } else { 893 } else {
901 // Persistent + desired stopped: update boot ID, mark stop recorded. 894 // Lost + desired stopped: update boot ID, mark stop recorded.
902 rec.BootID = bootID 895 rec.BootID = bootID
903 rec.StopRequested = true 896 rec.StopRequested = true
904 _ = e.St.SaveVM(rec) 897 _ = e.St.SaveVM(rec)
@@ -1045,6 +1038,5 @@ func specFromDesired(d *pb.VMDesired) state.VMSpec {
1045 VCPUs: d.Vcpus, 1038 VCPUs: d.Vcpus,
1046 MemMB: d.MemMb, 1039 MemMB: d.MemMb,
1047 DiskGB: d.DiskGb, 1040 DiskGB: d.DiskGb,
1048 Persistent: d.Persistent,
1049 } 1041 }
1050 } 1042 }
internal/agent/reconcile/reconcile_test.go
Old New
@@ -259,7 +259,6 @@ func vm(id string, opts ...func(*pb.VMDesired)) *pb.VMDesired {
259 259
260 func tombstoned(v *pb.VMDesired) *pb.VMDesired { v.Tombstoned = true; return v } 260 func tombstoned(v *pb.VMDesired) *pb.VMDesired { v.Tombstoned = true; return v }
261 func stopped(v *pb.VMDesired) { v.PowerState = "stopped" } 261 func stopped(v *pb.VMDesired) { v.PowerState = "stopped" }
262 func persistent(v *pb.VMDesired) { v.Persistent = true }
263 262
264 func findVM(rep *pb.ActualStateReport, id string) *pb.ActualVM { 263 func findVM(rep *pb.ActualStateReport, id string) *pb.ActualVM {
265 for _, v := range rep.Vms { 264 for _, v := range rep.Vms {
@@ -418,65 +417,62 @@ func TestUserStopIsStoppedNotLost(t *testing.T) {
418 assert.NotEqual(t, "failed", av.Phase, "recorded stop request: stopped != lost") 417 assert.NotEqual(t, "failed", av.Phase, "recorded stop request: stopped != lost")
419 } 418 }
420 419
421 // TestEphemeralLostCarriesTheHypervisorsReason pins that the one report an 420 // TestFailedRestartCarriesTheHypervisorsReason pins that the report a guest
422 // ephemeral VM ever produces says WHY. "ephemeral VM lost" is a true statement 421 // produces when it cannot be got running says WHY. The error reconcile has in
423 // about the process table and a useless one to debug from: a guest handed an 422 // hand describes the attempt, not the guest: a VM handed an image its host
424 // image its host cannot execute dies exactly here, and the hypervisor's own 423 // cannot execute dies exactly here, and the hypervisor's own complaint is the
425 // complaint is the only thing that names the cause. 424 // only thing that names the cause.
426 func TestEphemeralLostCarriesTheHypervisorsReason(t *testing.T) { 425 func TestFailedRestartCarriesTheHypervisorsReason(t *testing.T) {
427 f := setup(t) 426 f := setup(t)
428 f.step(snap(1, vm("vm1"))) 427 f.step(snap(1, vm("vm1")))
429 f.prov.running["vm1"] = false 428 f.prov.running["vm1"] = false
429 f.prov.bootErr = errors.New("start vm1")
430 f.prov.failReason = "Error: VmBoot(NoBootableDevice)" 430 f.prov.failReason = "Error: VmBoot(NoBootableDevice)"
431 431
432 av := findVM(f.step(snap(1, vm("vm1"))), "vm1") 432 av := findVM(f.step(snap(1, vm("vm1"))), "vm1")
433 assert.Equal(t, "failed", av.Phase) 433 assert.Equal(t, "failed", av.Phase)
434 assert.Equal(t, "ephemeral VM lost: Error: VmBoot(NoBootableDevice)", av.LastError) 434 assert.Equal(t, "start vm1: Error: VmBoot(NoBootableDevice)", av.LastError)
435 } 435 }
436 436
437 // TestEphemeralLostWithoutAReasonStaysBare pins the empty case: a backend with 437 // TestFailedRestartWithoutAReasonStaysBare pins the empty case: a backend with
438 // nothing to add must not produce a dangling separator. Empty means "nothing to 438 // nothing to add must not produce a dangling separator. Empty means "nothing to
439 // add", not "nothing went wrong" — a guest killed by a host reboot leaves no 439 // add", not "nothing went wrong".
440 // complaint behind and is still lost. 440 func TestFailedRestartWithoutAReasonStaysBare(t *testing.T) {
441 func TestEphemeralLostWithoutAReasonStaysBare(t *testing.T) {
442 f := setup(t) 441 f := setup(t)
443 f.step(snap(1, vm("vm1"))) 442 f.step(snap(1, vm("vm1")))
444 f.prov.running["vm1"] = false 443 f.prov.running["vm1"] = false
444 f.prov.bootErr = errors.New("start vm1")
445 f.prov.failReason = "" 445 f.prov.failReason = ""
446 446
447 av := findVM(f.step(snap(1, vm("vm1"))), "vm1") 447 av := findVM(f.step(snap(1, vm("vm1"))), "vm1")
448 assert.Equal(t, "ephemeral VM lost", av.LastError) 448 assert.Equal(t, "start vm1", av.LastError)
449 } 449 }
450 450
451 func TestEphemeralLostOnHostRebootNeverRestarts(t *testing.T) { 451 // TestLostVMRestartsAfterHostReboot pins the restart policy, which is now the
452 // only one there is: a host comes back, every guest on it is detected lost, and
453 // every guest on it is booted again. There is no field to consult first.
454 func TestLostVMRestartsAfterHostReboot(t *testing.T) {
452 f := setup(t) 455 f := setup(t)
453 f.step(snap(1, vm("vm1"))) 456 f.step(snap(1, vm("vm1")))
454 f.boot = "boot-2" // host rebooted
455 f.prov.running["vm1"] = false
456 f.prov.booted = nil
457 rep := f.step(snap(1, vm("vm1")))
458 av := findVM(rep, "vm1")
459 assert.Equal(t, "failed", av.Phase)
460 assert.Contains(t, av.LastError, "ephemeral VM lost")
461 assert.Empty(t, f.prov.booted, "ephemeral lost VMs are never restarted (spec)")
462 }
463
464 func TestPersistentRestartsAfterHostReboot(t *testing.T) {
465 f := setup(t)
466 f.step(snap(1, vm("vm1", persistent)))
467 f.boot = "boot-2" 457 f.boot = "boot-2"
468 f.prov.running["vm1"] = false 458 f.prov.running["vm1"] = false
469 f.prov.booted = nil 459 f.prov.booted = nil
470 f.step(snap(1, vm("vm1", persistent))) 460 rep := f.step(snap(1, vm("vm1")))
471 assert.Equal(t, []string{"vm1"}, f.prov.booted, "persistent + desired running: restart") 461 assert.Equal(t, []string{"vm1"}, f.prov.booted, "desired running: restart")
462 assert.Equal(t, "ready", findVM(rep, "vm1").Phase)
472 } 463 }
473 464
474 func TestProcessDiedWithoutStopIsLost(t *testing.T) { 465 // TestProcessDiedWithoutStopIsRestarted is the same policy at a smaller scale:
466 // the host stayed up and the hypervisor died under it. Nobody asked for the
467 // guest to stop, so it is lost, so it comes back.
468 func TestProcessDiedWithoutStopIsRestarted(t *testing.T) {
475 f := setup(t) 469 f := setup(t)
476 f.step(snap(1, vm("vm1"))) 470 f.step(snap(1, vm("vm1")))
477 f.prov.running["vm1"] = false // crashed; no stop request, same boot ID 471 f.prov.running["vm1"] = false // crashed; no stop request, same boot ID
472 f.prov.booted = nil
478 rep := f.step(snap(1, vm("vm1"))) 473 rep := f.step(snap(1, vm("vm1")))
479 assert.Equal(t, "failed", findVM(rep, "vm1").Phase) 474 assert.Equal(t, []string{"vm1"}, f.prov.booted)
475 assert.Equal(t, "ready", findVM(rep, "vm1").Phase)
480 } 476 }
481 477
482 func TestTombstoneQuarantinesThenDestroysAfterGrace(t *testing.T) { 478 func TestTombstoneQuarantinesThenDestroysAfterGrace(t *testing.T) {
@@ -560,13 +556,13 @@ func TestEditedSpecResetsCreateAttempts(t *testing.T) {
560 } 556 }
561 557
562 // Fix 3: a successful converge-path boot clears LastError. 558 // Fix 3: a successful converge-path boot clears LastError.
563 // Scenario: persistent VM created successfully, then crashes (lost), converge restarts 559 // Scenario: VM created successfully, then crashes (lost), converge restarts
564 // it but boot fails (one-shot bootErr). Record now has LastError set. Next step: 560 // it but boot fails (one-shot bootErr). Record now has LastError set. Next step:
565 // bootErr cleared, converge retries and succeeds → LastError must be cleared. 561 // bootErr cleared, converge retries and succeeds → LastError must be cleared.
566 func TestConvergeBootSuccessClearsLastError(t *testing.T) { 562 func TestConvergeBootSuccessClearsLastError(t *testing.T) {
567 f := setup(t) 563 f := setup(t)
568 // Step 1: create succeeds — VM is running, BootID set. 564 // Step 1: create succeeds — VM is running, BootID set.
569 f.step(snap(1, vm("vm1", persistent))) 565 f.step(snap(1, vm("vm1")))
570 require.Equal(t, []string{"vm1"}, f.prov.booted) 566 require.Equal(t, []string{"vm1"}, f.prov.booted)
571 567
572 // Step 2: simulate host reboot (boot-2), VM process gone. Converge will try to 568 // Step 2: simulate host reboot (boot-2), VM process gone. Converge will try to
@@ -574,7 +570,7 @@ func TestConvergeBootSuccessClearsLastError(t *testing.T) {
574 f.boot = "boot-2" 570 f.boot = "boot-2"
575 f.prov.running["vm1"] = false 571 f.prov.running["vm1"] = false
576 f.prov.bootErr = assert.AnError 572 f.prov.bootErr = assert.AnError
577 rep := f.step(snap(1, vm("vm1", persistent))) 573 rep := f.step(snap(1, vm("vm1")))
578 av := findVM(rep, "vm1") 574 av := findVM(rep, "vm1")
579 require.NotNil(t, av) 575 require.NotNil(t, av)
580 require.Equal(t, "failed", av.Phase, "boot failure must report failed") 576 require.Equal(t, "failed", av.Phase, "boot failure must report failed")
@@ -582,7 +578,7 @@ func TestConvergeBootSuccessClearsLastError(t *testing.T) {
582 578
583 // Step 3: bootErr is cleared (one-shot). Converge retries and succeeds. 579 // Step 3: bootErr is cleared (one-shot). Converge retries and succeeds.
584 // LastError must be cleared from both report and persisted record. 580 // LastError must be cleared from both report and persisted record.
585 rep = f.step(snap(1, vm("vm1", persistent))) 581 rep = f.step(snap(1, vm("vm1")))
586 av = findVM(rep, "vm1") 582 av = findVM(rep, "vm1")
587 require.NotNil(t, av) 583 require.NotNil(t, av)
588 assert.Equal(t, "ready", av.Phase) 584 assert.Equal(t, "ready", av.Phase)
@@ -849,13 +845,13 @@ func TestEmptyAddressAnswerKeepsTheKnownOne(t *testing.T) {
849 assert.Equal(t, "10.77.1.2", recs["vm1"].IP) 845 assert.Equal(t, "10.77.1.2", recs["vm1"].IP)
850 } 846 }
851 847
852 // TestRestartRebootsAPersistentVMAndRepublishesItsAddress pins that a host 848 // TestRestartRebootsAVMAndRepublishesItsAddress pins that a host reboot puts a
853 // reboot puts a persistent VM back on its feet: reconcile boots it again, and 849 // guest back on its feet: reconcile boots it again, and whatever address the
854 // whatever address the backend then reports is the one that reaches both the 850 // backend then reports is the one that reaches both the report and the durable
855 // report and the durable record. 851 // record.
856 func TestRestartRebootsAPersistentVMAndRepublishesItsAddress(t *testing.T) { 852 func TestRestartRebootsAVMAndRepublishesItsAddress(t *testing.T) {
857 f := setup(t) 853 f := setup(t)
858 f.step(snap(1, vm("vm1", persistent))) 854 f.step(snap(1, vm("vm1")))
859 recs, _ := f.st.LoadVMs() 855 recs, _ := f.st.LoadVMs()
860 require.Equal(t, "10.77.1.2", recs["vm1"].IP) 856 require.Equal(t, "10.77.1.2", recs["vm1"].IP)
861 857
@@ -863,7 +859,7 @@ func TestRestartRebootsAPersistentVMAndRepublishesItsAddress(t *testing.T) {
863 f.prov.running["vm1"] = false 859 f.prov.running["vm1"] = false
864 f.prov.booted = nil 860 f.prov.booted = nil
865 861
866 rep := f.step(snap(2, vm("vm1", persistent))) 862 rep := f.step(snap(2, vm("vm1")))
867 require.Equal(t, []string{"vm1"}, f.prov.booted) 863 require.Equal(t, []string{"vm1"}, f.prov.booted)
868 assert.Equal(t, "10.77.1.2", f.prov.Address("vm1")) 864 assert.Equal(t, "10.77.1.2", f.prov.Address("vm1"))
869 assert.Equal(t, "10.77.1.2", findVM(rep, "vm1").GetIp()) 865 assert.Equal(t, "10.77.1.2", findVM(rep, "vm1").GetIp())
internal/agent/state/state.go
Old New
@@ -17,7 +17,6 @@ import (
17 type VMSpec struct { 17 type VMSpec struct {
18 VMID, Name, ImageURL, ImageSHA256, CloudInit, SSHAuthorizedKey string 18 VMID, Name, ImageURL, ImageSHA256, CloudInit, SSHAuthorizedKey string
19 VCPUs, MemMB, DiskGB int64 19 VCPUs, MemMB, DiskGB int64
20 Persistent bool
21 } 20 }
22 21
23 // Disk is one block device attached to a VM, in attachment order. Index 0 is 22 // Disk is one block device attached to a VM, in attachment order. Index 0 is
internal/pb/sync.pb.go
Old New
@@ -887,19 +887,27 @@ func (x *ActualStateReport) GetHostUplinkAddr() string {
887 } 887 }
888 888
889 type VMDesired struct { 889 type VMDesired struct {
890 state protoimpl.MessageState `protogen:"open.v1"` 890 state protoimpl.MessageState `protogen:"open.v1"`
891 VmId string `protobuf:"bytes,1,opt,name=vm_id,json=vmId,proto3" json:"vm_id,omitempty"` 891 VmId string `protobuf:"bytes,1,opt,name=vm_id,json=vmId,proto3" json:"vm_id,omitempty"`
892 Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` 892 Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
893 ImageUrl string `protobuf:"bytes,3,opt,name=image_url,json=imageUrl,proto3" json:"image_url,omitempty"` 893 ImageUrl string `protobuf:"bytes,3,opt,name=image_url,json=imageUrl,proto3" json:"image_url,omitempty"`
894 ImageSha256 string `protobuf:"bytes,4,opt,name=image_sha256,json=imageSha256,proto3" json:"image_sha256,omitempty"` 894 ImageSha256 string `protobuf:"bytes,4,opt,name=image_sha256,json=imageSha256,proto3" json:"image_sha256,omitempty"`
895 CloudInit string `protobuf:"bytes,5,opt,name=cloud_init,json=cloudInit,proto3" json:"cloud_init,omitempty"` // user-data YAML, may be empty 895 CloudInit string `protobuf:"bytes,5,opt,name=cloud_init,json=cloudInit,proto3" json:"cloud_init,omitempty"` // user-data YAML, may be empty
896 Vcpus int64 `protobuf:"varint,6,opt,name=vcpus,proto3" json:"vcpus,omitempty"` 896 Vcpus int64 `protobuf:"varint,6,opt,name=vcpus,proto3" json:"vcpus,omitempty"`
897 MemMb int64 `protobuf:"varint,7,opt,name=mem_mb,json=memMb,proto3" json:"mem_mb,omitempty"` 897 MemMb int64 `protobuf:"varint,7,opt,name=mem_mb,json=memMb,proto3" json:"mem_mb,omitempty"`
898 DiskGb int64 `protobuf:"varint,8,opt,name=disk_gb,json=diskGb,proto3" json:"disk_gb,omitempty"` 898 DiskGb int64 `protobuf:"varint,8,opt,name=disk_gb,json=diskGb,proto3" json:"disk_gb,omitempty"`
899 Persistent bool `protobuf:"varint,9,opt,name=persistent,proto3" json:"persistent,omitempty"` 899 // Always true, and read by nobody in this tree — the control plane sets it
900 PowerState string `protobuf:"bytes,10,opt,name=power_state,json=powerState,proto3" json:"power_state,omitempty"` // "running"|"stopped" 900 // unconditionally and the agent no longer branches on it, because every VM is
901 Tombstoned bool `protobuf:"varint,11,opt,name=tombstoned,proto3" json:"tombstoned,omitempty"` // present-but-tombstoned (drives quarantine + destroyed[]) 901 // persistent. It cannot be reserved yet: this is a restart policy whose
902 SshAuthorizedKey string `protobuf:"bytes,12,opt,name=ssh_authorized_key,json=sshAuthorizedKey,proto3" json:"ssh_authorized_key,omitempty"` 902 // absence means "ephemeral", and an agent at or below v0.0.5 reads the proto3
903 // default as exactly that, marking a guest failed forever the first time its
904 // host reboots. It comes out in the first release that cannot meet a v0.0.5
905 // agent — the same fielded-version reasoning internal/server/release applies
906 // to the pre-CSR floor — and its number is reserved when it goes.
907 Persistent bool `protobuf:"varint,9,opt,name=persistent,proto3" json:"persistent,omitempty"`
908 PowerState string `protobuf:"bytes,10,opt,name=power_state,json=powerState,proto3" json:"power_state,omitempty"` // "running"|"stopped"
909 Tombstoned bool `protobuf:"varint,11,opt,name=tombstoned,proto3" json:"tombstoned,omitempty"` // present-but-tombstoned (drives quarantine + destroyed[])
910 SshAuthorizedKey string `protobuf:"bytes,12,opt,name=ssh_authorized_key,json=sshAuthorizedKey,proto3" json:"ssh_authorized_key,omitempty"`
903 // The certificate the control plane signed for the public key the host 911 // The certificate the control plane signed for the public key the host
904 // reported in ActualVM.ssh_host_pubkey (authorized_keys form); seed installs 912 // reported in ActualVM.ssh_host_pubkey (authorized_keys form); seed installs
905 // it as /etc/ssh/ssh_host_ed25519_key-cert.pub. Empty until the round trip 913 // it as /etc/ssh/ssh_host_ed25519_key-cert.pub. Empty until the round trip
internal/server/api/api.go
Old New
@@ -562,7 +562,6 @@ func toVMResponse(vm store.VM, actualPower, phase string, destroyAt int64) types
562 VCPUs: vm.VCPUs, 562 VCPUs: vm.VCPUs,
563 MemMB: vm.MemMB, 563 MemMB: vm.MemMB,
564 DiskGB: vm.DiskGB, 564 DiskGB: vm.DiskGB,
565 Persistent: vm.Persistent,
566 PowerState: vm.PowerState, 565 PowerState: vm.PowerState,
567 Status: vm.Status, 566 Status: vm.Status,
568 LastError: vm.LastError, 567 LastError: vm.LastError,
@@ -886,12 +885,9 @@ func (a *API) handleCreateVM(w http.ResponseWriter, r *http.Request) {
886 // exactly what permitted it to exist. 885 // exactly what permitted it to exist.
887 TrustedCAs: freezeTrustedCAs(tenantCAs), 886 TrustedCAs: freezeTrustedCAs(tenantCAs),
888 887
889 VCPUs: req.VCPUs, 888 VCPUs: req.VCPUs,
890 MemMB: req.MemMB, 889 MemMB: req.MemMB,
891 DiskGB: req.DiskGB, 890 DiskGB: req.DiskGB,
892 // A lost guest is booted again, always: the restart policy is not a
893 // choice a create gets to make, and the agent reads this field to decide.
894 Persistent: true,
895 PowerState: req.PowerState, 891 PowerState: req.PowerState,
896 } 892 }
897 893
internal/server/api/api_test.go
Old New
@@ -78,16 +78,20 @@ func TestResponseJSONKeysAreSnakeCase(t *testing.T) {
78 // Required snake_case keys must be present. 78 // Required snake_case keys must be present.
79 for _, k := range []string{ 79 for _, k := range []string{
80 "id", "host_id", "name", "image_url", "vcpus", "mem_mb", "disk_gb", 80 "id", "host_id", "name", "image_url", "vcpus", "mem_mb", "disk_gb",
81 "persistent", "power_state", "status", "last_error", "assigned_ip", 81 "power_state", "status", "last_error", "assigned_ip",
82 "created_at", "deleted", "actual_power", "phase", 82 "created_at", "deleted", "actual_power", "phase",
83 "destroy_at", "lifecycle", 83 "destroy_at", "lifecycle",
84 } { 84 } {
85 assert.Contains(t, v, k, "vm response must contain key %q", k) 85 assert.Contains(t, v, k, "vm response must contain key %q", k)
86 } 86 }
87 // Retired: persistence is not a property of a VM, so it is not one of
88 // its fields. Every consumer ships with the server, so there is nobody
89 // left to tell.
90 assert.NotContains(t, v, "persistent")
87 // PascalCase keys from embedded store.VM must be absent. 91 // PascalCase keys from embedded store.VM must be absent.
88 for _, k := range []string{ 92 for _, k := range []string{
89 "ID", "HostID", "Name", "ImageURL", "ImageSHA256", "CloudInit", 93 "ID", "HostID", "Name", "ImageURL", "ImageSHA256", "CloudInit",
90 "VCPUs", "MemMB", "DiskGB", "Persistent", "PowerState", 94 "VCPUs", "MemMB", "DiskGB", "PowerState",
91 "Status", "LastError", "AssignedIP", "SSHAuthorizedKey", 95 "Status", "LastError", "AssignedIP", "SSHAuthorizedKey",
92 "CreatedAt", "DeletedAt", 96 "CreatedAt", "DeletedAt",
93 } { 97 } {
@@ -504,7 +508,6 @@ func TestOneClickCreateFillsDefaultsAndPokesHub(t *testing.T) {
504 assert.Equal(t, int64(2), vms[0].VCPUs) 508 assert.Equal(t, int64(2), vms[0].VCPUs)
505 assert.Equal(t, int64(2048), vms[0].MemMB) 509 assert.Equal(t, int64(2048), vms[0].MemMB)
506 assert.Equal(t, int64(10), vms[0].DiskGB) 510 assert.Equal(t, int64(10), vms[0].DiskGB)
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) 511 assert.Equal(t, "running", vms[0].PowerState)
509 assert.NotEmpty(t, vms[0].Name) 512 assert.NotEmpty(t, vms[0].Name)
510 assert.Contains(t, vms[0].ImageURL, "ubuntu") 513 assert.Contains(t, vms[0].ImageURL, "ubuntu")
internal/server/api/testdata/snapshot.golden.json
Old New
@@ -54,7 +54,6 @@
54 "vcpus": 2, 54 "vcpus": 2,
55 "mem_mb": 2048, 55 "mem_mb": 2048,
56 "disk_gb": 10, 56 "disk_gb": 10,
57 "persistent": true,
58 "power_state": "running", 57 "power_state": "running",
59 "status": "ready", 58 "status": "ready",
60 "last_error": "boot timeout", 59 "last_error": "boot timeout",
internal/server/api/testdata/vm.golden.json
Old New
@@ -6,7 +6,6 @@
6 "vcpus": 2, 6 "vcpus": 2,
7 "mem_mb": 2048, 7 "mem_mb": 2048,
8 "disk_gb": 10, 8 "disk_gb": 10,
9 "persistent": true,
10 "power_state": "running", 9 "power_state": "running",
11 "status": "ready", 10 "status": "ready",
12 "last_error": "boot timeout", 11 "last_error": "boot timeout",
internal/server/api/types/types.go
Old New
@@ -88,10 +88,6 @@ 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.
95 type VM struct { 91 type VM struct {
96 ID string `json:"id"` 92 ID string `json:"id"`
97 HostID string `json:"host_id"` 93 HostID string `json:"host_id"`
@@ -100,7 +96,6 @@ type VM struct {
100 VCPUs int64 `json:"vcpus"` 96 VCPUs int64 `json:"vcpus"`
101 MemMB int64 `json:"mem_mb"` 97 MemMB int64 `json:"mem_mb"`
102 DiskGB int64 `json:"disk_gb"` 98 DiskGB int64 `json:"disk_gb"`
103 Persistent bool `json:"persistent"`
104 PowerState string `json:"power_state"` 99 PowerState string `json:"power_state"`
105 Status string `json:"status"` 100 Status string `json:"status"`
106 LastError string `json:"last_error"` 101 LastError string `json:"last_error"`
internal/server/api/wire_golden_test.go
Old New
@@ -97,7 +97,6 @@ func TestWireGolden(t *testing.T) {
97 VCPUs: 2, 97 VCPUs: 2,
98 MemMB: 2048, 98 MemMB: 2048,
99 DiskGB: 10, 99 DiskGB: 10,
100 Persistent: true,
101 PowerState: "running", 100 PowerState: "running",
102 Status: "ready", 101 Status: "ready",
103 LastError: "boot timeout", 102 LastError: "boot timeout",
internal/server/store/store.go
Old New
@@ -78,7 +78,6 @@ type HostFacts struct {
78 type VM struct { 78 type VM struct {
79 ID, HostID, Name, ImageURL, ImageSHA256, CloudInit string 79 ID, HostID, Name, ImageURL, ImageSHA256, CloudInit string
80 VCPUs, MemMB, DiskGB int64 80 VCPUs, MemMB, DiskGB int64
81 Persistent bool
82 PowerState, Status, LastError, AssignedIP string 81 PowerState, Status, LastError, AssignedIP string
83 SSHAuthorizedKey string 82 SSHAuthorizedKey string
84 // SSHHostPubKey is the public half of the guest's SSH host key, as its host 83 // SSHHostPubKey is the public half of the guest's SSH host key, as its host
@@ -188,8 +187,6 @@ CREATE TABLE IF NOT EXISTS vms (
188 vcpus INTEGER NOT NULL, 187 vcpus INTEGER NOT NULL,
189 mem_mb INTEGER NOT NULL, 188 mem_mb INTEGER NOT NULL,
190 disk_gb INTEGER NOT NULL, 189 disk_gb INTEGER NOT NULL,
191 -- Restart policy, and the only one there is: a lost guest is booted again.
192 persistent INTEGER NOT NULL DEFAULT 1,
193 power_state TEXT NOT NULL, 190 power_state TEXT NOT NULL,
194 status TEXT NOT NULL DEFAULT 'pending', 191 status TEXT NOT NULL DEFAULT 'pending',
195 last_error TEXT NOT NULL DEFAULT '', 192 last_error TEXT NOT NULL DEFAULT '',
@@ -396,14 +393,15 @@ func Open(path, cidrPool string) (*Store, error) {
396 return nil, err 393 return nil, err
397 } 394 }
398 395
399 // Every VM is persistent: a guest lost to a host reboot or a dead hypervisor 396 // Persistence stopped being a per-VM answer a release ago: a guest lost to a
400 // is booted again. Rows written when the policy was a create-time choice can 397 // host reboot or a dead hypervisor is booted again, every time, and the
401 // still say otherwise, and a false one is a guest its agent would mark failed 398 // previous release backfilled the rows that said otherwise. What is left is a
402 // forever the first time its host went down. Backfill them — the fleet's 399 // column every row agrees on, which is not a fact about a VM. The snapshot
403 // existing VMs get the same promise a new one does. 400 // still tells agents the policy — see syncsvc — it just no longer reads it
404 if _, err := db.Exec(`UPDATE vms SET persistent = 1 WHERE persistent = 0`); err != nil { 401 // back out of a table to do so.
402 if err := dropColumn(db, "vms", "persistent"); err != nil {
405 db.Close() 403 db.Close()
406 return nil, fmt.Errorf("backfill vms.persistent: %w", err) 404 return nil, err
407 } 405 }
408 406
409 // One identity binds at most one tenant (per issuer). Partial index so 407 // One identity binds at most one tenant (per issuer). Partial index so
@@ -823,12 +821,12 @@ func (s *Store) CreateVM(vm VM) error {
823 _, err = tx.Exec( 821 _, err = tx.Exec(
824 `INSERT INTO vms(id, host_id, name, tenant, image_url, image_sha256, cloud_init, ssh_authorized_key, 822 `INSERT INTO vms(id, host_id, name, tenant, image_url, image_sha256, cloud_init, ssh_authorized_key,
825 injected_key_type, injected_key_fp, injected_key_comment, trusted_cas, 823 injected_key_type, injected_key_fp, injected_key_comment, trusted_cas,
826 vcpus, mem_mb, disk_gb, persistent, power_state, created_at) 824 vcpus, mem_mb, disk_gb, power_state, created_at)
827 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, 825 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
828 vm.ID, vm.HostID, vm.Name, vm.Tenant, vm.ImageURL, vm.ImageSHA256, 826 vm.ID, vm.HostID, vm.Name, vm.Tenant, vm.ImageURL, vm.ImageSHA256,
829 vm.CloudInit, vm.SSHAuthorizedKey, 827 vm.CloudInit, vm.SSHAuthorizedKey,
830 vm.InjectedKeyType, vm.InjectedKeyFP, vm.InjectedKeyComment, trustedCAs, 828 vm.InjectedKeyType, vm.InjectedKeyFP, vm.InjectedKeyComment, trustedCAs,
831 vm.VCPUs, vm.MemMB, vm.DiskGB, vm.Persistent, vm.PowerState, 829 vm.VCPUs, vm.MemMB, vm.DiskGB, vm.PowerState,
832 now.Format(time.RFC3339), 830 now.Format(time.RFC3339),
833 ) 831 )
834 if err != nil { 832 if err != nil {
@@ -1339,7 +1337,7 @@ func (s *Store) RecordVMHostKey(vmID, hostID, pubkey, cert string) error {
1339 const vmColumns = `id, host_id, name, tenant, image_url, image_sha256, cloud_init, ssh_authorized_key, 1337 const vmColumns = `id, host_id, name, tenant, image_url, image_sha256, cloud_init, ssh_authorized_key,
1340 ssh_host_pubkey, ssh_host_cert, 1338 ssh_host_pubkey, ssh_host_cert,
1341 injected_key_type, injected_key_fp, injected_key_comment, trusted_cas, 1339 injected_key_type, injected_key_fp, injected_key_comment, trusted_cas,
1342 vcpus, mem_mb, disk_gb, persistent, power_state, status, last_error, assigned_ip, 1340 vcpus, mem_mb, disk_gb, power_state, status, last_error, assigned_ip,
1343 created_at, deleted_at` 1341 created_at, deleted_at`
1344 1342
1345 // scanVM's column order must match vmColumns exactly — it is positional, not 1343 // scanVM's column order must match vmColumns exactly — it is positional, not
@@ -1353,7 +1351,7 @@ func scanVM(rows *sql.Rows) (VM, error) {
1353 &vm.ID, &vm.HostID, &vm.Name, &vm.Tenant, &vm.ImageURL, &vm.ImageSHA256, 1351 &vm.ID, &vm.HostID, &vm.Name, &vm.Tenant, &vm.ImageURL, &vm.ImageSHA256,
1354 &vm.CloudInit, &vm.SSHAuthorizedKey, &vm.SSHHostPubKey, &vm.SSHHostCert, 1352 &vm.CloudInit, &vm.SSHAuthorizedKey, &vm.SSHHostPubKey, &vm.SSHHostCert,
1355 &vm.InjectedKeyType, &vm.InjectedKeyFP, &vm.InjectedKeyComment, &trustedCAs, 1353 &vm.InjectedKeyType, &vm.InjectedKeyFP, &vm.InjectedKeyComment, &trustedCAs,
1356 &vm.VCPUs, &vm.MemMB, &vm.DiskGB, &vm.Persistent, 1354 &vm.VCPUs, &vm.MemMB, &vm.DiskGB,
1357 &vm.PowerState, &vm.Status, &vm.LastError, &vm.AssignedIP, 1355 &vm.PowerState, &vm.Status, &vm.LastError, &vm.AssignedIP,
1358 &createdAt, &deletedAt, 1356 &createdAt, &deletedAt,
1359 ) 1357 )
internal/server/store/store_test.go
Old New
@@ -198,27 +198,28 @@ 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 201 // TestOpenDropsTheRetiredPersistentColumn is the upgrade for a fleet whose
202 // when persistence was a create-time choice. A row that says otherwise is a 202 // database still carries the restart policy as a column. Dropping a column
203 // guest its host would mark failed forever the first time it went down, so 203 // rewrites the table every row of which must come back out intact, so this
204 // every one of them is flipped — including the deleted rows, which can be 204 // pins the rows rather than the schema: the VMs an operator has — live and
205 // restored back into service. 205 // tombstoned — survive the migration with their fields, and a second Open
206 func TestOpenMakesEveryExistingVMPersistent(t *testing.T) { 206 // finds nothing left to drop.
207 func TestOpenDropsTheRetiredPersistentColumn(t *testing.T) {
207 path := t.TempDir() + "/eitri.db" 208 path := t.TempDir() + "/eitri.db"
208 s, err := Open(path, "10.77.0.0/16") 209 s, err := Open(path, "10.77.0.0/16")
209 require.NoError(t, err) 210 require.NoError(t, err)
210 _, err = s.CreateTenantForIdentity("https://test-issuer", "test-subject", testTenant+"@test.local") 211 _, err = s.CreateTenantForIdentity("https://test-issuer", "test-subject", testTenant+"@test.local")
211 require.NoError(t, err) 212 require.NoError(t, err)
212 h := enrollHost(t, s) 213 h := enrollHost(t, s)
213 for _, name := range []string{"inherited", "deleted"} { 214 for _, name := range []string{"live", "deleted"} {
214 require.NoError(t, s.CreateVM(VM{ 215 require.NoError(t, s.CreateVM(VM{
215 ID: name, HostID: h.ID, Name: name, ImageURL: "u", ImageSHA256: "abc", 216 ID: name, HostID: h.ID, Name: name, ImageURL: "u", ImageSHA256: "abc",
216 VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running", 217 VCPUs: 2, MemMB: 512, DiskGB: 5, PowerState: "running",
217 })) 218 }))
218 } 219 }
219 require.NoError(t, s.TombstoneVM("deleted")) 220 require.NoError(t, s.TombstoneVM("deleted"))
220 // The shape an earlier release left: rows carrying the zero value nobody chose. 221 // The shape the previous release left behind: the column, on every row.
221 _, err = s.db.Exec(`UPDATE vms SET persistent = 0`) 222 _, err = s.db.Exec(`ALTER TABLE vms ADD COLUMN persistent INTEGER NOT NULL DEFAULT 1`)
222 require.NoError(t, err) 223 require.NoError(t, err)
223 require.NoError(t, s.Close()) 224 require.NoError(t, s.Close())
224 225
@@ -226,11 +227,28 @@ func TestOpenMakesEveryExistingVMPersistent(t *testing.T) {
226 require.NoError(t, err) 227 require.NoError(t, err)
227 t.Cleanup(func() { up.Close() }) 228 t.Cleanup(func() { up.Close() })
228 229
229 for _, id := range []string{"inherited", "deleted"} { 230 var n int
231 require.NoError(t, up.db.QueryRow(
232 `SELECT count(*) FROM pragma_table_info('vms') WHERE name = 'persistent'`).Scan(&n))
233 assert.Zero(t, n, "the column must be gone")
234
235 for _, id := range []string{"live", "deleted"} {
230 vm, err := up.GetVM(id) 236 vm, err := up.GetVM(id)
231 require.NoError(t, err) 237 require.NoError(t, err)
232 assert.True(t, vm.Persistent, "%s must have been made persistent", id) 238 assert.Equal(t, id, vm.Name, "%s must have survived the migration", id)
239 assert.Equal(t, int64(2), vm.VCPUs, "%s must have kept its fields", id)
233 } 240 }
241 deleted, err := up.GetVM("deleted")
242 require.NoError(t, err)
243 assert.NotNil(t, deleted.DeletedAt, "a tombstoned VM stays tombstoned")
244
245 // Idempotent: a database already migrated has nothing to drop.
246 require.NoError(t, up.Close())
247 again, err := Open(path, "10.77.0.0/16")
248 require.NoError(t, err)
249 t.Cleanup(func() { again.Close() })
250 _, err = again.GetVM("live")
251 require.NoError(t, err)
234 } 252 }
235 253
236 func TestOpenReplacesTheProtocolBlindHostPortIndex(t *testing.T) { 254 func TestOpenReplacesTheProtocolBlindHostPortIndex(t *testing.T) {
internal/server/syncsvc/persistent_test.go
Old New
@@ -0,0 +1,38 @@
1 package syncsvc
2
3 import (
4 "testing"
5
6 "github.com/a73x/eitri/internal/server/store"
7 "github.com/stretchr/testify/assert"
8 "github.com/stretchr/testify/require"
9 )
10
11 // TestSnapshotStillCarriesPersistent is a compatibility pin, not a feature
12 // test. Persistence stopped being a column and stopped being a branch in this
13 // agent, but it has not stopped being on the wire, and this is the test that
14 // fails if someone finishes the job too early.
15 //
16 // A v0.0.5 agent — which exists, in production — reads VMDesired.persistent as
17 // a restart policy, and proto3 gives an absent bool the value false. False
18 // means ephemeral, and an ephemeral guest that goes lost is marked failed
19 // forever and never booted again. Every guest goes lost when its host reboots.
20 // So a server that stopped sending this field would, the first time a host
21 // running an older agent came back up, permanently strand every VM on it.
22 //
23 // The field comes out when no v0.0.5 agent can still be dialling in. Until
24 // then it is sent unconditionally, from nowhere: there is no stored value left
25 // to read, which is the point.
26 func TestSnapshotStillCarriesPersistent(t *testing.T) {
27 f := setup(t)
28 require.NoError(t, f.st.CreateVM(store.VM{
29 ID: "vm1", HostID: f.host.ID, Name: "web-1", ImageURL: "u", ImageSHA256: "s",
30 VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running",
31 }))
32
33 snap, err := f.svc.buildSnapshot(f.host.ID)
34 require.NoError(t, err)
35 require.Len(t, snap.GetVms(), 1)
36 assert.True(t, snap.GetVms()[0].GetPersistent(),
37 "a fielded v0.0.5 agent reads an absent persistent as ephemeral and strands the guest")
38 }
internal/server/syncsvc/syncsvc.go
Old New
@@ -354,7 +354,12 @@ func (s *Service) buildSnapshot(hostID string) (*pb.DesiredStateSnapshot, error)
354 snap.Vms = append(snap.Vms, &pb.VMDesired{ 354 snap.Vms = append(snap.Vms, &pb.VMDesired{
355 VmId: v.ID, Name: v.Name, ImageUrl: v.ImageURL, ImageSha256: v.ImageSHA256, 355 VmId: v.ID, Name: v.Name, ImageUrl: v.ImageURL, ImageSha256: v.ImageSHA256,
356 CloudInit: v.CloudInit, Vcpus: v.VCPUs, MemMb: v.MemMB, DiskGb: v.DiskGB, 356 CloudInit: v.CloudInit, Vcpus: v.VCPUs, MemMb: v.MemMB, DiskGb: v.DiskGB,
357 Persistent: v.Persistent, PowerState: v.PowerState, Tombstoned: v.DeletedAt != nil, 357 // Always true, and no longer read from anywhere: every VM is
358 // persistent, so there is nothing left to look up. It stays on the
359 // wire for the agents that still read it — a v0.0.5 agent handed the
360 // proto3 default takes absence for "ephemeral" and marks a guest
361 // failed forever the first time its host reboots. See sync.proto.
362 Persistent: true, PowerState: v.PowerState, Tombstoned: v.DeletedAt != nil,
358 SshAuthorizedKey: v.SSHAuthorizedKey, 363 SshAuthorizedKey: v.SSHAuthorizedKey,
359 SshUserCaAuthorizedKeys: cas, 364 SshUserCaAuthorizedKeys: cas,
360 SshHostCert: v.SSHHostCert, 365 SshHostCert: v.SSHHostCert,
proto/eitri/v1/sync.proto
Old New
@@ -123,6 +123,14 @@ message VMDesired {
123 int64 vcpus = 6; 123 int64 vcpus = 6;
124 int64 mem_mb = 7; 124 int64 mem_mb = 7;
125 int64 disk_gb = 8; 125 int64 disk_gb = 8;
126 // Always true, and read by nobody in this tree — the control plane sets it
127 // unconditionally and the agent no longer branches on it, because every VM is
128 // persistent. It cannot be reserved yet: this is a restart policy whose
129 // absence means "ephemeral", and an agent at or below v0.0.5 reads the proto3
130 // default as exactly that, marking a guest failed forever the first time its
131 // host reboots. It comes out in the first release that cannot meet a v0.0.5
132 // agent — the same fielded-version reasoning internal/server/release applies
133 // to the pre-CSR floor — and its number is reserved when it goes.
126 bool persistent = 9; 134 bool persistent = 9;
127 string power_state = 10; // "running"|"stopped" 135 string power_state = 10; // "running"|"stopped"
128 bool tombstoned = 11; // present-but-tombstoned (drives quarantine + destroyed[]) 136 bool tombstoned = 11; // present-but-tombstoned (drives quarantine + destroyed[])
web/src/lib/api-types.ts
Old New
@@ -1689,7 +1689,6 @@ export interface components {
1689 lifecycle: string; 1689 lifecycle: string;
1690 mem_mb: number; 1690 mem_mb: number;
1691 name: string; 1691 name: string;
1692 persistent: boolean;
1693 phase: string; 1692 phase: string;
1694 power_state: string; 1693 power_state: string;
1695 status: string; 1694 status: string;