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:
@@ -35,6 +35,47 @@ func (r *fakeReporter) Report(ctx context.Context, _ *HostReport) (*ControlEnvel
|
||||
return r.env, nil
|
||||
}
|
||||
|
||||
// recordingObserver records the envelopes the loop hands it (slice 10A EnvelopeObserver seam).
|
||||
type recordingObserver struct{ envs []*ControlEnvelope }
|
||||
|
||||
func (o *recordingObserver) OnEnvelope(_ context.Context, e *ControlEnvelope) { o.envs = append(o.envs, e) }
|
||||
|
||||
// The loop notifies the EnvelopeObserver once per successful cycle (with the envelope) AND still
|
||||
// adopts PollIntervalSeconds — the two are independent.
|
||||
func TestLoop_CycleNotifiesObserverAndAdoptsInterval(t *testing.T) {
|
||||
var cn, rn int32
|
||||
env := &ControlEnvelope{DesiredGeneration: 3, HasSignedOps: true, PollIntervalSeconds: intPtr(120)}
|
||||
loop := NewLoop(
|
||||
&fakeCollector{report: &HostReport{}, n: &cn},
|
||||
&fakeReporter{env: env, n: &rn},
|
||||
900*time.Second, quietLogger())
|
||||
obs := &recordingObserver{}
|
||||
loop.SetEnvelopeObserver(obs)
|
||||
|
||||
got := loop.cycle(context.Background(), 900*time.Second)
|
||||
if len(obs.envs) != 1 || obs.envs[0].DesiredGeneration != 3 || !obs.envs[0].HasSignedOps {
|
||||
t.Fatalf("observer envelopes = %+v, want 1 with gen 3 + has_signed_ops", obs.envs)
|
||||
}
|
||||
if got != 120*time.Second {
|
||||
t.Errorf("poll interval = %v, want 120s adopted alongside the observer notify", got)
|
||||
}
|
||||
}
|
||||
|
||||
// On a report failure the observer is NOT notified (there is no envelope to act on).
|
||||
func TestLoop_ReportErrorSkipsObserver(t *testing.T) {
|
||||
var cn, rn int32
|
||||
loop := NewLoop(
|
||||
&fakeCollector{report: &HostReport{}, n: &cn},
|
||||
&fakeReporter{env: &ControlEnvelope{}, errSeq: []error{errors.New("hub 5xx")}, n: &rn},
|
||||
900*time.Second, quietLogger())
|
||||
obs := &recordingObserver{}
|
||||
loop.SetEnvelopeObserver(obs)
|
||||
loop.cycle(context.Background(), 900*time.Second)
|
||||
if len(obs.envs) != 0 {
|
||||
t.Errorf("observer notified on a report error: %+v", obs.envs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClampInterval(t *testing.T) {
|
||||
cases := []struct {
|
||||
in int
|
||||
|
||||
Reference in New Issue
Block a user