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:
+23
-2
@@ -20,6 +20,15 @@ type collectorIface interface {
|
||||
Collect(ctx context.Context) (*HostReport, error)
|
||||
}
|
||||
|
||||
// EnvelopeObserver is notified of the hub's control envelope on every heartbeat (slice 10A).
|
||||
// The desired-state sync layer (internal/desired) implements it: when DesiredGeneration advances
|
||||
// past its cache it fetches the full desired-state and updates the engine's provider. Defined
|
||||
// here (consumer-side) so hub does NOT import the desired/reconcile packages — same seam pattern
|
||||
// as the collector's StorageObserver. A nil observer (no desired-state wiring) is a clean no-op.
|
||||
type EnvelopeObserver interface {
|
||||
OnEnvelope(ctx context.Context, env *ControlEnvelope)
|
||||
}
|
||||
|
||||
// Loop is the agent's first daemon run loop: collect a host-report, POST it, adopt
|
||||
// the hub's cadence, repeat. It is resilient — a collect or report error is logged
|
||||
// and the loop continues (the data plane is independent of the agent; a hub outage
|
||||
@@ -30,7 +39,8 @@ type Loop struct {
|
||||
client reporter
|
||||
interval time.Duration
|
||||
logger *slog.Logger
|
||||
trigger <-chan struct{} // optional: an out-of-band report request (storage watchdog)
|
||||
trigger <-chan struct{} // optional: an out-of-band report request (storage watchdog)
|
||||
observer EnvelopeObserver // optional: the slice-10A desired-state sync hook
|
||||
}
|
||||
|
||||
// NewLoop builds the loop. interval is the starting cadence (the hub may override it
|
||||
@@ -48,6 +58,11 @@ func NewLoop(collector collectorIface, client reporter, interval time.Duration,
|
||||
// so this fires at most once per debounce window.
|
||||
func (l *Loop) SetTrigger(ch <-chan struct{}) { l.trigger = ch }
|
||||
|
||||
// SetEnvelopeObserver wires the slice-10A desired-state sync hook. It is called once per cycle
|
||||
// with the hub's control envelope (after the interval is adopted), so the sync layer can fetch
|
||||
// desired-state when the generation advances. Optional — unset is a clean no-op.
|
||||
func (l *Loop) SetEnvelopeObserver(o EnvelopeObserver) { l.observer = o }
|
||||
|
||||
// Run reports immediately, then on each tick, until ctx is cancelled (then nil).
|
||||
func (l *Loop) Run(ctx context.Context) error {
|
||||
interval := l.interval
|
||||
@@ -97,9 +112,15 @@ func (l *Loop) cycle(ctx context.Context, current time.Duration) time.Duration {
|
||||
}
|
||||
l.logger.Debug("hub: report sent",
|
||||
"guests", len(report.Guests),
|
||||
// reserved/forward-compat envelope fields — logged only, never acted on (slice 4).
|
||||
"blocked", env.Blocked, "desired_generation", env.DesiredGeneration, "has_signed_ops", env.HasSignedOps)
|
||||
|
||||
// Slice 10A: hand the envelope to the desired-state sync hook (fetch desired-state on a
|
||||
// generation advance). Done off the report's critical path semantics — a sync/fetch failure
|
||||
// is the observer's concern and never affects the heartbeat cadence below.
|
||||
if l.observer != nil {
|
||||
l.observer.OnEnvelope(ctx, env)
|
||||
}
|
||||
|
||||
if env.PollIntervalSeconds == nil {
|
||||
return current
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user