Files
llmgw/internal/agent/mtls_test.go
T
Ilker ManapandClaude Opus 5 4ba84fa90e Let an agent find the leader instead of being told one address
The second half of leadership. A standby that refuses to place but still holds
an agent's stream is worse than useless: commands travel down the stream the
agent opened, so the leader's placements fail with "no agent connected" for a
node that is plainly connected.

So a standby refuses the stream, before reading the opening message, and says
why. And an agent takes a list rather than an address, trying each in turn — a
standby refusing, a stopped control plane refusing the connection and a crashed
one never answering are the same thing from a node, and the answer to all three
is somewhere else.

`server` and `servers` are both accepted, with `server` first when both are
given: every agent configuration written before there could be a second control
plane names one address, and an operator adding a standby expects the one
already working to stay preferred. With a single address the behaviour is
exactly what it was, including the backoff — which is skipped only while there
is another address that has not just been tried, because the delay exists to
stop hammering one control plane and the next one has not been hammered.

Removed a check that asked whether one field was empty, which is how the first
run of these tests failed: the list was populated and `server` was not, and an
agent configured entirely correctly was refused by its own validation.

The tests run a real agent against a real service over h2c. The first attempt
used a plain HTTP server, and the refusal arrived as an HTTP/2 framing error
that said nothing about why — which is exactly what an operator would have seen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 19:30:06 +02:00

318 lines
9.1 KiB
Go

