Files
DDI/agent/core/helpers_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

698 lines
19 KiB
Go

// SPDX-License-Identifier: Apache-2.0
package core
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"errors"
"fmt"
"io"
"log/slog"
"math/big"
"net"
"net/url"
"os"
"path/filepath"
"slices"
"sync"
"testing"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/keepalive"
"google.golang.org/grpc/peer"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/durationpb"
"git.beugsiud.de/beug.k/DDI/internal/agentpki"
"git.beugsiud.de/beug.k/DDI/internal/bundle"
agentv1 "git.beugsiud.de/beug.k/DDI/proto/gen/ddi/agent/v1"
)
const testAgentID = "0b6f3c1e-5d2a-4c8e-9f10-123456789abc"
func testLogger() *slog.Logger {
if testing.Verbose() {
return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))
}
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// waitFor polls cond until it holds or the timeout expires.
func waitFor(t *testing.T, what string, cond func() bool) {
t.Helper()
deadline := time.Now().Add(15 * time.Second)
for !cond() {
if time.Now().After(deadline) {
t.Fatalf("timeout waiting for %s", what)
}
time.Sleep(10 * time.Millisecond)
}
}
// ---- PKI ----
type testPKI struct {
ca *x509.Certificate
caKey *ecdsa.PrivateKey
serverCert tls.Certificate
}
func newTestPKI(t *testing.T) *testPKI {
t.Helper()
caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "test agent CA"},
NotBefore: time.Now().Add(-24 * time.Hour), NotAfter: time.Now().Add(24 * time.Hour),
IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &caKey.PublicKey, caKey)
if err != nil {
t.Fatal(err)
}
ca, err := x509.ParseCertificate(der)
if err != nil {
t.Fatal(err)
}
p := &testPKI{ca: ca, caKey: caKey}
srvKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
srvDER, err := x509.CreateCertificate(rand.Reader, &x509.Certificate{
SerialNumber: big.NewInt(2), Subject: pkix.Name{CommonName: "localhost"},
DNSNames: []string{"localhost"}, IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1)},
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}, ca, &srvKey.PublicKey, caKey)
if err != nil {
t.Fatal(err)
}
p.serverCert = tls.Certificate{Certificate: [][]byte{srvDER, ca.Raw}, PrivateKey: srvKey}
return p
}
func (p *testPKI) issueClient(pub crypto.PublicKey, agentID string, notBefore, notAfter time.Time) (*x509.Certificate, error) {
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 127))
if err != nil {
return nil, err
}
der, err := x509.CreateCertificate(rand.Reader, &x509.Certificate{
SerialNumber: serial, Subject: pkix.Name{CommonName: agentID},
URIs: []*url.URL{agentpki.AgentURI(agentID)},
NotBefore: notBefore, NotAfter: notAfter,
KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
}, p.ca, pub, p.caKey)
if err != nil {
return nil, err
}
return x509.ParseCertificate(der)
}
// ---- bundles ----
func newSigningKey(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) {
t.Helper()
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
return pub, priv
}
// testArtifacts returns the artifacts of a test revision.
func testArtifacts(rev uint64) []bundle.Artifact {
return []bundle.Artifact{
{Path: "ddi-suite.conf", Mode: 0o640, Content: fmt.Appendf(nil, "// revision %d\n", rev)},
{Path: "zones/db.example.com", Mode: 0o644, Content: fmt.Appendf(nil, "example.com. SOA ns 1 %d\n", rev)},
}
}
func signBundle(t testing.TB, key ed25519.PrivateKey, agentID string, role Kind, rev uint64) *Bundle {
t.Helper()
arts := testArtifacts(rev)
manifest, sig, err := bundle.Sign(key, agentID, string(role), rev, time.Unix(1_700_000_000, 0), arts)
if err != nil {
t.Fatal(err)
}
b := &Bundle{Manifest: manifest, Signature: sig}
for _, a := range arts {
b.Artifacts = append(b.Artifacts, BundleArtifact{Path: a.Path, Content: a.Content})
}
return b
}
// ---- fake role ----
type fakeRole struct {
kind Kind
mu sync.Mutex
validateErr map[uint64]error
activateErr map[uint64]error
unhealthy map[uint64]bool
active uint64
calls []string
healthCalls map[uint64]int
leases []Lease
leasesErr error
versionErr error
}
func newFakeRole(kind Kind) *fakeRole {
return &fakeRole{
kind: kind, validateErr: map[uint64]error{}, activateErr: map[uint64]error{},
unhealthy: map[uint64]bool{}, healthCalls: map[uint64]int{},
}
}
func (r *fakeRole) Kind() Kind { return r.kind }
func (r *fakeRole) ServiceVersion(context.Context) (string, error) {
r.mu.Lock()
defer r.mu.Unlock()
if r.versionErr != nil {
return "", r.versionErr
}
return "Fake 1.0", nil
}
// checkFiles verifies that the stored revision holds its artifacts.
func checkFiles(rev Revision) error {
for _, f := range rev.Files {
data, err := os.ReadFile(filepath.Join(rev.Dir, filepath.FromSlash(f.Path)))
if err != nil {
return err
}
if int64(len(data)) != f.Size {
return fmt.Errorf("%s: size %d, want %d", f.Path, len(data), f.Size)
}
}
return nil
}
func (r *fakeRole) Validate(_ context.Context, rev Revision) error {
r.mu.Lock()
defer r.mu.Unlock()
r.calls = append(r.calls, fmt.Sprintf("validate %d", rev.Number))
if err := checkFiles(rev); err != nil {
return err
}
return r.validateErr[rev.Number]
}
func (r *fakeRole) Activate(_ context.Context, rev Revision, prev *Revision) error {
r.mu.Lock()
defer r.mu.Unlock()
p := "none"
if prev != nil {
p = fmt.Sprint(prev.Number)
}
r.calls = append(r.calls, fmt.Sprintf("activate %d prev %s", rev.Number, p))
if err := r.activateErr[rev.Number]; err != nil {
return err
}
r.active = rev.Number
return nil
}
func (r *fakeRole) Health(_ context.Context, expected *Revision) Health {
r.mu.Lock()
defer r.mu.Unlock()
var n uint64
if expected != nil {
n = expected.Number
}
r.healthCalls[n]++
if r.unhealthy[r.active] {
return Health{State: HealthFailed, Message: "service down",
Checks: []HealthCheck{{Name: "soa", State: HealthFailed, Message: "serial mismatch"}, {Name: "status", State: HealthOK}}}
}
return Health{State: HealthOK}
}
func (r *fakeRole) Leases(context.Context) ([]Lease, error) {
r.mu.Lock()
defer r.mu.Unlock()
return slices.Clone(r.leases), r.leasesErr
}
func (r *fakeRole) setLeases(l []Lease) {
r.mu.Lock()
defer r.mu.Unlock()
r.leases = l
}
func (r *fakeRole) callLog() []string {
r.mu.Lock()
defer r.mu.Unlock()
return slices.Clone(r.calls)
}
func (r *fakeRole) activeRevision() uint64 {
r.mu.Lock()
defer r.mu.Unlock()
return r.active
}
func (r *fakeRole) resetCalls() {
r.mu.Lock()
defer r.mu.Unlock()
r.calls = nil
}
// fakeLeaseRole is a role with a LeaseSource; fakeRole itself has the
// method too, so wrap it to hide Leases for the DNS case.
type dnsOnlyRole struct{ *fakeRole }
func (d dnsOnlyRole) Leases() {} // shadows LeaseSource.Leases with another signature
// ---- fake source ----
type fakeSource struct {
mu sync.Mutex
bundles map[uint64]*Bundle
fetchErr error
reportErr error
leaseErr error
reports []DeploymentReport
batches []LeaseBatch
fetches int
onFetch func()
run func(ctx context.Context, h SourceHandler) error
}
func newFakeSource() *fakeSource { return &fakeSource{bundles: map[uint64]*Bundle{}} }
func (s *fakeSource) Run(ctx context.Context, h SourceHandler) error {
if s.run != nil {
return s.run(ctx, h)
}
<-ctx.Done()
return ctx.Err()
}
func (s *fakeSource) FetchBundle(_ context.Context, rev uint64) (*Bundle, error) {
s.mu.Lock()
s.fetches++
b, err, hook := s.bundles[rev], s.fetchErr, s.onFetch
s.mu.Unlock()
if hook != nil {
hook()
}
if err != nil {
return nil, err
}
if b == nil {
return nil, fmt.Errorf("revision %d not found", rev)
}
return b, nil
}
func (s *fakeSource) ReportDeployment(_ context.Context, r DeploymentReport) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.reportErr != nil {
return s.reportErr
}
s.reports = append(s.reports, r)
return nil
}
func (s *fakeSource) ReportLeases(_ context.Context, b LeaseBatch) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.leaseErr != nil {
return s.leaseErr
}
b.Leases = slices.Clone(b.Leases)
s.batches = append(s.batches, b)
return nil
}
func (s *fakeSource) setReportErr(err error) {
s.mu.Lock()
defer s.mu.Unlock()
s.reportErr = err
}
func (s *fakeSource) reported() []DeploymentReport {
s.mu.Lock()
defer s.mu.Unlock()
return slices.Clone(s.reports)
}
func (s *fakeSource) leaseBatches() []LeaseBatch {
s.mu.Lock()
defer s.mu.Unlock()
return slices.Clone(s.batches)
}
// ---- agent fixture ----
type fixture struct {
agent *Agent
role *fakeRole
src *fakeSource
store *Store
signer ed25519.PrivateKey
}
func testConfig(dir string) *Config {
return &Config{
StateDir: dir, ApplyTimeout: 5 * time.Second, HealthTimeout: 300 * time.Millisecond,
LeaseInterval: time.Second, LeaseFullSync: time.Hour,
}
}
func newFixture(t *testing.T, kind Kind) *fixture {
t.Helper()
store, err := OpenStore(t.TempDir())
if err != nil {
t.Fatal(err)
}
return newFixtureWithStore(t, kind, store, nil)
}
func newFixtureWithStore(t *testing.T, kind Kind, store *Store, signer ed25519.PrivateKey) *fixture {
t.Helper()
if signer == nil {
_, signer = newSigningKey(t)
}
role := newFakeRole(kind)
src := newFakeSource()
id := &Identity{AgentID: testAgentID, Role: kind, BundleKey: signer.Public().(ed25519.PublicKey)}
a, err := NewAgent(kind, testConfig(store.Dir()), role, store, id, src, testLogger())
if err != nil {
t.Fatal(err)
}
a.healthRetry = 20 * time.Millisecond
a.reportRetry = 50 * time.Millisecond
return &fixture{agent: a, role: role, src: src, store: store, signer: signer}
}
// addBundle makes revision rev available from the fake source.
func (f *fixture) addBundle(t *testing.T, rev uint64) {
t.Helper()
f.src.mu.Lock()
defer f.src.mu.Unlock()
f.src.bundles[rev] = signBundle(t, f.signer, testAgentID, f.agent.kind, rev)
}
func (f *fixture) pull(rev uint64) {
f.agent.handle(context.Background(), applyRequest{revision: rev, trigger: TriggerPull})
}
func (f *fixture) push(rev uint64, dep string) {
f.agent.handle(context.Background(), applyRequest{revision: rev, trigger: TriggerPush, deploymentID: dep})
}
func (f *fixture) lastResult(t *testing.T) DeploymentReport {
t.Helper()
st := f.agent.state()
if st.LastResult == nil {
t.Fatal("no result recorded")
}
return *st.LastResult
}
// ---- fake gRPC server ----
type fakeServer struct {
agentv1.UnimplementedAgentServiceServer
pki *testPKI
signKey ed25519.PrivateKey
role Kind
mu sync.Mutex
token string
agentID string
serial *big.Int
enrollValidity [2]time.Duration // NotBefore offset, NotAfter offset for Enroll
desired uint64
mode agentv1.OperatingMode
pullInterval time.Duration
heartbeat time.Duration
streamDisabled bool
hellos []*agentv1.Hello
heartbeats int
acks []*agentv1.CommandAck
reports []*agentv1.ReportDeploymentRequest
leaseReports []*agentv1.ReportLeasesRequest
renewals int
polls int
commands chan *agentv1.Command
enrollments int
}
func newFakeServer(t *testing.T, role Kind) *fakeServer {
t.Helper()
_, key := newSigningKey(t)
return &fakeServer{
pki: newTestPKI(t), signKey: key, role: role, token: "0123456789abcdef.secret",
agentID: "11111111-2222-4333-8444-555555555555",
enrollValidity: [2]time.Duration{-time.Minute, 14 * 24 * time.Hour},
mode: agentv1.OperatingMode_OPERATING_MODE_AUTO_PULL,
pullInterval: 50 * time.Millisecond, heartbeat: 50 * time.Millisecond,
commands: make(chan *agentv1.Command, 10),
}
}
// start serves the fake on 127.0.0.1 with mTLS and returns its address.
func (f *fakeServer) start(t *testing.T) string {
t.Helper()
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
pool := x509.NewCertPool()
pool.AddCert(f.pki.ca)
srv := grpc.NewServer(
grpc.Creds(credentials.NewTLS(&tls.Config{
MinVersion: tls.VersionTLS13, Certificates: []tls.Certificate{f.pki.serverCert},
ClientAuth: tls.VerifyClientCertIfGiven, ClientCAs: pool,
})),
grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{MinTime: 10 * time.Second, PermitWithoutStream: true}),
)
agentv1.RegisterAgentServiceServer(srv, f)
go func() { _ = srv.Serve(lis) }()
t.Cleanup(srv.Stop)
return lis.Addr().String()
}
func (f *fakeServer) fingerprint() string { return agentpki.Fingerprint(f.pki.ca) }
func (f *fakeServer) authenticate(ctx context.Context) error {
p, ok := peer.FromContext(ctx)
if !ok {
return status.Error(codes.Unauthenticated, "no peer")
}
ti, ok := p.AuthInfo.(credentials.TLSInfo)
if !ok || len(ti.State.PeerCertificates) == 0 {
return status.Error(codes.Unauthenticated, "client certificate required")
}
cert := ti.State.PeerCertificates[0]
id, err := agentpki.AgentID(cert)
if err != nil {
return status.Error(codes.Unauthenticated, err.Error())
}
f.mu.Lock()
defer f.mu.Unlock()
if id != f.agentID || f.serial == nil || cert.SerialNumber.Cmp(f.serial) != 0 {
return status.Error(codes.Unauthenticated, "unknown certificate")
}
return nil
}
func (f *fakeServer) issue(csrDER []byte, notBefore, notAfter time.Time) (*x509.Certificate, error) {
csr, err := x509.ParseCertificateRequest(csrDER)
if err != nil {
return nil, err
}
if err := csr.CheckSignature(); err != nil {
return nil, err
}
f.mu.Lock()
id := f.agentID
f.mu.Unlock()
cert, err := f.pki.issueClient(csr.PublicKey, id, notBefore, notAfter)
if err != nil {
return nil, err
}
f.mu.Lock()
f.serial = cert.SerialNumber
f.mu.Unlock()
return cert, nil
}
func (f *fakeServer) Enroll(_ context.Context, req *agentv1.EnrollRequest) (*agentv1.EnrollResponse, error) {
f.mu.Lock()
token, validity := f.token, f.enrollValidity
f.enrollments++
f.mu.Unlock()
if req.GetToken() != token {
return nil, status.Error(codes.PermissionDenied, "invalid enrollment token")
}
want, _ := roleToProto(f.role)
if req.GetRole() != want {
return nil, status.Error(codes.InvalidArgument, "wrong role")
}
now := time.Now()
cert, err := f.issue(req.GetCsrDer(), now.Add(validity[0]), now.Add(validity[1]))
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
f.mu.Lock()
defer f.mu.Unlock()
return &agentv1.EnrollResponse{
AgentId: f.agentID, CertificateDer: cert.Raw, CaCertificatesDer: [][]byte{f.pki.ca.Raw},
BundleSigningPublicKey: f.signKey.Public().(ed25519.PublicKey),
}, nil
}
func (f *fakeServer) RenewCertificate(ctx context.Context, req *agentv1.RenewCertificateRequest) (*agentv1.RenewCertificateResponse, error) {
if err := f.authenticate(ctx); err != nil {
return nil, err
}
now := time.Now()
cert, err := f.issue(req.GetCsrDer(), now.Add(-time.Minute), now.Add(14*24*time.Hour))
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
f.mu.Lock()
f.renewals++
f.mu.Unlock()
return &agentv1.RenewCertificateResponse{CertificateDer: cert.Raw, CaCertificatesDer: [][]byte{f.pki.ca.Raw}}, nil
}
func (f *fakeServer) Connect(stream agentv1.AgentService_ConnectServer) error {
f.mu.Lock()
disabled := f.streamDisabled
f.mu.Unlock()
if disabled {
return status.Error(codes.Unimplemented, "streams disabled")
}
if err := f.authenticate(stream.Context()); err != nil {
return err
}
first, err := stream.Recv()
if err != nil {
return err
}
if first.GetHello() == nil {
return status.Error(codes.InvalidArgument, "Hello expected")
}
f.mu.Lock()
f.hellos = append(f.hellos, first.GetHello())
w := &agentv1.Welcome{
Mode: f.mode, PullInterval: durationpb.New(f.pullInterval),
HeartbeatInterval: durationpb.New(f.heartbeat), DesiredRevision: f.desired,
}
f.mu.Unlock()
if err := stream.Send(&agentv1.ConnectResponse{Message: &agentv1.ConnectResponse_Welcome{Welcome: w}}); err != nil {
return err
}
recvErr := make(chan error, 1)
go func() {
for {
msg, err := stream.Recv()
if err != nil {
recvErr <- err
return
}
f.mu.Lock()
switch {
case msg.GetHeartbeat() != nil:
f.heartbeats++
case msg.GetCommandAck() != nil:
f.acks = append(f.acks, msg.GetCommandAck())
}
f.mu.Unlock()
}
}()
for {
select {
case err := <-recvErr:
if errors.Is(err, io.EOF) {
return nil
}
return err
case <-stream.Context().Done():
return nil
case c := <-f.commands:
if err := stream.Send(&agentv1.ConnectResponse{Message: &agentv1.ConnectResponse_Command{Command: c}}); err != nil {
return err
}
}
}
}
func (f *fakeServer) GetDesiredState(ctx context.Context, _ *agentv1.GetDesiredStateRequest) (*agentv1.GetDesiredStateResponse, error) {
if err := f.authenticate(ctx); err != nil {
return nil, err
}
f.mu.Lock()
defer f.mu.Unlock()
f.polls++
return &agentv1.GetDesiredStateResponse{DesiredRevision: f.desired, Mode: f.mode, PullInterval: durationpb.New(f.pullInterval)}, nil
}
func (f *fakeServer) FetchBundle(ctx context.Context, req *agentv1.FetchBundleRequest) (*agentv1.FetchBundleResponse, error) {
if err := f.authenticate(ctx); err != nil {
return nil, err
}
f.mu.Lock()
id := f.agentID
f.mu.Unlock()
arts := testArtifacts(req.GetRevision())
manifest, sig, err := bundle.Sign(f.signKey, id, string(f.role), req.GetRevision(), time.Unix(1_700_000_000, 0), arts)
if err != nil {
return nil, status.Error(codes.Internal, err.Error())
}
resp := &agentv1.FetchBundleResponse{Manifest: manifest, Signature: sig}
for _, a := range arts {
resp.Artifacts = append(resp.Artifacts, &agentv1.Artifact{Path: a.Path, Content: a.Content})
}
return resp, nil
}
func (f *fakeServer) ReportDeployment(ctx context.Context, req *agentv1.ReportDeploymentRequest) (*agentv1.ReportDeploymentResponse, error) {
if err := f.authenticate(ctx); err != nil {
return nil, err
}
f.mu.Lock()
defer f.mu.Unlock()
f.reports = append(f.reports, req)
return &agentv1.ReportDeploymentResponse{}, nil
}
func (f *fakeServer) ReportLeases(ctx context.Context, req *agentv1.ReportLeasesRequest) (*agentv1.ReportLeasesResponse, error) {
if err := f.authenticate(ctx); err != nil {
return nil, err
}
f.mu.Lock()
defer f.mu.Unlock()
f.leaseReports = append(f.leaseReports, req)
return &agentv1.ReportLeasesResponse{AcknowledgedSequence: req.GetSequence()}, nil
}
// snapshot returns a copy of the recorded data under the lock.
func (f *fakeServer) with(fn func(f *fakeServer)) {
f.mu.Lock()
defer f.mu.Unlock()
fn(f)
}