v0.4.0-rc1: slice 4 Phase A — reconcile engine (structural, runs live unfed)
New internal/reconcile package: the agent-side control core's structural half. - Per-guest serializer Queue (doc 03 §10): the single choke point all mutation sources funnel through; same-vmid serial in submit order, different vmids parallel (cond-var FIFO lanes). - Desired-state model + DesiredProvider seam; EmptyProvider is the only live source at slice 4 (no hub serving until slice 10) so the live engine computes an empty action set and performs zero mutations. - Normalization layer (FieldNormalizers): normalized desired-vs-actual so Proxmox round-trip quirks don't read as drift. normDesc promoted out of main.go to reconcile.NormDescription; selftest uses the shared helper. - Plan (pure diff): minimal benign action set (Start/Stop/SetConfig) for guests in both desired and actual; provision/destroy out of scope here. - Engine: dispatches onto the shared queue; honors the dual-mode SetConfig contract (UPID -> WaitTask; empty UPID -> synchronous success). - Durable op journal + idempotency store (mirrors authz.FileNonceStore): in-flight task ids for crash detection + AlreadyApplied dedupe across restart. - Wired into runDaemon alongside the hub loop, sharing the queue; runs cleanly with no desired state and no signers. Full module race-clean and vet-clean on the Linux build server. CHECKPOINT: Phase A only. Awaiting validation before Phase B (the reversibility gate + signed-op consuming layer, landing v0.4.0). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
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_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
|
||||
}
|
||||
Reference in New Issue
Block a user