Files
DDI/agent/core/leases_test.go
T
beug.k@beugsiud.deandClaude Opus 5.5 3065175673 feat(agent): enrollment, server connection and apply pipeline
- CLI: enroll, run, status, version, help (core.Main with a role
  factory); the agent binaries use a placeholder factory until the
  BIND and Kea roles are wired in.
- Enrollment: local ECDSA P-256 key and CSR, TLS 1.3 to the agent API
  with the server chain pinned to the CA fingerprint.
- ServerSource: mTLS gRPC with keepalive, Connect stream (Hello,
  Welcome, heartbeats, apply and renew commands), reconnect with
  exponential backoff and jitter, GetDesiredState polling as fallback,
  certificate renewal after half of the lifetime.
- Apply pipeline: fetch, bundle verification with replay protection,
  store, Validate, Activate, Health with retries, rollback to the last
  good revision; outcomes succeeded/rejected/rolled_back/failed are
  queued in state.json and reported; failed revisions are retried only
  by a newer revision or an explicit push.
- Lease reporting with change detection, persistent sequence and full
  snapshots at start, per connection and periodically.
- Tests with fake roles, a fake source and an in-process fake
  AgentService over real mTLS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:06:05 +02:00

193 lines
6.0 KiB
Go

// SPDX-License-Identifier: Apache-2.0
package core
import (
"context"
"errors"
"fmt"
"net/netip"
"testing"
"time"
)
func mustAddr(s string) netip.Addr { return netip.MustParseAddr(s) }
func lease(ip, mac string) Lease {
return Lease{
IP: mustAddr(ip), MAC: mac, Hostname: "host-" + ip, State: LeaseActive,
Expires: time.Unix(1_800_000_000, 0), Updated: time.Unix(1_700_000_000, 0), SubnetID: 1,
}
}
func TestDiffLeases(t *testing.T) {
a, b, c := lease("10.0.0.1", "aa:00:00:00:00:01"), lease("10.0.0.2", "aa:00:00:00:00:02"), lease("10.0.0.3", "aa:00:00:00:00:03")
changes, known := diffLeases(nil, []Lease{b, a, {}})
if len(changes) != 2 || changes[0].IP != a.IP || changes[1].IP != b.IP || len(known) != 2 {
t.Fatalf("initial changes = %+v", changes)
}
if changes, _ := diffLeases(known, []Lease{a, b}); len(changes) != 0 {
t.Errorf("unchanged leases reported: %+v", changes)
}
b2 := b
b2.Expires = b.Expires.Add(time.Hour)
changes, next := diffLeases(known, []Lease{b2, c})
if len(changes) != 3 {
t.Fatalf("changes = %+v", changes)
}
if changes[0].IP != a.IP || changes[0].State != LeaseExpired {
t.Errorf("disappeared lease = %+v", changes[0])
}
if !changes[1].Expires.Equal(b2.Expires) || changes[2].IP != c.IP {
t.Errorf("changes = %+v", changes)
}
if len(next) != 2 {
t.Errorf("next = %v", next)
}
// Equal instants in different locations are not a change.
b3 := b2
b3.Expires = b2.Expires.In(time.FixedZone("x", 3600))
if changes, _ := diffLeases(next, []Lease{b3, c}); len(changes) != 0 {
t.Errorf("time zone change reported: %+v", changes)
}
}
func TestPollLeases(t *testing.T) {
f := newFixture(t, KindDHCP)
ctx := context.Background()
tr := &leaseTracker{needFull: true}
a, b := lease("10.0.0.1", "aa:00:00:00:00:01"), lease("10.0.0.2", "aa:00:00:00:00:02")
f.role.setLeases([]Lease{a, b})
f.agent.pollLeases(ctx, f.role, tr)
batches := f.src.leaseBatches()
if len(batches) != 1 || !batches[0].FullSync || batches[0].Sequence != 1 || len(batches[0].Leases) != 2 {
t.Fatalf("batches = %+v", batches)
}
// No change: nothing is sent.
f.agent.pollLeases(ctx, f.role, tr)
if n := len(f.src.leaseBatches()); n != 1 {
t.Fatalf("%d batches after an unchanged poll", n)
}
// b released, a gone, c new: one delta.
b.State = LeaseReleased
c := lease("10.0.0.3", "aa:00:00:00:00:03")
f.role.setLeases([]Lease{b, c})
f.agent.pollLeases(ctx, f.role, tr)
batches = f.src.leaseBatches()
if len(batches) != 2 {
t.Fatalf("batches = %+v", batches)
}
d := batches[1]
if d.FullSync || d.Sequence != 2 || len(d.Leases) != 3 || d.Leases[0].State != LeaseExpired || d.Leases[1].State != LeaseReleased {
t.Fatalf("delta = %+v", d)
}
// A failed report forces a full snapshot next time.
f.src.mu.Lock()
f.src.leaseErr = errors.New("down")
f.src.mu.Unlock()
c.Hostname = "renamed"
f.role.setLeases([]Lease{b, c})
f.agent.pollLeases(ctx, f.role, tr)
if !tr.needFull {
t.Fatal("failure did not request a full snapshot")
}
f.src.mu.Lock()
f.src.leaseErr = nil
f.src.mu.Unlock()
f.agent.pollLeases(ctx, f.role, tr)
batches = f.src.leaseBatches()
last := batches[len(batches)-1]
if !last.FullSync || len(last.Leases) != 2 || last.Sequence != 4 {
t.Fatalf("snapshot after failure = %+v", last)
}
// The sequence survives a restart.
st, err := f.store.LoadState()
if err != nil {
t.Fatal(err)
}
if st.LeaseSequence != 4 {
t.Errorf("persisted sequence = %d", st.LeaseSequence)
}
// The full sync interval triggers a snapshot.
tr.lastFull = time.Now().Add(-2 * time.Hour)
f.agent.pollLeases(ctx, f.role, tr)
batches = f.src.leaseBatches()
if last := batches[len(batches)-1]; !last.FullSync || last.Sequence != 5 {
t.Errorf("periodic snapshot = %+v", last)
}
// Errors of the role and a busy role skip the round.
f.role.mu.Lock()
f.role.leasesErr = errors.New("socket gone")
f.role.mu.Unlock()
n := len(f.src.leaseBatches())
tr.needFull = true
f.agent.pollLeases(ctx, f.role, tr)
f.role.mu.Lock()
f.role.leasesErr = nil
f.role.mu.Unlock()
f.agent.roleMu.Lock()
f.agent.pollLeases(ctx, f.role, tr)
f.agent.roleMu.Unlock()
if len(f.src.leaseBatches()) != n {
t.Error("leases reported despite an error or a running apply")
}
}
func TestPollLeasesEmptySnapshot(t *testing.T) {
f := newFixture(t, KindDHCP)
tr := &leaseTracker{needFull: true}
f.agent.pollLeases(context.Background(), f.role, tr)
b := f.src.leaseBatches()
if len(b) != 1 || !b[0].FullSync || len(b[0].Leases) != 0 {
t.Fatalf("batches = %+v", b)
}
}
func TestLeaseBatching(t *testing.T) {
f := newFixture(t, KindDHCP)
var leases []Lease
for i := 0; i < 2*maxLeasesPerBatch+1; i++ {
ip := netip.AddrFrom4([4]byte{10, byte(i >> 16), byte(i >> 8), byte(i)})
leases = append(leases, Lease{IP: ip, MAC: fmt.Sprintf("aa:00:00:%02x:%02x:%02x", byte(i>>16), byte(i>>8), byte(i)), State: LeaseActive})
}
f.role.setLeases(leases)
f.agent.pollLeases(context.Background(), f.role, &leaseTracker{needFull: true})
b := f.src.leaseBatches()
if len(b) != 3 {
t.Fatalf("%d batches", len(b))
}
if !b[0].FullSync || b[1].FullSync || b[2].FullSync {
t.Errorf("full_sync flags = %v %v %v", b[0].FullSync, b[1].FullSync, b[2].FullSync)
}
if len(b[0].Leases) != maxLeasesPerBatch || len(b[2].Leases) != 1 || b[0].Sequence != 1 || b[2].Sequence != 3 {
t.Errorf("batches: %d/%d/%d leases, sequences %d..%d", len(b[0].Leases), len(b[1].Leases), len(b[2].Leases), b[0].Sequence, b[2].Sequence)
}
}
func TestLeaseLoopNotStartedForDNS(t *testing.T) {
f := newFixture(t, KindDNS)
role := dnsOnlyRole{f.role}
a, err := NewAgent(KindDNS, f.agent.cfg, role, f.store, &Identity{AgentID: testAgentID, BundleKey: f.agent.bundleKey}, f.src, testLogger())
if err != nil {
t.Fatal(err)
}
if _, ok := a.role.(LeaseSource); ok {
t.Fatal("DNS test role implements LeaseSource")
}
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
if err := a.Run(ctx); err != nil {
t.Fatal(err)
}
if n := len(f.src.leaseBatches()); n != 0 {
t.Errorf("%d lease batches from a DNS agent", n)
}
}