package reconcile import ( "context" "testing" "gitea.dooplex.hu/admin/felhom-agent/internal/hub" "gitea.dooplex.hu/admin/felhom-agent/internal/proxmox" ) // The headline slice-10A reconcile behaviour: a desired-state carrying ONE benign delta and ONE // destructive (decommission) delta → the benign op is applied, the destructive one is GATED // pending_signature (counted Pending, NOT Failed) and is NEVER executed (no signer in 10A). func TestReconcile_BenignAppliedDestructiveGated(t *testing.T) { api := &fakeAPI{ lxc: []proxmox.Guest{ {VMID: 100, Status: "stopped"}, // benign: desired running → Start {VMID: 200, Status: "running"}, // destructive: decommission → gated }, cfg: map[int]proxmox.GuestConfig{100: {Cores: 2}, 200: {Cores: 2}}, } provider := StaticProvider{State: DesiredState{Guests: map[int]DesiredGuest{ 100: {VMID: 100, Run: RunRunning}, 200: {VMID: 200, Decommission: true}, }}} e, _, _ := newEngine(t, api, provider) res, err := e.Reconcile(context.Background()) if err != nil { t.Fatalf("Reconcile: %v", err) } if res.Planned != 2 { t.Errorf("planned = %d, want 2 (one benign + one destructive)", res.Planned) } // Benign Start(100) applied. if res.Executed != 1 || len(api.starts) != 1 || api.starts[0] != 100 { t.Errorf("benign delta not applied: executed=%d starts=%v", res.Executed, api.starts) } // Destructive decommission GATED pending (not a failure). if res.Pending != 1 { t.Errorf("pending = %d, want 1 (decommission gated pending_signature)", res.Pending) } if res.Failed != 0 { t.Errorf("failed = %d, want 0 (a pending_signature gate is expected, not a failure)", res.Failed) } // And it was NEVER executed: no destroy/decommission op reached Proxmox. if len(api.destroys) != 0 { t.Errorf("destructive decommission EXECUTED (destroys=%v) — it must be gated, not run", api.destroys) } } // Plan unit: an explicit Decommission emits exactly one ActionDecommission and suppresses any // other delta for that guest (no point reconciling cores on a guest being torn down). func TestPlan_DecommissionEmitsDestructiveActionOnly(t *testing.T) { desired := DesiredState{Guests: map[int]DesiredGuest{ // Decommission set AND a spec drift — only the decommission should be emitted. 7: {VMID: 7, Decommission: true, Spec: &hub.GuestSpec{Cores: 9, MemoryBytes: 9 << 20}, Run: RunStopped}, }} actual := ActualState{Guests: map[int]ActualGuest{ 7: {VMID: 7, Run: RunRunning, SpecKnown: true, Cores: 2}, }} actions := Plan(desired, actual, DefaultNormalizers()) if len(actions) != 1 { t.Fatalf("actions = %d (%+v), want exactly 1 (decommission only)", len(actions), actions) } if actions[0].Kind != ActionDecommission || actions[0].VMID != 7 { t.Errorf("action = %+v, want decommission of vmid 7", actions[0]) } // And it classifies destructive. if Classify(classOfAction(ActionDecommission), Provenance{}) != Destructive { t.Error("ActionDecommission must classify Destructive (no provenance)") } } // Decommission of an ABSENT guest is a no-op (nothing to tear down). func TestPlan_DecommissionAbsentGuestNoop(t *testing.T) { desired := DesiredState{Guests: map[int]DesiredGuest{7: {VMID: 7, Decommission: true}}} actual := ActualState{Guests: map[int]ActualGuest{}} // guest 7 not present if actions := Plan(desired, actual, DefaultNormalizers()); len(actions) != 0 { t.Errorf("decommission of absent guest emitted %+v, want none", actions) } } // CachingProvider: empty until Update, then serves the cached state + generation, and isolates // the cache from caller mutation. func TestCachingProvider_UpdateAndIsolation(t *testing.T) { p := NewCachingProvider() if p.Generation() != 0 { t.Fatalf("fresh generation = %d, want 0", p.Generation()) } if st, _ := p.Desired(context.Background()); len(st.Guests) != 0 { t.Fatalf("fresh provider should be empty, got %+v", st.Guests) } p.Update(3, DesiredState{Guests: map[int]DesiredGuest{5: {VMID: 5, Run: RunRunning}}}) if p.Generation() != 3 { t.Errorf("generation after update = %d, want 3", p.Generation()) } st, _ := p.Desired(context.Background()) if len(st.Guests) != 1 || st.Guests[5].Run != RunRunning { t.Fatalf("cached state = %+v", st.Guests) } // Mutating the returned copy must NOT affect the cache. st.Guests[5] = DesiredGuest{VMID: 5, Run: RunStopped} st.Guests[99] = DesiredGuest{VMID: 99} st2, _ := p.Desired(context.Background()) if len(st2.Guests) != 1 || st2.Guests[5].Run != RunRunning { t.Errorf("cache was mutated by a caller: %+v", st2.Guests) } }