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
+56
View File
@@ -3,6 +3,7 @@ package reconcile
import (
"context"
"encoding/json"
"sync"
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
@@ -50,6 +51,14 @@ type DesiredGuest struct {
// Description, when non-nil, manages the cosmetic `description` field (the first
// proven SetConfig round-trip, slice-4 pre-check). Nil = unmanaged.
Description *string
// Decommission, when true, is an EXPLICIT destructive intent to tear the guest down
// (slice 10A). It is the canonical destructive desired-state delta: the planner emits an
// ActionDecommission, which classifies ClassDecommission → Destructive → the gate refuses it
// `pending_signature` unless a verified operator signature is present. 10A never has a signer,
// so a decommission is always gated (never executed); the signed execution path is 10B. An
// explicit flag (not "absent from the desired list") is the safe design — a partial/empty hub
// list can never silently mass-destroy guests.
Decommission bool
}
// DesiredState is the vmid-keyed target for this host. At slice 4 the only live
@@ -101,6 +110,53 @@ type StaticProvider struct{ State DesiredState }
// Desired returns the static state.
func (p StaticProvider) Desired(context.Context) (DesiredState, error) { return p.State, nil }
// CachingProvider is the slice-10A production provider: a thread-safe cache of the hub-served
// DesiredState plus the generation it corresponds to. The hub-sync layer (internal/desired) calls
// Update when the heartbeat envelope's generation advances and a fresh fetch arrives; the engine
// reads the cache via Desired each reconcile tick. Until the first Update it returns an empty
// state (generation 0) — so reconcile is a live no-op exactly like EmptyProvider, with zero
// mutations, which is the correct cold-start behaviour.
type CachingProvider struct {
mu sync.RWMutex
state DesiredState
gen int64
}
// NewCachingProvider builds an empty provider (generation 0, no guests).
func NewCachingProvider() *CachingProvider {
return &CachingProvider{state: DesiredState{Guests: map[int]DesiredGuest{}}}
}
// Desired returns the cached state (a shallow copy of the guest map so a caller can't mutate the
// cache, and a concurrent Update can't race the read).
func (p *CachingProvider) Desired(context.Context) (DesiredState, error) {
p.mu.RLock()
defer p.mu.RUnlock()
out := DesiredState{Guests: make(map[int]DesiredGuest, len(p.state.Guests))}
for k, v := range p.state.Guests {
out.Guests[k] = v
}
return out, nil
}
// Update replaces the cached state + generation (called by the sync layer on a generation advance).
func (p *CachingProvider) Update(generation int64, state DesiredState) {
p.mu.Lock()
defer p.mu.Unlock()
if state.Guests == nil {
state.Guests = map[int]DesiredGuest{}
}
p.state = state
p.gen = generation
}
// Generation returns the cached generation (the agent's view of "what I have applied from").
func (p *CachingProvider) Generation() int64 {
p.mu.RLock()
defer p.mu.RUnlock()
return p.gen
}
// GuestAPI is the narrow Proxmox surface the engine needs: read actual state and
// dispatch the benign-on-existing-guest mutations. *proxmox.Client satisfies it; a
// fake satisfies it in tests. Every mutating call returns a UPID (or "" for the