Files
felhom-agent/internal/reconcile/slice10_test.go
T
admin 8ecf8929fb slice 10A: activate the control envelope (Down channel) + hub-backed desired provider (v0.15.0)
The control envelope becomes live: the agent caches the hub's desired-state +
generation and re-fetches GET /hosts/{id}/desired-state only when the
generation advances. A new internal/desired Syncer maps the wire shape into a
reconcile.CachingProvider feeding the engine; benign deltas reconcile, an
explicit guest decommission is gated pending_signature (exec is 10B). Adds the
DesiredStateResponse/WireDesiredState wire types + Client.FetchDesiredState +
the loop EnvelopeObserver seam. Cross-repo golden (envelope + desired-state)
byte-identical with the hub.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 19:02:59 +02:00

111 lines
4.5 KiB
Go

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)
}
}