f986d9b8
feat(fleet): persistence is not a column
a73x 2026-08-11 10:24
Commit message
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; |