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:
+60
-16
@@ -15,7 +15,7 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -23,11 +23,12 @@ import (
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
|
||||
applog "gitea.dooplex.hu/admin/felhom-agent/internal/log"
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/reconcile"
|
||||
)
|
||||
|
||||
// version is the agent version. Overridable at build time with
|
||||
// -ldflags "-X main.version=<v>"; defaults to the in-repo CHANGELOG version.
|
||||
var version = "0.3.2"
|
||||
var version = "0.4.0-rc1"
|
||||
|
||||
func main() {
|
||||
var (
|
||||
@@ -109,19 +110,66 @@ func runDaemon(cfg config.Config, logger *slog.Logger) int {
|
||||
hcfg := cfg.Hub.WithDefaults()
|
||||
collector := hub.NewCollector(px, hub.SystemctlProber{}, cfg.Hub.HostID, version, logger)
|
||||
loop := hub.NewLoop(collector, client, time.Duration(hcfg.PollSeconds)*time.Second, logger)
|
||||
interval := time.Duration(hcfg.PollSeconds) * time.Second
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
logger.Info("felhom-agent daemon starting",
|
||||
"version", version, "host_id", cfg.Hub.HostID, "hub_url", cfg.Hub.URL,
|
||||
"interval_s", hcfg.PollSeconds) // hub key intentionally not logged
|
||||
if err := loop.Run(ctx); err != nil {
|
||||
logger.Error("daemon: loop exited with error", "err", err)
|
||||
|
||||
// Reconcile (slice 4) runs alongside the hub loop, sharing the per-guest queue
|
||||
// (doc 03 §10). At slice 4 the desired-state provider is empty (no hub serving
|
||||
// until slice 10), so reconcile is a live no-op: it reads state and computes an
|
||||
// empty action set each tick, mutating nothing. The daemon must run cleanly with
|
||||
// no desired state and no signers configured — so a journal-open failure is logged
|
||||
// and reconcile proceeds journal-less (it has nothing destructive to journal yet).
|
||||
queue := reconcile.NewQueue()
|
||||
defer queue.Close()
|
||||
var journal *reconcile.Journal
|
||||
if jp := reconcileJournalPath(cfg); jp != "" {
|
||||
if err := os.MkdirAll(filepath.Dir(jp), 0o700); err != nil {
|
||||
logger.Warn("daemon: cannot ensure journal dir; reconcile runs without a journal", "path", jp, "err", err)
|
||||
} else if j, err := reconcile.OpenJournal(jp); err != nil {
|
||||
logger.Warn("daemon: cannot open op journal; reconcile runs without a journal", "path", jp, "err", err)
|
||||
} else {
|
||||
journal = j
|
||||
defer journal.Close()
|
||||
}
|
||||
}
|
||||
engine := reconcile.NewEngine(reconcile.EngineOptions{
|
||||
API: px,
|
||||
Queue: queue,
|
||||
Journal: journal,
|
||||
Provider: reconcile.EmptyProvider{}, // slice 4: no live desired-state source
|
||||
Logger: logger,
|
||||
})
|
||||
|
||||
// Run reconcile and the hub loop concurrently; either returning ends the daemon.
|
||||
errc := make(chan error, 2)
|
||||
go func() { errc <- engine.Run(ctx, interval) }()
|
||||
go func() { errc <- loop.Run(ctx) }()
|
||||
|
||||
err = <-errc
|
||||
stop() // tear down the sibling on the first exit
|
||||
<-errc // wait for it
|
||||
if err != nil && err != context.Canceled {
|
||||
logger.Error("daemon: exited with error", "err", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// reconcileJournalPath chooses the op-journal path: a `journal.log` sibling of the
|
||||
// configured nonce store (both are durable agent state), falling back to the standard
|
||||
// host state dir when the nonce store is unset.
|
||||
func reconcileJournalPath(cfg config.Config) string {
|
||||
if p := cfg.Authz.NonceStorePath; p != "" {
|
||||
return filepath.Join(filepath.Dir(p), "journal.log")
|
||||
}
|
||||
return "/var/lib/felhom-agent/journal.log"
|
||||
}
|
||||
|
||||
// runSelftestHub validates hub config, does ONE collect + report, and prints the
|
||||
// report it would send plus the envelope it got back.
|
||||
func runSelftestHub(ctx context.Context, cfg config.Config, logger *slog.Logger) int {
|
||||
@@ -317,10 +365,11 @@ func selftestSetConfig(ctx context.Context, client *proxmox.Client, vmid int) in
|
||||
return 1
|
||||
}
|
||||
// PVE normalizes `description` by appending a trailing newline on read, so all
|
||||
// comparisons here use normDesc (strip trailing newlines) and restores write the
|
||||
// normalized original — otherwise an exact-match check sees false drift. This is
|
||||
// load-bearing intel for slice-4 reconcile (compare descriptions normalized).
|
||||
origDesc = normDesc(origDesc)
|
||||
// comparisons here use the shared reconcile.NormDescription (strip trailing
|
||||
// newlines) and restores write the normalized original — otherwise an exact-match
|
||||
// check sees false drift. Same helper the slice-4 reconciler uses to normalize
|
||||
// description, so the quirk has one source of truth.
|
||||
origDesc = reconcile.NormDescription(origDesc)
|
||||
|
||||
// 2. Write the marker.
|
||||
marker := "felhom-selftest " + time.Now().UTC().Format(time.RFC3339)
|
||||
@@ -339,7 +388,7 @@ func selftestSetConfig(ctx context.Context, client *proxmox.Client, vmid int) in
|
||||
fmt.Printf(" [FAIL] %-16s decode description (verify): %v\n", "setconfig", err)
|
||||
return 1
|
||||
}
|
||||
if !present || normDesc(got) != marker {
|
||||
if !present || reconcile.NormDescription(got) != marker {
|
||||
fmt.Printf(" [FAIL] %-16s write did not land: present=%v got=%q want=%q\n", "setconfig", present, got, marker)
|
||||
return 1
|
||||
}
|
||||
@@ -368,8 +417,8 @@ func selftestSetConfig(ctx context.Context, client *proxmox.Client, vmid int) in
|
||||
return 1
|
||||
}
|
||||
if origPresent {
|
||||
if !present || normDesc(got) != origDesc {
|
||||
fmt.Printf(" [FAIL] %-16s revert did not restore: present=%v got=%q want=%q\n", "setconfig-revert", present, normDesc(got), origDesc)
|
||||
if !present || reconcile.NormDescription(got) != origDesc {
|
||||
fmt.Printf(" [FAIL] %-16s revert did not restore: present=%v got=%q want=%q\n", "setconfig-revert", present, reconcile.NormDescription(got), origDesc)
|
||||
return 1
|
||||
}
|
||||
} else if present {
|
||||
@@ -407,11 +456,6 @@ func applySetConfig(ctx context.Context, client *proxmox.Client, vmid int, step
|
||||
return 0
|
||||
}
|
||||
|
||||
// normDesc strips trailing newlines that PVE appends to the `description` field
|
||||
// on read, so a written value round-trips equal. (PVE stores `description` with a
|
||||
// trailing "\n"; comparing raw would always mismatch.)
|
||||
func normDesc(s string) string { return strings.TrimRight(s, "\n") }
|
||||
|
||||
// extraString reads a string-valued key from GuestConfig.Extra (raw JSON). It
|
||||
// returns ("", false, nil) when the key is absent, and decodes the JSON string
|
||||
// otherwise.
|
||||
|
||||
Reference in New Issue
Block a user