package agent
import (
"context"
"log/slog"
"net"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/supervisor"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/agentsvc"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/inventory"
"gitea.mynodes.xyz/ilker/llmgw/internal/tlsconf"
)
// pki issues a cluster's certificates into a temporary directory and returns
// the configurations the control plane and one agent would load.
func pki(t *testing.T, nodeID string) (server, client tlsconf.Config) {
t.Helper()
dir := t.TempDir()
ca, err := tlsconf.NewCA("test CA")
if err != nil {
t.Fatal(err)
}
if err := ca.Write(dir); err != nil {
t.Fatal(err)
}
srv, err := ca.IssueServer("control-plane", []string{"127.0.0.1"})
if err != nil {
t.Fatal(err)
}
if err := srv.Write(dir, "server"); err != nil {
t.Fatal(err)
}
cli, err := ca.IssueClient(nodeID)
if err != nil {
t.Fatal(err)
}
if err := cli.Write(dir, "agent"); err != nil {
t.Fatal(err)
}
caPath := filepath.Join(dir, "ca.pem")
return tlsconf.Config{CA: caPath, Cert: filepath.Join(dir, "server.pem"), Key: filepath.Join(dir, "server-key.pem")},
tlsconf.Config{CA: caPath, Cert: filepath.Join(dir, "agent.pem"), Key: filepath.Join(dir, "agent-key.pem")}
}
// startTLS runs a real service behind a real TLS listener, with identity
// required — the deployed configuration rather than an approximation of it.
func startTLS(t *testing.T, serverTLS tlsconf.Config) (*agentsvc.Service, *inventory.Store, string) {
t.Helper()
inv := inventory.NewStore()
svc := agentsvc.New(inv, newRecorder(), true, slog.New(slog.DiscardHandler))
path, handler := svc.Handler()
mux := http.NewServeMux()
mux.Handle(path, handler)
tc, err := serverTLS.ServerConfig()
if err != nil {
t.Fatal(err)
}
raw, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
srv := httptest.NewUnstartedServer(mux)
srv.Listener = raw
srv.TLS = tc
srv.StartTLS()
t.Cleanup(srv.Close)
return svc, inv, "https://" + raw.Addr().String()
}
func runAgent(t *testing.T, cfg Config) *Agent {
t.Helper()
cfg.MgmtAddr = "127.0.0.1"
cfg.HeartbeatInterval = 50 * time.Millisecond
cfg.ReconnectBase = 20 * time.Millisecond
cfg.ReconnectMax = 100 * time.Millisecond
cfg.Discovery.SkipGPUs = true
cfg.Discovery.IBRoot = filepath.Join(t.TempDir(), "absent")
cfg.Superv = supervisor.Config{
WorkDir: t.TempDir(), LogLines: 64,
StopTimeout: 2 * time.Second, HealthInterval: 20 * time.Millisecond,
}
client, err := Client(cfg.TLS, 5*time.Second)
if err != nil {
t.Fatal(err)
}
ag, err := New(cfg, client, slog.New(slog.DiscardHandler))
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
go func() { _ = ag.Run(ctx) }()
t.Cleanup(func() {
cancel()
ag.Supervisor().StopAll(context.Background())
})
return ag
}
func registered(svc *agentsvc.Service, inv *inventory.Store, nodeID string) func() bool {
return func() bool {
_, ok := inv.Get(nodeID)
return ok && len(svc.ConnectedNodes()) == 1
}
}
func eventually(t *testing.T, limit time.Duration, pred func() bool) bool {
t.Helper()
deadline := time.Now().Add(limit)
for time.Now().Before(deadline) {
if pred() {
return true
}
time.Sleep(10 * time.Millisecond)
}
return false
}
func TestAgentRegistersOverMutualTLS(t *testing.T) {
serverTLS, clientTLS := pki(t, "llmgw-node01")
svc, inv, url := startTLS(t, serverTLS)
cfg := DefaultConfig()
cfg.NodeID = "llmgw-node01"
cfg.Server = url
cfg.TLS = clientTLS
runAgent(t, cfg)
if !eventually(t, 10*time.Second, registered(svc, inv, "llmgw-node01")) {
t.Fatal("the agent never registered over mutual TLS")
}
}
func TestNodeCannotClaimAnotherNodesIdentity(t *testing.T) {
// The point of the whole exercise. node01 holds a valid certificate — it is
// a legitimate member of the cluster — and still cannot register as node02,
// because the id it claims is checked against the one it can prove.
serverTLS, clientTLS := pki(t, "llmgw-node01")
svc, inv, url := startTLS(t, serverTLS)
cfg := DefaultConfig()
cfg.NodeID = "llmgw-node02"
cfg.Server = url
cfg.TLS = clientTLS
runAgent(t, cfg)
// The agent reconnects on failure, so this has to stay false throughout
// rather than merely start false.
if eventually(t, 2*time.Second, func() bool {
_, ok := inv.Get("llmgw-node02")
return ok || len(svc.ConnectedNodes()) > 0
}) {
t.Fatal("an agent registered under a node id its certificate does not authorize")
}
}
func TestUnauthenticatedAgentIsRefused(t *testing.T) {
// The defensive half of the check: identity is required, but the connection
// carried none. In the shipped wiring TLS and this flag are tied together,
// so reaching here means someone untied them — and the answer must still be
// refusal rather than a silently trusted node id.
inv := inventory.NewStore()
svc := agentsvc.New(inv, newRecorder(), true, slog.New(slog.DiscardHandler))
path, handler := svc.Handler()
mux := http.NewServeMux()
mux.Handle(path, handler)
srv := httptest.NewServer(h2c.NewHandler(mux, &http2.Server{}))
t.Cleanup(srv.Close)
cfg := DefaultConfig()
cfg.NodeID = "llmgw-node01"
cfg.Server = srv.URL
runAgent(t, cfg)
if eventually(t, 2*time.Second, registered(svc, inv, "llmgw-node01")) {
t.Fatal("an agent with no proven identity reached the control plane")
}
}
func TestPlaintextAgentIsRejectedWhenTLSIsConfigured(t *testing.T) {
// A misconfiguration worth catching at startup rather than at runtime: an
// agent holding certificates but dialling http:// would connect in the
// clear and never learn that it had.
_, clientTLS := pki(t, "llmgw-node01")
cfg := DefaultConfig()
cfg.NodeID = "llmgw-node01"
cfg.Server = "http://127.0.0.1:9443"
cfg.TLS = clientTLS
if _, err := New(cfg, nil, slog.New(slog.DiscardHandler)); err == nil {
t.Fatal("an agent with certificates was allowed to dial a plaintext address")
}
}
func TestBareAddressGainsTheRightScheme(t *testing.T) {
_, clientTLS := pki(t, "llmgw-node01")
secure := Config{NodeID: "n", Server: "10.0.0.140:9443", TLS: clientTLS}
if err := secure.normalize(); err != nil {
t.Fatal(err)
}
if secure.Server != "https://10.0.0.140:9443" {
t.Errorf("a configured agent dials %q", secure.Server)
}
plain := Config{NodeID: "n", Server: "10.0.0.140:9443"}
if err := plain.normalize(); err != nil {
t.Fatal(err)
}
if plain.Server != "http://10.0.0.140:9443" {
t.Errorf("an unconfigured agent dials %q", plain.Server)
}
}
func TestRefusedAgentLearnsWhy(t *testing.T) {
// An agent that cannot register must be able to say why. Reporting a
// refusal as a clean disconnect leaves an operator with a silent reconnect
// loop and nothing to act on — and resets the backoff, so the loop is a
// fast one.
serverTLS, clientTLS := pki(t, "llmgw-node01")
_, _, url := startTLS(t, serverTLS)
cfg := DefaultConfig()
cfg.NodeID = "llmgw-node02"
cfg.Server = url
cfg.TLS = clientTLS
cfg.Discovery.SkipGPUs = true
cfg.Discovery.IBRoot = filepath.Join(t.TempDir(), "absent")
cfg.Superv = supervisor.Config{WorkDir: t.TempDir(), LogLines: 8}
client, err := Client(cfg.TLS, 5*time.Second)
if err != nil {
t.Fatal(err)
}
ag, err := New(cfg, client, slog.New(slog.DiscardHandler))
if err != nil {
t.Fatal(err)
}
err = ag.session(context.Background(), ag.cfg.Server)
if err == nil {
t.Fatal("a refused session reported success")
}
if !strings.Contains(err.Error(), "llmgw-node01") || !strings.Contains(err.Error(), "llmgw-node02") {
t.Errorf("the error names neither certificate nor claim: %v", err)
}
}
// TestAnAgentWithNothingRunningStillTellsTheControlPlane covers the gap that
// made a reconciler fix dead code in production.
//
// "I am running nothing" is information: it is how the control plane learns
// that an engine died while its agent survived. The service used to return
// early on an empty list, so that message never reached the reconciler — while
// the reconciler's own tests, which call it directly, kept passing.
//
// This test goes through the real stream for that reason.
func TestAnAgentWithNothingRunningStillTellsTheControlPlane(t *testing.T) {
serverTLS, clientTLS := pki(t, "llmgw-node01")
inv := inventory.NewStore()
rec := newRecorder()
svc := agentsvc.New(inv, rec, true, slog.New(slog.DiscardHandler))
path, handler := svc.Handler()
mux := http.NewServeMux()
mux.Handle(path, handler)
tc, err := serverTLS.ServerConfig()
if err != nil {
t.Fatal(err)
}
raw, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
srv := httptest.NewUnstartedServer(mux)
srv.Listener = raw
srv.TLS = tc
srv.StartTLS()
t.Cleanup(srv.Close)
cfg := DefaultConfig()
cfg.NodeID = "llmgw-node01"
cfg.Server = "https://" + raw.Addr().String()
cfg.TLS = clientTLS
runAgent(t, cfg)
// The agent has no shards at all, which is the whole point.
if !eventually(t, 10*time.Second, func() bool {
return len(rec.adoptCalls()) > 0
}) {
t.Fatal("an agent running nothing never reached the reconciler")
}
for _, call := range rec.adoptCalls() {
if call.nodeID != "llmgw-node01" {
t.Errorf("adopt was told node %q", call.nodeID)
}
}
}