slice 10A: activate the control envelope (Down channel) + hub-backed desired provider (v0.15.0)

The control envelope becomes live: the agent caches the hub's desired-state +
generation and re-fetches GET /hosts/{id}/desired-state only when the
generation advances. A new internal/desired Syncer maps the wire shape into a
reconcile.CachingProvider feeding the engine; benign deltas reconcile, an
explicit guest decommission is gated pending_signature (exec is 10B). Adds the
DesiredStateResponse/WireDesiredState wire types + Client.FetchDesiredState +
the loop EnvelopeObserver seam. Cross-repo golden (envelope + desired-state)
byte-identical with the hub.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 19:02:59 +02:00
parent aa4dfb75ea
commit 8ecf8929fb
19 changed files with 836 additions and 59 deletions
+12 -2
View File
@@ -27,6 +27,7 @@ import (
"gitea.dooplex.hu/admin/felhom-agent/internal/authz"
"gitea.dooplex.hu/admin/felhom-agent/internal/backup"
"gitea.dooplex.hu/admin/felhom-agent/internal/config"
"gitea.dooplex.hu/admin/felhom-agent/internal/desired"
"gitea.dooplex.hu/admin/felhom-agent/internal/escrow"
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
"gitea.dooplex.hu/admin/felhom-agent/internal/localapi"
@@ -40,7 +41,7 @@ import (
// 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.14.0"
var version = "0.15.0"
func main() {
var (
@@ -237,6 +238,15 @@ func runDaemon(cfg config.Config, logger *slog.Logger) int {
loop := hub.NewLoop(collector, client, time.Duration(hcfg.PollSeconds)*time.Second, logger)
interval := time.Duration(hcfg.PollSeconds) * time.Second
// Desired-state provider (slice 10A): the hub-served target the reconcile engine converges
// toward. Starts EMPTY (generation 0) — reconcile is a live no-op until the hub serves intent,
// exactly like the slice-4 EmptyProvider. The desired.Syncer (wired below) fills it when the
// control envelope's generation advances. Replaces EmptyProvider in the engine.
desiredProvider := reconcile.NewCachingProvider()
// The "Down" channel sync hook: on each heartbeat, fetch desired-state when the generation
// advances. The loop calls it via the EnvelopeObserver seam (hub does not import desired).
loop.SetEnvelopeObserver(desired.NewSyncer(client, desiredProvider, logger))
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
logger.Info("felhom-agent daemon starting",
@@ -304,7 +314,7 @@ func runDaemon(cfg config.Config, logger *slog.Logger) int {
API: px,
Queue: queue,
Journal: journal,
Provider: reconcile.EmptyProvider{}, // slice 4: no live desired-state source
Provider: desiredProvider, // slice 10A: hub-served desired-state (empty until the hub serves intent)
Gate: gate,
HostID: cfg.Hub.HostID,
Logger: logger,