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,87 @@
|
||||
package reconcile
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormDescription_TrimsTrailingNewlines(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"felhom-selftest 2026": "felhom-selftest 2026",
|
||||
"felhom-selftest 2026\n": "felhom-selftest 2026", // PVE's single trailing \n
|
||||
"x\n\n": "x", // defensive: multiple
|
||||
"": "",
|
||||
"\n": "",
|
||||
"keep trailing spaces ": "keep trailing spaces ", // only newlines stripped
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := NormDescription(in); got != want {
|
||||
t.Errorf("NormDescription(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
// Idempotent.
|
||||
if NormDescription(NormDescription("a\n")) != NormDescription("a\n") {
|
||||
t.Error("NormDescription not idempotent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldNormalizers_DescriptionRoundTrip(t *testing.T) {
|
||||
n := DefaultNormalizers()
|
||||
// A value the agent wrote ("...Z") and read back with PVE's newline ("...Z\n")
|
||||
// must compare EQUAL — the whole point of the layer.
|
||||
if !n.Equal("description", "felhom-op", "felhom-op\n") {
|
||||
t.Error("description round-trip should normalize equal")
|
||||
}
|
||||
if n.Equal("description", "felhom-op", "different") {
|
||||
t.Error("genuinely different descriptions must not be equal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldNormalizers_UnknownFieldIsIdentity(t *testing.T) {
|
||||
n := DefaultNormalizers()
|
||||
if n.Norm("cores", "2\n") != "2\n" {
|
||||
t.Error("a field with no normalizer must compare verbatim")
|
||||
}
|
||||
if n.Equal("cores", "2", "2\n") {
|
||||
t.Error("unknown field must not normalize away differences")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFieldNormalizers_ExtensibilitySeam proves the structure accepts new quirks
|
||||
// (boolean coercion, list ordering) the way it will as they're discovered — the task's
|
||||
// "structure it so other normalizers slot in." These are synthetic, not production.
|
||||
func TestFieldNormalizers_ExtensibilitySeam(t *testing.T) {
|
||||
booleanCoerce := func(s string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "1", "true", "on", "yes":
|
||||
return "1"
|
||||
default:
|
||||
return "0"
|
||||
}
|
||||
}
|
||||
sortCSV := func(s string) string {
|
||||
parts := strings.Split(s, ",")
|
||||
sort.Strings(parts)
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
n := FieldNormalizers{
|
||||
"description": NormDescription,
|
||||
"onboot": booleanCoerce,
|
||||
"tags": sortCSV,
|
||||
}
|
||||
|
||||
if !n.Equal("onboot", "true", "1") || !n.Equal("onboot", "on", "1") {
|
||||
t.Error("boolean coercion normalizer should equate truthy forms")
|
||||
}
|
||||
if n.Equal("onboot", "true", "0") {
|
||||
t.Error("boolean coercion must still distinguish true from false")
|
||||
}
|
||||
if !n.Equal("tags", "b,a,c", "a,b,c") {
|
||||
t.Error("list-ordering normalizer should equate reordered lists")
|
||||
}
|
||||
// The built-in still works alongside the synthetic ones.
|
||||
if !n.Equal("description", "d", "d\n") {
|
||||
t.Error("description normalizer should coexist with added ones")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user