8ecf8929fb
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>
111 lines
4.5 KiB
Go
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)
|
|
}
|
|
}
|