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>
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user