05c450147c
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>
48 lines
2.1 KiB
Go
48 lines
2.1 KiB
Go
package reconcile
|
|
|
|
import "strings"
|
|
|
|
// The normalization layer keeps Proxmox round-trip quirks from reading as drift.
|
|
// Reconcile compares NORMALIZED desired-vs-actual so a value the agent wrote and then
|
|
// read back equal does not look like a change. `description`'s trailing newline (the
|
|
// first proven case, slice-4 pre-check) is the seed; the structure is a per-field
|
|
// registry so other quirks (omitted-default fields, boolean coercions, list ordering)
|
|
// slot in as they are discovered — each is a Normalizer mapped to a field name.
|
|
|
|
// Normalizer maps a raw field value to its canonical comparison form. It must be
|
|
// idempotent: Normalizer(Normalizer(x)) == Normalizer(x).
|
|
type Normalizer func(string) string
|
|
|
|
// FieldNormalizers maps a Proxmox config field name to its Normalizer. A field with
|
|
// no entry compares verbatim (identity).
|
|
type FieldNormalizers map[string]Normalizer
|
|
|
|
// DefaultNormalizers is the production set. Today only `description` needs one (PVE
|
|
// appends a trailing newline on read). New quirks are added here as they are found.
|
|
func DefaultNormalizers() FieldNormalizers {
|
|
return FieldNormalizers{
|
|
"description": NormDescription,
|
|
}
|
|
}
|
|
|
|
// Norm returns the canonical comparison form of value for field, applying the
|
|
// field's Normalizer if one is registered, else the value unchanged.
|
|
func (n FieldNormalizers) Norm(field, value string) string {
|
|
if f, ok := n[field]; ok && f != nil {
|
|
return f(value)
|
|
}
|
|
return value
|
|
}
|
|
|
|
// Equal reports whether two raw values for field are equal AFTER normalization.
|
|
func (n FieldNormalizers) Equal(field, a, b string) bool {
|
|
return n.Norm(field, a) == n.Norm(field, b)
|
|
}
|
|
|
|
// NormDescription strips the trailing newline(s) PVE appends to the LXC `description`
|
|
// field on read, so a written value round-trips equal. (Proven slice-4 pre-check:
|
|
// PVE stores `description` with a trailing "\n"; a verbatim compare always mismatches.)
|
|
// Exported so the --selftest=task description round-trip uses the SAME helper the
|
|
// reconciler does — one source of truth for the quirk.
|
|
func NormDescription(s string) string { return strings.TrimRight(s, "\n") }
|