Files
felhom-agent/internal/reconcile/normalize.go
T
admin 05c450147c 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>
2026-06-08 23:21:55 +02:00

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") }