Files
sultan/pkg/agent/agent_test.go
T

927 lines
32 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package agent
import (
"context"
"crypto/ed25519"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"sultan/pkg/auth"
"sultan/pkg/crypto"
"sultan/pkg/docker"
"sultan/pkg/license"
"sultan/pkg/registry"
"sultan/pkg/server"
"sultan/pkg/store"
"sultan/pkg/stream"
)
var ctx = context.Background()
func eventually(t *testing.T, msg string, cond func() bool) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for !cond() {
if time.Now().After(deadline) {
t.Fatalf("Bedingung nicht erfüllt: %s", msg)
}
time.Sleep(10 * time.Millisecond)
}
}
// testServer ist ein echter sultan-Server auf freien Ports.
type testServer struct {
*server.Server
dataDir string
}
func startServer(t *testing.T, dataDir, agentAddr string) *testServer {
return startServerFull(t, dataDir, agentAddr, nil)
}
func startServerWith(t *testing.T, mutate func(*server.Config)) *testServer {
return startServerFull(t, "", "", mutate)
}
func startServerFull(t *testing.T, dataDir, agentAddr string, mutate func(*server.Config)) *testServer {
t.Helper()
if dataDir == "" {
dataDir = filepath.Join(t.TempDir(), "server")
}
if agentAddr == "" {
agentAddr = "127.0.0.1:0"
}
cfg := server.Config{
DataDir: dataDir, AgentAddr: agentAddr, ProviderAddr: "127.0.0.1:0", LanderAddr: "127.0.0.1:0",
RefreshInterval: 20 * time.Millisecond,
Logger: slog.New(slog.DiscardHandler),
}
if mutate != nil {
mutate(&cfg)
}
s, err := server.New(ctx, cfg)
if err != nil {
t.Fatal(err)
}
if err := s.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { s.Shutdown(ctx) })
return &testServer{Server: s, dataDir: dataDir}
}
func (s *testServer) url() string { return "https://" + s.AgentAddr() }
func (s *testServer) db(t *testing.T) *store.Store {
t.Helper()
st, err := store.Open(ctx, filepath.Join(s.dataDir, "sultan.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
return st
}
// invite legt (falls nötig) den Mandanten an und erzeugt eine Einladung.
func (s *testServer) invite(t *testing.T, tenant string) string {
t.Helper()
st := s.db(t)
if _, err := st.GetTenant(ctx, tenant); err != nil {
if _, err := st.CreateTenant(ctx, store.Tenant{
ID: tenant, Name: tenant, Email: tenant + "@example.com", Tier: store.TierPro, MaxRoutes: 10,
AllowedDomains: []string{"kunde.de", "*.kunde.de"}, ValidUntil: time.Now().AddDate(1, 0, 0),
}); err != nil {
t.Fatal(err)
}
}
tok, _ := crypto.NewEnrollmentToken(s.Fingerprint(), "")
if err := st.AddEnrollmentToken(ctx, tok.Secret, tenant, time.Now().Add(time.Hour)); err != nil {
t.Fatal(err)
}
return tok.String()
}
func (s *testServer) traefikConfig(t *testing.T) string {
t.Helper()
resp, err := http.Get("http://" + s.ProviderAddr() + "/v1/traefik-config")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return string(b)
}
func (s *testServer) nodeStatus(t *testing.T, id string) store.NodeStatus {
n, err := s.db(t).GetNode(ctx, id)
if err != nil {
return ""
}
return n.Status
}
// fakeDocker bedient /containers/json und /events über einen Unix-Socket.
type fakeDocker struct {
socket string
mu sync.Mutex
containers []docker.Container
subs []chan docker.Event
}
func newFakeDocker(t *testing.T, containers ...docker.Container) *fakeDocker {
t.Helper()
dir, err := os.MkdirTemp("", "ag")
if err != nil {
t.Fatal(err)
}
f := &fakeDocker{socket: filepath.Join(dir, "d.sock"), containers: containers}
ln, err := net.Listen("unix", f.socket)
if err != nil {
t.Fatal(err)
}
mux := http.NewServeMux()
// Wie Docker: gestoppte Container ohne Ports in der Liste; die Zuordnung
// gibt es nur über /containers/{id}/json.
mux.HandleFunc("/containers/json", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
list := []docker.Container{}
for _, c := range f.containers {
if c.State != "running" {
c.Ports = nil
}
list = append(list, c)
}
json.NewEncoder(w).Encode(list)
})
mux.HandleFunc("/containers/{id}/json", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
for _, c := range f.containers {
if c.ID != r.PathValue("id") {
continue
}
bindings := map[string][]map[string]string{}
for _, p := range c.Ports {
key := fmt.Sprintf("%d/%s", p.PrivatePort, p.Type)
bindings[key] = append(bindings[key], map[string]string{"HostIp": p.IP, "HostPort": fmt.Sprint(p.PublicPort)})
}
state := map[string]any{"Status": c.State, "StartedAt": c.StartedAt}
if c.Health != "" {
state["Health"] = map[string]string{"Status": c.Health}
}
json.NewEncoder(w).Encode(map[string]any{"Id": c.ID, "State": state, "HostConfig": map[string]any{"PortBindings": bindings}})
return
}
http.Error(w, `{"message":"No such container"}`, http.StatusNotFound)
})
mux.HandleFunc("/info", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"Name":"webdocker11","OperatingSystem":"Debian GNU/Linux 13 (trixie)","ServerVersion":"29.8.2"}`))
})
mux.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) {
ch := make(chan docker.Event, 8)
f.mu.Lock()
f.subs = append(f.subs, ch)
f.mu.Unlock()
w.WriteHeader(http.StatusOK)
w.(http.Flusher).Flush()
for {
select {
case <-r.Context().Done():
return
case ev := <-ch:
json.NewEncoder(w).Encode(ev)
w.(http.Flusher).Flush()
}
}
})
srv := &http.Server{Handler: mux}
go srv.Serve(ln)
t.Cleanup(func() { srv.Close(); os.RemoveAll(dir) })
return f
}
// set ersetzt die Containerliste und meldet ein Event.
func (f *fakeDocker) set(action string, containers ...docker.Container) {
f.mu.Lock()
f.containers = containers
subs := append([]chan docker.Event(nil), f.subs...)
f.mu.Unlock()
for _, ch := range subs {
ch <- docker.Event{Type: "container", Action: action, Actor: docker.Actor{ID: "x"}}
}
}
func wordpress() docker.Container {
return ctr("wordpress", wpLabels(), pub(80, 8081))
}
func testConfig(t *testing.T, dataDir string, s *testServer, token, socket string) Config {
return Config{
DataDir: dataDir,
ServerURL: s.url(),
Token: token,
NodeID: "node-1",
Hostname: "pi",
DockerSocket: socket,
BackendHost: "100.64.0.42",
Logger: slog.New(slog.DiscardHandler),
Debounce: 20 * time.Millisecond,
RetryInterval: 50 * time.Millisecond,
ReconnectMin: 20 * time.Millisecond,
}
}
// runAgent startet den Agenten im Hintergrund; stop beendet ihn und
// liefert den Rückgabewert von Run.
func runAgent(t *testing.T, cfg Config) (stop func() error) {
t.Helper()
a, err := New(cfg)
if err != nil {
t.Fatalf("New: %v", err)
}
actx, cancel := context.WithCancel(ctx)
done := make(chan error, 1)
go func() { done <- a.Run(actx) }()
var once sync.Once
var result error
stop = func() error {
once.Do(func() {
cancel()
select {
case result = <-done:
case <-time.After(5 * time.Second):
result = errors.New("Agent endet nicht")
}
})
return result
}
t.Cleanup(func() { stop() })
return stop
}
// Der ganze Weg: Enrollment, Stream, Container → Route bei Traefik,
// Container weg → Route weg, Agent weg → offline, Neustart ohne Token.
func TestRun_EndToEnd(t *testing.T) {
s := startServer(t, "", "")
dock := newFakeDocker(t, wordpress())
dataDir := filepath.Join(t.TempDir(), "suldata")
cfg := testConfig(t, dataDir, s, s.invite(t, "kunde"), dock.socket)
stop := runAgent(t, cfg)
eventually(t, "Route bei Traefik", func() bool {
c := s.traefikConfig(t)
return strings.Contains(c, "kunde__node-1__wp") && strings.Contains(c, "http://100.64.0.42:8081")
})
if st := s.nodeStatus(t, "node-1"); st != store.NodeOnline {
t.Errorf("Knotenstatus %q", st)
}
// Gestoppt (docker stop, Absturz): Wartungsseite statt 404.
down := wordpress()
down.State = "exited"
dock.set("die", down)
eventually(t, "Wartungsseite nach Containerstopp", func() bool {
c := s.traefikConfig(t)
return strings.Contains(c, "kunde__node-1__wp") && strings.Contains(c, "sultan__interceptor") &&
strings.Contains(c, `"maintenance"`) && strings.Contains(c, "http://100.64.0.42:8081")
})
dock.set("destroy") // entfernt (docker compose down): Route weg
eventually(t, "Route nach Entfernen des Containers weg", func() bool { return s.traefikConfig(t) == "{}" })
dock.set("start", wordpress())
eventually(t, "Route nach Containerstart zurück", func() bool {
return strings.Contains(s.traefikConfig(t), "kunde__node-1__wp")
})
if err := stop(); err != nil {
t.Errorf("Run: %v", err)
}
eventually(t, "Knoten offline", func() bool { return s.nodeStatus(t, "node-1") == store.NodeOffline })
// Ungeplant weg: Wartungsseite, solange die Lease gilt.
eventually(t, "Wartungsseite", func() bool { return strings.Contains(s.traefikConfig(t), "sultan__interceptor") })
// Neustart: schon enrollt, kein Token mehr nötig (Einladung ist verbraucht).
cfg.Token = ""
runAgent(t, cfg)
eventually(t, "Route nach Neustart des Agenten wieder normal", func() bool {
c := s.traefikConfig(t)
return strings.Contains(c, "kunde__node-1__wp") && !strings.Contains(c, "sultan__interceptor")
})
}
// Nach einem Neustart des Servers verbindet sich der Agent neu und sendet
// seinen Zustand erneut (OnConnect).
func TestRun_ResendsStateAfterServerRestart(t *testing.T) {
serverDir := filepath.Join(t.TempDir(), "server")
s := startServer(t, serverDir, "")
addr := s.AgentAddr()
dock := newFakeDocker(t, wordpress())
runAgent(t, testConfig(t, filepath.Join(t.TempDir(), "suldata"), s, s.invite(t, "kunde"), dock.socket))
eventually(t, "Route", func() bool { return strings.Contains(s.traefikConfig(t), "kunde__node-1__wp") })
s.Shutdown(ctx)
s2 := startServer(t, serverDir, addr) // gleiche Identität, gleiche Adresse
eventually(t, "Route nach Server-Neustart", func() bool {
return strings.Contains(s2.traefikConfig(t), "kunde__node-1__wp")
})
}
func TestEnroll_WritesIdentity(t *testing.T) {
s := startServer(t, "", "")
dataDir := filepath.Join(t.TempDir(), "suldata")
a, err := New(testConfig(t, dataDir, s, s.invite(t, "kunde"), ""))
if err != nil {
t.Fatal(err)
}
if err := a.Enroll(ctx); err != nil {
t.Fatalf("Enroll: %v", err)
}
for file, perm := range map[string]os.FileMode{"node.key": 0o600, "node.json": 0o600} {
info, err := os.Stat(filepath.Join(dataDir, file))
if err != nil || info.Mode().Perm() != perm {
t.Errorf("%s: %v %v", file, info, err)
}
}
var id Identity
b, _ := os.ReadFile(filepath.Join(dataDir, "node.json"))
if err := json.Unmarshal(b, &id); err != nil {
t.Fatal(err)
}
if id.NodeID != "node-1" || id.TenantID != "kunde" || id.Server != s.url() ||
id.MasterPublicKey != auth.EncodePublicKey(s.MasterPublicKey()) || id.ServerFingerprint != s.Fingerprint() {
t.Errorf("node.json: %+v", id)
}
if _, err := os.Stat(filepath.Join(dataDir, "known_servers")); err == nil {
t.Error("known_servers ist überflüssig – der Pin steht in node.json")
}
// Zweites Enroll: nichts zu tun, kein Token nötig.
a2, _ := New(Config{DataDir: dataDir, Logger: slog.New(slog.DiscardHandler)})
if err := a2.Enroll(ctx); err != nil {
t.Errorf("erneutes Enroll ohne Token: %v", err)
}
}
func TestEnroll_Errors(t *testing.T) {
s := startServer(t, "", "")
other := startServer(t, "", "")
t.Run("erster Start ohne Token", func(t *testing.T) {
dir := filepath.Join(t.TempDir(), "suldata")
a, _ := New(testConfig(t, dir, s, "", ""))
if err := a.Enroll(ctx); !errors.Is(err, ErrNotEnrolled) {
t.Errorf("erwartet ErrNotEnrolled, bekommen %v", err)
}
})
t.Run("Token ohne URL und ohne --server", func(t *testing.T) {
cfg := testConfig(t, filepath.Join(t.TempDir(), "suldata"), s, s.invite(t, "kunde"), "")
cfg.ServerURL = ""
a, _ := New(cfg)
if err := a.Enroll(ctx); !errors.Is(err, ErrNotEnrolled) || !strings.Contains(err.Error(), "--server") {
t.Errorf("erwartet Hinweis auf --server, bekommen %v", err)
}
})
t.Run("Token eines anderen Servers", func(t *testing.T) {
dir := filepath.Join(t.TempDir(), "suldata")
a, _ := New(testConfig(t, dir, s, other.invite(t, "kunde"), ""))
if err := a.Enroll(ctx); !errors.Is(err, crypto.ErrFingerprintMismatch) {
t.Errorf("erwartet ErrFingerprintMismatch, bekommen %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "node.json")); err == nil {
t.Error("node.json trotz Fehlschlag geschrieben")
}
})
t.Run("Einladung verbraucht", func(t *testing.T) {
tok := s.invite(t, "kunde")
a, _ := New(testConfig(t, filepath.Join(t.TempDir(), "a"), s, tok, ""))
if err := a.Enroll(ctx); err != nil {
t.Fatal(err)
}
cfg := testConfig(t, filepath.Join(t.TempDir(), "b"), s, tok, "")
cfg.NodeID = "node-2"
b, _ := New(cfg)
if err := b.Enroll(ctx); err == nil || !strings.Contains(err.Error(), "403") {
t.Errorf("verbrauchte Einladung: %v", err)
}
})
// Registriert bei s, dann kommt eine Einladung von other: anderer
// Fingerprint = anderer Server = nur mit leerem Datenverzeichnis.
t.Run("Einladung eines anderen Servers trotz Registrierung", func(t *testing.T) {
dir := filepath.Join(t.TempDir(), "suldata")
cfg := testConfig(t, dir, s, s.invite(t, "kunde"), "")
cfg.NodeID = "node-wechsel" // node-1 ist auf s schon vergeben
first, _ := New(cfg)
if err := first.Enroll(ctx); err != nil {
t.Fatal(err)
}
switched, _ := New(testConfig(t, dir, other, other.invite(t, "kunde"), ""))
if err := switched.Enroll(ctx); err == nil || !strings.Contains(err.Error(), "anderen Server") {
t.Errorf("Serverwechsel ohne neues Enrollment: %v", err)
}
})
}
func TestNew_Validation(t *testing.T) {
dir := t.TempDir()
if _, err := New(Config{DataDir: dir, ServerURL: "http://ingress:8443", Token: "x", NodeID: "n"}); err == nil {
t.Error("http-URL akzeptiert")
}
if _, err := New(Config{DataDir: dir, ServerURL: "https://ingress:8443", NodeID: "a__b"}); err == nil {
t.Error("ungültige Knoten-ID akzeptiert")
}
a, err := New(Config{DataDir: dir, ServerURL: "https://ingress:8443/", Hostname: "Mein.Pi"})
if err != nil {
t.Fatal(err)
}
if a.cfg.NodeID != "mein-pi" || a.cfg.ServerURL != "https://ingress:8443" || a.cfg.DockerSocket != "/var/run/docker.sock" {
t.Errorf("Defaults: %+v", a.cfg)
}
if def, _ := New(Config{}); def.cfg.DataDir != "./suldata" {
t.Errorf("Default-Datenverzeichnis: %q", def.cfg.DataDir)
}
}
// Ohne Docker (Bare-Metal) meldet der Agent einen leeren Zustand und bleibt
// verbunden; ein fehlender Socket ist kein Fehler.
func TestRun_WithoutDocker(t *testing.T) {
s := startServer(t, "", "")
cfg := testConfig(t, filepath.Join(t.TempDir(), "suldata"), s, s.invite(t, "kunde"), "/gibt/es/nicht.sock")
runAgent(t, cfg)
eventually(t, "Knoten online", func() bool { return s.nodeStatus(t, "node-1") == store.NodeOnline })
if c := s.traefikConfig(t); c != "{}" {
t.Errorf("Konfiguration ohne Docker: %s", c)
}
}
// Läuft Run ohne Enrollment-Möglichkeit, endet es sofort mit Fehler.
func TestRun_NotEnrolled(t *testing.T) {
s := startServer(t, "", "")
a, _ := New(testConfig(t, filepath.Join(t.TempDir(), "suldata"), s, "", ""))
if err := a.Run(ctx); !errors.Is(err, ErrNotEnrolled) {
t.Errorf("erwartet ErrNotEnrolled, bekommen %v", err)
}
}
// Bare-Metal-Weg: Dienst aus /etc/sultan.d → Route; Healthcheck schlägt fehl →
// Wartungsseite (Interceptor); Dienst erholt sich → wieder normal; Datei
// geändert/gelöscht → Konfiguration folgt.
func TestRun_ServiceDirWithHealthcheck(t *testing.T) {
s := startServer(t, "", "")
var healthy atomic.Bool
healthy.Store(true)
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !healthy.Load() {
http.Error(w, "kaputt", http.StatusInternalServerError)
return
}
w.Write([]byte("<html><head><title>Shop</title></head><body><h2>Impressum</h2></body></html>"))
}))
defer backend.Close()
port := backend.URL[strings.LastIndex(backend.URL, ":")+1:]
confDir := t.TempDir()
writeConf := func(content string) {
if err := os.WriteFile(filepath.Join(confDir, "shop.yaml"), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
writeConf(`
services:
- name: shop
url: ` + backend.URL + `
hosts: [shop.kunde.de]
healthcheck: { interval: 30ms, failures: 2, expect_title: Shop }
`)
cfg := testConfig(t, filepath.Join(t.TempDir(), "suldata"), s, s.invite(t, "kunde"), NoDocker)
cfg.ConfigDir = confDir
cfg.ConfigPoll = 20 * time.Millisecond
runAgent(t, cfg)
eventually(t, "Route aus sultan.d", func() bool {
c := s.traefikConfig(t)
return strings.Contains(c, "kunde__node-1__shop") && strings.Contains(c, "http://100.64.0.42:"+port)
})
if strings.Contains(s.traefikConfig(t), "sultan__interceptor") {
t.Error("gesunder Dienst wird umgeleitet")
}
healthy.Store(false)
eventually(t, "Wartungsseite bei Ausfall", func() bool {
return strings.Contains(s.traefikConfig(t), "sultan__interceptor")
})
healthy.Store(true)
eventually(t, "zurück zum Dienst nach Erholung", func() bool {
return !strings.Contains(s.traefikConfig(t), "sultan__interceptor")
})
writeConf(`
services:
- name: shop
url: ` + backend.URL + `
hosts: [shop.kunde.de, laden.kunde.de]
`)
eventually(t, "geänderte Datei übernommen", func() bool {
return strings.Contains(s.traefikConfig(t), "laden.kunde.de")
})
os.Remove(filepath.Join(confDir, "shop.yaml"))
eventually(t, "gelöschte Datei → Route weg", func() bool { return s.traefikConfig(t) == "{}" })
}
// Docker- und Datei-Workloads werden gemeinsam gemeldet.
func TestRun_DockerAndServiceDirTogether(t *testing.T) {
s := startServer(t, "", "")
dock := newFakeDocker(t, wordpress())
confDir := t.TempDir()
os.WriteFile(filepath.Join(confDir, "blog.yaml"), []byte(`
services:
- name: blog
url: http://127.0.0.1:8090
hosts: [blog.kunde.de]
`), 0o644)
cfg := testConfig(t, filepath.Join(t.TempDir(), "suldata"), s, s.invite(t, "kunde"), dock.socket)
cfg.ConfigDir = confDir
cfg.ConfigPoll = 20 * time.Millisecond
runAgent(t, cfg)
eventually(t, "beide Routen", func() bool {
c := s.traefikConfig(t)
return strings.Contains(c, "kunde__node-1__wp") && strings.Contains(c, "kunde__node-1__blog")
})
}
// inviteURL erzeugt eine Einladung mit eingebetteter Server-URL.
func (s *testServer) inviteURL(t *testing.T, tenant, serverURL string) string {
t.Helper()
s.invite(t, tenant) // legt den Mandanten bei Bedarf an
tok, err := crypto.NewEnrollmentToken(s.Fingerprint(), serverURL)
if err != nil {
t.Fatal(err)
}
if err := s.db(t).AddEnrollmentToken(ctx, tok.Secret, tenant, time.Now().Add(time.Hour)); err != nil {
t.Fatal(err)
}
return tok.String()
}
func readIdentity(t *testing.T, dataDir string) Identity {
t.Helper()
var id Identity
b, err := os.ReadFile(filepath.Join(dataDir, "node.json"))
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(b, &id); err != nil {
t.Fatal(err)
}
return id
}
// Ein einziger Token genügt: die Adresse steckt darin.
func TestEnroll_URLFromToken(t *testing.T) {
s := startServer(t, "", "")
dir := filepath.Join(t.TempDir(), "suldata")
cfg := testConfig(t, dir, s, s.inviteURL(t, "kunde", s.url()), "")
cfg.ServerURL = ""
a, _ := New(cfg)
if err := a.Enroll(ctx); err != nil {
t.Fatalf("Enroll nur mit Token: %v", err)
}
if id := readIdentity(t, dir); id.Server != s.url() || id.ServerFingerprint != s.Fingerprint() {
t.Errorf("node.json: %+v", id)
}
}
// --server schlägt die URL im Token (z. B. interne Adresse statt öffentlicher).
func TestEnroll_ServerFlagOverridesTokenURL(t *testing.T) {
s := startServer(t, "", "")
dir := filepath.Join(t.TempDir(), "suldata")
cfg := testConfig(t, dir, s, s.inviteURL(t, "kunde", "https://nicht-erreichbar.invalid:8443"), "")
a, _ := New(cfg) // cfg.ServerURL = s.url()
if err := a.Enroll(ctx); err != nil {
t.Fatalf("Enroll mit Override: %v", err)
}
if id := readIdentity(t, dir); id.Server != s.url() {
t.Errorf("gespeicherte Adresse %q, erwartet die tatsächlich genutzte %q", id.Server, s.url())
}
}
// Ist der Agent registriert, darf dieselbe Einladung (bzw. eine des
// gleichen Servers) erneut mitgegeben werden – sie wird ignoriert.
func TestEnroll_SameServerTokenIgnoredWhenEnrolled(t *testing.T) {
s := startServer(t, "", "")
dir := filepath.Join(t.TempDir(), "suldata")
tok := s.inviteURL(t, "kunde", s.url())
cfg := testConfig(t, dir, s, tok, "")
a, _ := New(cfg)
if err := a.Enroll(ctx); err != nil {
t.Fatal(err)
}
again, _ := New(cfg) // gleicher, inzwischen verbrauchter Token (z. B. in docker-compose stehen geblieben)
if err := again.Enroll(ctx); err != nil {
t.Errorf("erneuter Start mit altem Token: %v", err)
}
}
// Der Pin hängt am Server, nicht an der Adresse: derselbe Server unter
// neuer Adresse funktioniert, die neue Adresse wird übernommen.
func TestRun_AddressChangePersisted(t *testing.T) {
s := startServer(t, "", "")
dock := newFakeDocker(t, wordpress())
dir := filepath.Join(t.TempDir(), "suldata")
cfg := testConfig(t, dir, s, s.invite(t, "kunde"), dock.socket)
a, _ := New(cfg)
if err := a.Enroll(ctx); err != nil {
t.Fatal(err)
}
moved := "https://localhost" + s.url()[len("https://127.0.0.1"):]
cfg.Token, cfg.ServerURL = "", moved
runAgent(t, cfg)
eventually(t, "Route über neue Adresse", func() bool {
return strings.Contains(s.traefikConfig(t), "kunde__node-1__wp")
})
eventually(t, "neue Adresse gespeichert", func() bool { return readIdentity(t, dir).Server == moved })
if id := readIdentity(t, dir); id.ServerFingerprint != s.Fingerprint() {
t.Errorf("Fingerprint verändert: %+v", id)
}
}
// `sultan agent maintenance on|off` auf dem Knoten, z. B. aus einem Patch-Skript.
func TestSetMaintenance(t *testing.T) {
s := startServer(t, "", "")
dock := newFakeDocker(t, wordpress())
dir := filepath.Join(t.TempDir(), "suldata")
runAgent(t, testConfig(t, dir, s, s.invite(t, "kunde"), dock.socket))
eventually(t, "Route", func() bool { return strings.Contains(s.traefikConfig(t), "kunde__node-1__wp") })
ctl, err := New(Config{DataDir: dir, Logger: slog.New(slog.DiscardHandler)})
if err != nil {
t.Fatal(err)
}
if err := ctl.SetMaintenance(ctx, true, 2*time.Hour, "Kernel-Update", "Sicherheitsupdate"); err != nil {
t.Fatalf("SetMaintenance: %v", err)
}
n, _ := s.db(t).GetNode(ctx, "node-1")
if n.Maintenance == nil || n.Maintenance.Reason != "Kernel-Update" || n.Maintenance.PublicReason != "Sicherheitsupdate" {
t.Errorf("gespeichert: %+v", n.Maintenance)
}
if !strings.Contains(s.traefikConfig(t), "sultan__interceptor") {
t.Error("Wartung nicht wirksam")
}
if err := ctl.SetMaintenance(ctx, false, 0, "", ""); err != nil {
t.Fatal(err)
}
if strings.Contains(s.traefikConfig(t), "sultan__interceptor") {
t.Error("Wartung nicht beendet")
}
notEnrolled, _ := New(Config{DataDir: filepath.Join(t.TempDir(), "leer"), Logger: slog.New(slog.DiscardHandler)})
if err := notEnrolled.SetMaintenance(ctx, true, time.Hour, "", ""); !errors.Is(err, ErrNotEnrolled) {
t.Errorf("nicht registriert: %v", err)
}
}
func TestRun_LeaseRecorded(t *testing.T) {
s := startServer(t, "", "")
dir := filepath.Join(t.TempDir(), "suldata")
var logs syncBuffer
cfg := testConfig(t, dir, s, s.invite(t, "kunde"), NoDocker)
cfg.Logger = slog.New(slog.NewTextHandler(&logs, nil))
runAgent(t, cfg)
eventually(t, "lease.json", func() bool {
_, err := os.Stat(filepath.Join(dir, "lease.json"))
return err == nil
})
b, _ := os.ReadFile(filepath.Join(dir, "lease.json"))
l, err := license.ParseLease(b, s.MasterPublicKey())
if err != nil || l.NodeID != "node-1" {
t.Errorf("lease.json: %+v %v", l, err)
}
eventually(t, "Lease im Log", func() bool { return strings.Contains(logs.String(), "Lease erhalten") })
}
// resync: Der Server fordert den Zustand neu an (hier: Container geändert,
// ohne dass Docker ein Event geschickt hat).
func TestRun_ResyncCommand(t *testing.T) {
s := startServer(t, "", "")
dock := newFakeDocker(t)
runAgent(t, testConfig(t, filepath.Join(t.TempDir(), "suldata"), s, s.invite(t, "kunde"), dock.socket))
eventually(t, "online", func() bool { return s.nodeStatus(t, "node-1") == store.NodeOnline })
dock.mu.Lock()
dock.containers = []docker.Container{wordpress()}
dock.mu.Unlock()
if err := s.db(t).EnqueueNodeCommand(ctx, "node-1", stream.CmdResync, ""); err != nil {
t.Fatal(err)
}
eventually(t, "Zustand nach resync", func() bool { return strings.Contains(s.traefikConfig(t), "kunde__node-1__wp") })
}
func TestRun_RotateKeyCommand(t *testing.T) {
s := startServer(t, "", "")
dock := newFakeDocker(t)
dir := filepath.Join(t.TempDir(), "suldata")
runAgent(t, testConfig(t, dir, s, s.invite(t, "kunde"), dock.socket))
eventually(t, "online", func() bool { return s.nodeStatus(t, "node-1") == store.NodeOnline })
before, _ := os.ReadFile(filepath.Join(dir, "node.key"))
oldPub, _ := s.db(t).GetNode(ctx, "node-1")
s.db(t).EnqueueNodeCommand(ctx, "node-1", stream.CmdRotateKey, "Routine")
eventually(t, "Schlüssel gewechselt", func() bool {
n, _ := s.db(t).GetNode(ctx, "node-1")
after, _ := os.ReadFile(filepath.Join(dir, "node.key"))
return n.PublicKey != oldPub.PublicKey && string(after) != string(before)
})
if _, err := os.Stat(filepath.Join(dir, "node.key.new")); err == nil {
t.Error("node.key.new nach erfolgreicher Rotation noch vorhanden")
}
// Der Agent arbeitet mit dem neuen Schlüssel weiter.
dock.set("start", wordpress())
eventually(t, "Zustand mit neuem Schlüssel", func() bool { return strings.Contains(s.traefikConfig(t), "kunde__node-1__wp") })
}
// Abbruch mitten in der Rotation: Der Server hat den neuen Schlüssel schon,
// der Agent hat node.key.new noch nicht umbenannt. Beim nächsten Start wird
// das sicher abgeschlossen.
func TestRotateKey_RecoveryAfterCrash(t *testing.T) {
s := startServer(t, "", "")
dir := filepath.Join(t.TempDir(), "suldata")
cfg := testConfig(t, dir, s, s.invite(t, "kunde"), NoDocker)
a, _ := New(cfg)
if err := a.Enroll(ctx); err != nil {
t.Fatal(err)
}
newKey, _ := auth.GenerateKey()
if err := auth.WritePrivateKey(filepath.Join(dir, "node.key.new"), newKey); err != nil {
t.Fatal(err)
}
newPub := auth.EncodePublicKey(newKey.Public().(ed25519.PublicKey))
if err := s.db(t).UpdateNodePublicKey(ctx, "node-1", newPub); err != nil { // Server war schon fertig
t.Fatal(err)
}
cfg.Token = ""
runAgent(t, cfg)
eventually(t, "online mit neuem Schlüssel", func() bool { return s.nodeStatus(t, "node-1") == store.NodeOnline })
got, err := auth.ReadPrivateKey(filepath.Join(dir, "node.key"))
if err != nil || !got.Equal(newKey) {
t.Errorf("node.key nicht durch den neuen Schlüssel ersetzt: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "node.key.new")); err == nil {
t.Error("node.key.new noch vorhanden")
}
}
// Unsignierte oder gefälschte Events werden ignoriert – kein Befehl ohne
// gültige Signatur des Servers.
func TestHandleEvent_RejectsUnsigned(t *testing.T) {
s := startServer(t, "", "")
dir := filepath.Join(t.TempDir(), "suldata")
var logs syncBuffer
cfg := testConfig(t, dir, s, s.invite(t, "kunde"), NoDocker)
cfg.Logger = slog.New(slog.NewTextHandler(&logs, nil))
a, _ := New(cfg)
if err := a.Enroll(ctx); err != nil {
t.Fatal(err)
}
before, _ := os.ReadFile(filepath.Join(dir, "node.key"))
forged, _ := stream.NewEvent(stream.EventNodeCommand, stream.NodeCommand{Command: stream.CmdRotateKey})
if err := a.handleEvent(forged); err != nil {
t.Errorf("handleEvent darf den Stream nicht beenden: %v", err)
}
after, _ := os.ReadFile(filepath.Join(dir, "node.key"))
if string(before) != string(after) {
t.Error("unsignierter Befehl wurde ausgeführt")
}
if !strings.Contains(logs.String(), "verworfen") {
t.Errorf("keine Warnung im Log: %s", logs.String())
}
}
type syncBuffer struct {
mu sync.Mutex
b strings.Builder
}
func (s *syncBuffer) Write(p []byte) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.b.Write(p)
}
func (s *syncBuffer) String() string { s.mu.Lock(); defer s.mu.Unlock(); return s.b.String() }
// Der Server schickt beim Verbinden seine Adressliste; der Agent merkt sie
// sich in node.json (nur gültige https-Adressen).
func TestRun_StoresServerURLsFromConfig(t *testing.T) {
s := startServerWith(t, func(c *server.Config) {
c.PublicURLs = []string{"https://ingress.example:8443", "https://100.64.0.1:8443"}
})
dir := filepath.Join(t.TempDir(), "suldata")
runAgent(t, testConfig(t, dir, s, s.invite(t, "kunde"), NoDocker))
eventually(t, "server_urls in node.json", func() bool {
b, err := os.ReadFile(filepath.Join(dir, "node.json"))
var id Identity
return err == nil && json.Unmarshal(b, &id) == nil && len(id.ServerURLs) == 2
})
if id := readIdentity(t, dir); id.ServerURLs[0] != "https://ingress.example:8443" || id.Server != s.url() {
t.Errorf("node.json: %+v", id)
}
}
// Ausweichweg: Die zuletzt genutzte Adresse ist tot, eine andere aus der
// Liste funktioniert – der Agent verbindet sich darüber und merkt sie sich.
func TestRun_FailoverToNextServerURL(t *testing.T) {
s := startServer(t, "", "")
dir := filepath.Join(t.TempDir(), "suldata")
cfg := testConfig(t, dir, s, s.invite(t, "kunde"), NoDocker)
a, _ := New(cfg)
if err := a.Enroll(ctx); err != nil {
t.Fatal(err)
}
id := readIdentity(t, dir)
dead := "https://127.0.0.1:1"
id.Server, id.ServerURLs = dead, []string{dead, s.url()}
if err := writeJSON(filepath.Join(dir, "node.json"), id); err != nil {
t.Fatal(err)
}
cfg.Token, cfg.ServerURL = "", ""
runAgent(t, cfg)
eventually(t, "online über Ausweichadresse", func() bool { return s.nodeStatus(t, "node-1") == store.NodeOnline })
eventually(t, "funktionierende Adresse gemerkt", func() bool { return readIdentity(t, dir).Server == s.url() })
}
// --server hat Vorrang, die übrigen Adressen dienen als Ausweichweg.
func TestServerCandidates(t *testing.T) {
a := &Agent{cfg: Config{ServerURL: "https://override:8443"}, id: Identity{
Server: "https://zuletzt:8443", ServerURLs: []string{"https://a:8443", "https://zuletzt:8443", "https://b:8443"},
}}
want := []string{"https://override:8443", "https://zuletzt:8443", "https://a:8443", "https://b:8443"}
if got := a.serverCandidates(); !reflect.DeepEqual(got, want) {
t.Errorf("Kandidaten %v, erwartet %v", got, want)
}
a.cfg.ServerURL = ""
if got := a.serverCandidates(); got[0] != "https://zuletzt:8443" || len(got) != 3 {
t.Errorf("ohne Override: %v", got)
}
}
// Zusatzdaten für Inventar und Störfall kommen automatisch beim Server an:
// Compose-Projekt/-Verzeichnis, Startzeit, Health je Container; FQDN,
// Betriebssystem (des Hosts) und Docker-Version je Knoten. Ein Wechsel des
// Health-Status wird nachgemeldet.
func TestRun_ReportsMetadata(t *testing.T) {
s := startServer(t, "", "")
started := time.Date(2026, 10, 16, 8, 15, 30, 0, time.UTC)
wp := wordpress()
wp.Labels["com.docker.compose.project"] = "kunde-a"
wp.Labels["com.docker.compose.service"] = "wordpress"
wp.Labels["com.docker.compose.project.working_dir"] = "/srv/kunde-a"
wp.StartedAt = started
wp.Health = "healthy"
dock := newFakeDocker(t, wp)
cfg := testConfig(t, filepath.Join(t.TempDir(), "suldata"), s, s.invite(t, "kunde"), dock.socket)
cfg.FQDN = "webdocker11.lan.r25dc.de"
runAgent(t, cfg)
var st registry.NodeState
eventually(t, "Zustand mit Zusatzdaten", func() bool {
var ok bool
st, ok = s.NodeState("node-1")
return ok && len(st.Workloads) == 1 && st.Host != nil
})
wantMeta := &registry.WorkloadMeta{
ComposeProject: "kunde-a", ComposeService: "wordpress", ComposeDir: "/srv/kunde-a",
State: "running", StartedAt: started, Health: "healthy",
}
if !reflect.DeepEqual(st.Workloads[0].Meta, wantMeta) {
t.Errorf("Meta = %+v, erwartet %+v", st.Workloads[0].Meta, wantMeta)
}
if h := st.Host; h.FQDN != "webdocker11.lan.r25dc.de" || h.OS != "Debian GNU/Linux 13 (trixie)" || h.DockerVersion != "29.8.2" {
t.Errorf("Host = %+v", h)
}
sick := wp
sick.Health = "unhealthy"
dock.set("health_status: unhealthy", sick)
eventually(t, "Health-Wechsel nachgemeldet", func() bool {
st, ok := s.NodeState("node-1")
return ok && len(st.Workloads) == 1 && st.Workloads[0].Meta != nil && st.Workloads[0].Meta.Health == "unhealthy"
})
}