package reconcile import ( "testing" "gitea.dooplex.hu/admin/felhom-agent/internal/hub" ) func sp(s string) *string { return &s } func mib(n int64) int64 { return n * bytesPerMiB } // desired/actual builders keep the table compact. func desired(gs ...DesiredGuest) DesiredState { m := map[int]DesiredGuest{} for _, g := range gs { m[g.VMID] = g } return DesiredState{Guests: m} } func actual(gs ...ActualGuest) ActualState { m := map[int]ActualGuest{} for _, g := range gs { m[g.VMID] = g } return ActualState{Guests: m} } func TestPlan_RunStateStartAndStop(t *testing.T) { // stopped -> running got := Plan( desired(DesiredGuest{VMID: 100, Run: RunRunning}), actual(ActualGuest{VMID: 100, Run: RunStopped, SpecKnown: true}), nil) mustActions(t, got, Action{VMID: 100, Kind: ActionStart}) // running -> stopped got = Plan( desired(DesiredGuest{VMID: 100, Run: RunStopped}), actual(ActualGuest{VMID: 100, Run: RunRunning, SpecKnown: true}), nil) mustActions(t, got, Action{VMID: 100, Kind: ActionStop}) // already matches -> nothing got = Plan( desired(DesiredGuest{VMID: 100, Run: RunRunning}), actual(ActualGuest{VMID: 100, Run: RunRunning, SpecKnown: true}), nil) mustActions(t, got) } func TestPlan_SpecDrift(t *testing.T) { // cores change got := Plan( desired(DesiredGuest{VMID: 100, Spec: &hub.GuestSpec{Cores: 4, MemoryBytes: mib(2048)}}), actual(ActualGuest{VMID: 100, Run: RunStopped, SpecKnown: true, Cores: 2, MemoryMiB: 2048}), nil) mustActions(t, got, Action{VMID: 100, Kind: ActionSetConfig, Params: map[string]string{"cores": "4"}}) // memory change (bytes desired -> MiB param) got = Plan( desired(DesiredGuest{VMID: 100, Spec: &hub.GuestSpec{Cores: 2, MemoryBytes: mib(4096)}}), actual(ActualGuest{VMID: 100, Run: RunStopped, SpecKnown: true, Cores: 2, MemoryMiB: 2048}), nil) mustActions(t, got, Action{VMID: 100, Kind: ActionSetConfig, Params: map[string]string{"memory": "4096"}}) // no spec drift -> nothing got = Plan( desired(DesiredGuest{VMID: 100, Spec: &hub.GuestSpec{Cores: 2, MemoryBytes: mib(2048)}}), actual(ActualGuest{VMID: 100, Run: RunStopped, SpecKnown: true, Cores: 2, MemoryMiB: 2048}), nil) mustActions(t, got) } func TestPlan_MemoryNonAlignedConverges(t *testing.T) { // Note-2 guard: a desired MemoryBytes that is NOT a clean MiB multiple must not // cause perpetual drift. We compare in MiB and write the SAME MiB we compared, so it // settles in one pass. desiredBytes := int64(2049)*bytesPerMiB + 500000 // 2049 MiB + change → floors to 2049 d := desired(DesiredGuest{VMID: 100, Spec: &hub.GuestSpec{Cores: 2, MemoryBytes: desiredBytes}}) // First pass: actual is 2048 MiB → one SetConfig memory=2049. got := Plan(d, actual(ActualGuest{VMID: 100, Run: RunStopped, SpecKnown: true, Cores: 2, MemoryMiB: 2048}), nil) mustActions(t, got, Action{VMID: 100, Kind: ActionSetConfig, Params: map[string]string{"memory": "2049"}}) // Apply it: actual becomes 2049 MiB. Re-plan against the SAME desired → no action. if got2 := Plan(d, actual(ActualGuest{VMID: 100, Run: RunStopped, SpecKnown: true, Cores: 2, MemoryMiB: 2049}), nil); len(got2) != 0 { t.Fatalf("non-MiB-aligned memory did not converge (perpetual drift): %+v", got2) } } func TestPlan_DiskNotReconciled(t *testing.T) { // DiskBytes differs but is intentionally not reconciled (pct resize, later slice). got := Plan( desired(DesiredGuest{VMID: 100, Spec: &hub.GuestSpec{Cores: 2, MemoryBytes: mib(2048), DiskBytes: 1 << 40}}), actual(ActualGuest{VMID: 100, Run: RunStopped, SpecKnown: true, Cores: 2, MemoryMiB: 2048}), nil) mustActions(t, got) } func TestPlan_DescriptionNormalizedNoFalseDrift(t *testing.T) { // PVE returns the description with a trailing newline; desired has none. Must NOT // be planned as drift. got := Plan( desired(DesiredGuest{VMID: 100, Description: sp("felhom-managed")}), actual(ActualGuest{VMID: 100, Run: RunStopped, SpecKnown: true, Description: "felhom-managed\n"}), nil) mustActions(t, got) // A genuine description change IS planned. got = Plan( desired(DesiredGuest{VMID: 100, Description: sp("new-desc")}), actual(ActualGuest{VMID: 100, Run: RunStopped, SpecKnown: true, Description: "old-desc\n"}), nil) mustActions(t, got, Action{VMID: 100, Kind: ActionSetConfig, Params: map[string]string{"description": "new-desc"}}) } func TestPlan_UnmanagedFieldsProduceNothing(t *testing.T) { // Run unspecified, Spec nil, Description nil -> the reconciler leaves it alone even // though actual differs from "defaults". got := Plan( desired(DesiredGuest{VMID: 100}), actual(ActualGuest{VMID: 100, Run: RunRunning, SpecKnown: true, Cores: 8, MemoryMiB: 9999, Description: "whatever"}), nil) mustActions(t, got) } func TestPlan_SpecUnknownSkipsConfigButKeepsRunState(t *testing.T) { // GuestConfig read failed (SpecKnown=false): never write a config we couldn't read, // but run-state is still comparable from the list. got := Plan( desired(DesiredGuest{VMID: 100, Run: RunRunning, Spec: &hub.GuestSpec{Cores: 4, MemoryBytes: mib(4096)}, Description: sp("x")}), actual(ActualGuest{VMID: 100, Run: RunStopped, SpecKnown: false}), nil) mustActions(t, got, Action{VMID: 100, Kind: ActionStart}) } func TestPlan_DesiredAbsentInActualSkipped(t *testing.T) { // A guest desired but not present would be provisioning (slice 7) — not a slice-4 // action. And a guest present but not desired would be a destroy (gated, slice 10). got := Plan( desired(DesiredGuest{VMID: 200, Run: RunRunning}), actual(ActualGuest{VMID: 100, Run: RunStopped, SpecKnown: true}), nil) mustActions(t, got) } func TestPlan_CombinedConfigBeforeRunState(t *testing.T) { // cores + memory + description + run change: one SetConfig (all params) THEN the // run-state action, both on the same vmid (the queue serializes them). got := Plan( desired(DesiredGuest{VMID: 100, Run: RunRunning, Spec: &hub.GuestSpec{Cores: 4, MemoryBytes: mib(4096)}, Description: sp("new")}), actual(ActualGuest{VMID: 100, Run: RunStopped, SpecKnown: true, Cores: 2, MemoryMiB: 2048, Description: "old\n"}), nil) if len(got) != 2 { t.Fatalf("want 2 actions, got %d: %+v", len(got), got) } if got[0].Kind != ActionSetConfig { t.Errorf("first action should be SetConfig, got %s", got[0].Kind) } for _, k := range []string{"cores", "memory", "description"} { if _, ok := got[0].Params[k]; !ok { t.Errorf("SetConfig params missing %q: %v", k, got[0].Params) } } if got[1].Kind != ActionStart { t.Errorf("second action should be Start, got %s", got[1].Kind) } } func TestPlan_EmptyDesiredNoActions(t *testing.T) { // The slice-4 production case: empty desired -> zero actions regardless of actual. got := Plan( DesiredState{Guests: map[int]DesiredGuest{}}, actual(ActualGuest{VMID: 100, Run: RunRunning, SpecKnown: true}), nil) mustActions(t, got) } func TestPlan_DeterministicVMIDOrder(t *testing.T) { got := Plan( desired( DesiredGuest{VMID: 300, Run: RunRunning}, DesiredGuest{VMID: 100, Run: RunRunning}, DesiredGuest{VMID: 200, Run: RunRunning}, ), actual( ActualGuest{VMID: 100, Run: RunStopped, SpecKnown: true}, ActualGuest{VMID: 200, Run: RunStopped, SpecKnown: true}, ActualGuest{VMID: 300, Run: RunStopped, SpecKnown: true}, ), nil) if len(got) != 3 || got[0].VMID != 100 || got[1].VMID != 200 || got[2].VMID != 300 { t.Fatalf("actions not sorted by vmid: %+v", got) } } // mustActions asserts the planned actions equal want (vmid+kind+params), ignoring // Reason (debug-only). func mustActions(t *testing.T, got []Action, want ...Action) { t.Helper() if len(got) != len(want) { t.Fatalf("got %d actions, want %d: %+v", len(got), len(want), got) } for i := range want { if got[i].VMID != want[i].VMID || got[i].Kind != want[i].Kind { t.Errorf("action[%d] = {vmid:%d kind:%s}, want {vmid:%d kind:%s}", i, got[i].VMID, got[i].Kind, want[i].VMID, want[i].Kind) } if !sameParams(got[i].Params, want[i].Params) { t.Errorf("action[%d] params = %v, want %v", i, got[i].Params, want[i].Params) } } } func sameParams(a, b map[string]string) bool { if len(a) != len(b) { return false } for k, v := range b { if a[k] != v { return false } } return true }