- 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>
193 lines
6.0 KiB
Go
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)
|
|
}
|
|
}
|