Files
felhom-agent/internal/desired/syncer_test.go
T
admin 8ecf8929fb 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>
2026-06-10 19:02:59 +02:00

118 lines
4.1 KiB
Go

package desired
import (
"context"
"errors"
"io"
"log/slog"
"testing"
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
"gitea.dooplex.hu/admin/felhom-agent/internal/reconcile"
)
func quiet() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
// fakeFetcher counts FetchDesiredState calls and returns a fixed response (or error).
type fakeFetcher struct {
resp *hub.DesiredStateResponse
err error
calls int
}
func (f *fakeFetcher) FetchDesiredState(context.Context) (*hub.DesiredStateResponse, error) {
f.calls++
return f.resp, f.err
}
func env(gen int64, signed bool) *hub.ControlEnvelope {
return &hub.ControlEnvelope{DesiredGeneration: gen, HasSignedOps: signed}
}
// The headline caching behaviour: desired-state is fetched ONCE when the generation advances, and
// NOT re-fetched while the generation is unchanged (the heartbeat stays light).
func TestSyncer_FetchesOnceOnGenerationAdvance(t *testing.T) {
run := "running"
f := &fakeFetcher{resp: &hub.DesiredStateResponse{
Generation: 1,
DesiredState: hub.WireDesiredState{Guests: []hub.WireDesiredGuest{
{VMID: 100, Run: run},
{VMID: 200, Decommission: true},
}},
}}
p := reconcile.NewCachingProvider()
s := NewSyncer(f, p, quiet())
ctx := context.Background()
// Generation 0 (fresh host, no intent) → NO fetch.
s.OnEnvelope(ctx, env(0, false))
if f.calls != 0 {
t.Fatalf("fetched %d times at generation 0, want 0", f.calls)
}
// Generation advances to 1 → fetch exactly once, cache updated.
s.OnEnvelope(ctx, env(1, false))
if f.calls != 1 {
t.Fatalf("fetched %d times on advance, want 1", f.calls)
}
if p.Generation() != 1 {
t.Errorf("cached generation = %d, want 1", p.Generation())
}
st, _ := p.Desired(ctx)
if st.Guests[100].Run != reconcile.RunRunning {
t.Errorf("guest 100 run = %q, want running", st.Guests[100].Run)
}
if !st.Guests[200].Decommission {
t.Errorf("guest 200 decommission = false, want true")
}
// Same generation on the next heartbeats → NO re-fetch (cached).
s.OnEnvelope(ctx, env(1, false))
s.OnEnvelope(ctx, env(1, false))
if f.calls != 1 {
t.Errorf("re-fetched on an unchanged generation (calls=%d, want 1)", f.calls)
}
// A further advance → one more fetch.
f.resp = &hub.DesiredStateResponse{Generation: 2, DesiredState: hub.WireDesiredState{}}
s.OnEnvelope(ctx, env(2, false))
if f.calls != 2 || p.Generation() != 2 {
t.Errorf("second advance: calls=%d gen=%d, want 2/2", f.calls, p.Generation())
}
}
// A fetch failure keeps the last-known cache (the engine keeps reconciling toward it) and does not
// advance the cached generation (so the next heartbeat retries).
func TestSyncer_FetchFailureKeepsCache(t *testing.T) {
p := reconcile.NewCachingProvider()
p.Update(1, reconcile.DesiredState{Guests: map[int]reconcile.DesiredGuest{100: {VMID: 100, Run: reconcile.RunRunning}}})
f := &fakeFetcher{err: errors.New("hub down")}
s := NewSyncer(f, p, quiet())
s.OnEnvelope(context.Background(), env(5, false)) // generation jumped, but fetch fails
if p.Generation() != 1 {
t.Errorf("generation advanced to %d despite fetch failure, want kept at 1", p.Generation())
}
st, _ := p.Desired(context.Background())
if st.Guests[100].Run != reconcile.RunRunning {
t.Errorf("cache lost on fetch failure: %+v", st.Guests)
}
}
// The fetched generation (not the envelope's) is what gets cached — robust to a generation that
// advanced again between the heartbeat and the fetch.
func TestSyncer_CachesFetchedGeneration(t *testing.T) {
f := &fakeFetcher{resp: &hub.DesiredStateResponse{Generation: 7, DesiredState: hub.WireDesiredState{}}}
p := reconcile.NewCachingProvider()
s := NewSyncer(f, p, quiet())
s.OnEnvelope(context.Background(), env(5, false)) // envelope said 5, fetch returned 7
if p.Generation() != 7 {
t.Errorf("cached generation = %d, want 7 (the fetched generation)", p.Generation())
}
// A later envelope at generation 6 must NOT trigger a re-fetch (we already have 7).
s.OnEnvelope(context.Background(), env(6, false))
if f.calls != 1 {
t.Errorf("re-fetched at generation 6 when cache is 7 (calls=%d)", f.calls)
}
}