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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user