Files
sultan/pkg/registry/registry_test.go
T

506 lines
17 KiB
Go

package registry
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"reflect"
"slices"
"sync"
"testing"
"time"
"sultan/pkg/license"
"sultan/pkg/policy"
"sultan/pkg/store"
"sultan/pkg/traefik"
)
var ctx = context.Background()
// fakeDirectory liefert Mandant/Lizenz je Knoten; err simuliert Ausfälle.
type fakeDirectory struct {
mu sync.Mutex
nodes map[string]NodeContext
err map[string]error
}
func (d *fakeDirectory) NodeContext(_ context.Context, nodeID string) (NodeContext, error) {
d.mu.Lock()
defer d.mu.Unlock()
if err := d.err[nodeID]; err != nil {
return NodeContext{}, err
}
nc, ok := d.nodes[nodeID]
if !ok {
return NodeContext{}, ErrUnknownNode
}
return nc, nil
}
func (d *fakeDirectory) set(nodeID string, f func(*NodeContext)) {
d.mu.Lock()
defer d.mu.Unlock()
nc := d.nodes[nodeID]
f(&nc)
d.nodes[nodeID] = nc
}
func nodeCtx(tenant string, domains ...string) NodeContext {
return NodeContext{
Tenant: policy.Tenant{ID: tenant, Status: store.TenantActive},
License: license.License{
TenantID: tenant, Tier: license.TierPro, MaxRoutes: 10,
AllowedDomains: domains, ValidUntil: time.Now().AddDate(1, 0, 0),
},
}
}
type audit struct {
mu sync.Mutex
log []Violation
}
func (a *audit) record(v Violation) { a.mu.Lock(); a.log = append(a.log, v); a.mu.Unlock() }
func (a *audit) entries() []Violation {
a.mu.Lock()
defer a.mu.Unlock()
return append([]Violation(nil), a.log...)
}
func newRegistry() (*Registry, *fakeDirectory, *audit) {
dir := &fakeDirectory{
nodes: map[string]NodeContext{
"n1": nodeCtx("kunde", "kunde.de", "*.kunde.de"),
"n2": nodeCtx("andere", "andere.de"),
},
err: map[string]error{},
}
a := &audit{}
r := &Registry{
Directory: dir,
Policy: &policy.Engine{InterceptorURL: "http://127.0.0.1:8081"},
OnViolation: a.record,
}
return r, dir, a
}
func wp(name, host, domain string) Workload {
return Workload{Name: name, Host: host, Labels: map[string]string{
"traefik.enable": "true",
"traefik.http.routers.wp.rule": "Host(`" + domain + "`)",
"traefik.http.services.wp.loadbalancer.server.port": "80",
}}
}
func routers(r *Registry) []string {
c := r.Config()
if c.HTTP == nil {
return nil
}
var out []string
for name := range c.HTTP.Routers {
out = append(out, name)
}
slices.Sort(out)
return out
}
func TestRegistry_Empty(t *testing.T) {
r, _, _ := newRegistry()
if c := r.Config(); c == nil || c.HTTP != nil {
t.Errorf("leere Registry: %+v", c)
}
}
func TestRegistry_UpdateAndAggregate(t *testing.T) {
r, _, a := newRegistry()
if err := r.Update(ctx, "n1", NodeState{Workloads: []Workload{wp("wordpress", "100.64.0.1", "kunde.de")}}); err != nil {
t.Fatalf("Update n1: %v", err)
}
// Gleicher Router-Name bei einem anderen Mandanten: kein Konflikt.
if err := r.Update(ctx, "n2", NodeState{Workloads: []Workload{wp("wordpress", "100.64.0.2", "andere.de")}}); err != nil {
t.Fatalf("Update n2: %v", err)
}
if got := routers(r); !reflect.DeepEqual(got, []string{"andere__n2__wp", "kunde__n1__wp"}) {
t.Errorf("Router = %v", got)
}
svc := r.Config().HTTP.Services["kunde__n1__wp"]
if svc.LoadBalancer.Servers[0].URL != "http://100.64.0.1:80" {
t.Errorf("Backend = %s, erwartet Host des Workloads", svc.LoadBalancer.Servers[0].URL)
}
if len(a.entries()) != 0 {
t.Errorf("unerwartete Violations: %+v", a.entries())
}
}
// service_state ist ein vollständiger Schnappschuss, kein Delta.
func TestRegistry_UpdateReplacesState(t *testing.T) {
r, _, _ := newRegistry()
r.Update(ctx, "n1", NodeState{Workloads: []Workload{wp("wordpress", "100.64.0.1", "kunde.de")}})
r.Update(ctx, "n1", NodeState{})
if got := routers(r); len(got) != 0 {
t.Errorf("Router nach leerem Schnappschuss: %v", got)
}
}
func TestRegistry_Remove(t *testing.T) {
r, _, _ := newRegistry()
r.Update(ctx, "n1", NodeState{Workloads: []Workload{wp("wordpress", "100.64.0.1", "kunde.de")}})
r.Update(ctx, "n2", NodeState{Workloads: []Workload{wp("wordpress", "100.64.0.2", "andere.de")}})
r.Remove("n1")
if got := routers(r); !reflect.DeepEqual(got, []string{"andere__n2__wp"}) {
t.Errorf("Router nach Remove: %v", got)
}
r.Remove("gibts-nicht") // darf nicht stören
if !reflect.DeepEqual(r.Nodes(), []string{"n2"}) {
t.Errorf("Nodes = %v", r.Nodes())
}
}
func TestRegistry_UpdateErrors(t *testing.T) {
r, dir, _ := newRegistry()
if err := r.Update(ctx, "", NodeState{}); err == nil {
t.Error("leere Knoten-ID akzeptiert")
}
if err := r.Update(ctx, "fremd", NodeState{}); !errors.Is(err, ErrUnknownNode) {
t.Errorf("unbekannter Knoten: erwartet ErrUnknownNode, bekommen %v", err)
}
dbDown := errors.New("DB weg")
dir.err["n1"] = dbDown
if err := r.Update(ctx, "n1", NodeState{}); !errors.Is(err, dbDown) {
t.Errorf("Directory-Fehler: %v", err)
}
if len(r.Nodes()) != 0 {
t.Errorf("fehlgeschlagene Updates wurden gespeichert: %v", r.Nodes())
}
}
func TestRegistry_PolicyViolationsAuditedOnce(t *testing.T) {
r, _, a := newRegistry()
bad := NodeState{Workloads: []Workload{
wp("wordpress", "100.64.0.1", "kunde.de"),
{Name: "phish", Host: "100.64.0.1", Labels: map[string]string{
"traefik.enable": "true",
"traefik.http.routers.bank.rule": "Host(`bank.de`)",
"traefik.http.services.bank.loadbalancer.server.port": "81",
}},
}}
r.Update(ctx, "n1", bad)
if got := routers(r); !reflect.DeepEqual(got, []string{"kunde__n1__wp"}) {
t.Errorf("Router = %v", got)
}
log := a.entries()
if len(log) != 1 || log[0].NodeID != "n1" || log[0].Kind != policy.ViolationDomain || log[0].Router != "bank" {
t.Fatalf("Audit = %+v", log)
}
// Gleicher Zustand erneut (z. B. nächster Heartbeat): nicht erneut melden.
r.Update(ctx, "n1", bad)
r.Refresh(ctx)
if n := len(a.entries()); n != 1 {
t.Errorf("Violation %d-mal gemeldet, erwartet 1", n)
}
// Behoben und wieder kaputt: erneut melden.
r.Update(ctx, "n1", NodeState{Workloads: bad.Workloads[:1]})
r.Update(ctx, "n1", bad)
if n := len(a.entries()); n != 2 {
t.Errorf("erneuter Verstoß nicht gemeldet (%d Einträge)", n)
}
}
func TestRegistry_LabelErrorsSkipWorkload(t *testing.T) {
r, _, a := newRegistry()
broken := Workload{Name: "kaputt", Host: "100.64.0.1", Labels: map[string]string{
"traefik.enable": "true",
"traefik.tcp.routers.db.rule": "HostSNI(`*`)",
}}
r.Update(ctx, "n1", NodeState{Workloads: []Workload{broken, wp("wordpress", "100.64.0.1", "kunde.de")}})
if got := routers(r); !reflect.DeepEqual(got, []string{"kunde__n1__wp"}) {
t.Errorf("Router = %v", got)
}
log := a.entries()
if len(log) != 1 || log[0].Kind != ViolationLabels || log[0].Workload != "kaputt" {
t.Errorf("Audit = %+v", log)
}
}
// Zwei Workloads eines Knotens mit gleichem Router-Namen: der zweite
// (nach Workload-Namen) wird übersprungen.
func TestRegistry_NameConflictWithinNode(t *testing.T) {
r, _, a := newRegistry()
r.Update(ctx, "n1", NodeState{Workloads: []Workload{
wp("b-zweiter", "100.64.0.2", "b.kunde.de"),
wp("a-erster", "100.64.0.1", "a.kunde.de"),
}})
cfg := r.Config()
if rule := cfg.HTTP.Routers["kunde__n1__wp"].Rule; rule != "Host(`a.kunde.de`)" {
t.Errorf("Regel = %s, erwartet den ersten Workload", rule)
}
log := a.entries()
if len(log) != 1 || log[0].Kind != ViolationNameConflict || log[0].Workload != "b-zweiter" {
t.Errorf("Audit = %+v", log)
}
}
func TestRegistry_DuplicateWorkloadName(t *testing.T) {
r, _, a := newRegistry()
r.Update(ctx, "n1", NodeState{Workloads: []Workload{
wp("gleich", "100.64.0.1", "kunde.de"),
wp("gleich", "100.64.0.2", "kunde.de"),
}})
log := a.entries()
if len(log) != 1 || log[0].Kind != ViolationNameConflict {
t.Errorf("Audit = %+v", log)
}
}
func TestRegistry_DownServicesGoToMaintenance(t *testing.T) {
r, _, _ := newRegistry()
r.Update(ctx, "n1", NodeState{
Workloads: []Workload{wp("wordpress", "100.64.0.1", "kunde.de")},
DownServices: []string{"wp"},
})
if svc := r.Config().HTTP.Routers["kunde__n1__wp"].Service; svc != policy.InterceptorService {
t.Errorf("Service = %s, erwartet Interceptor", svc)
}
}
func TestRegistry_RefreshAppliesDirectoryChanges(t *testing.T) {
r, dir, _ := newRegistry()
r.Update(ctx, "n1", NodeState{Workloads: []Workload{wp("wordpress", "100.64.0.1", "kunde.de")}})
dir.set("n1", func(nc *NodeContext) { nc.Tenant.Status = store.TenantSuspendedPayment })
if err := r.Refresh(ctx); err != nil {
t.Fatal(err)
}
if svc := r.Config().HTTP.Routers["kunde__n1__wp"].Service; svc != policy.InterceptorService {
t.Errorf("nach Sperre: Service = %s", svc)
}
dir.set("n1", func(nc *NodeContext) { nc.Drained = true })
r.Refresh(ctx)
if got := routers(r); len(got) != 0 {
t.Errorf("gedraint: Router = %v", got)
}
}
// Fällt das Directory vorübergehend aus, bleibt der letzte gültige Stand
// eines Knotens erhalten (fail-static); ist der Knoten gelöscht, verschwindet er.
func TestRegistry_RefreshDirectoryFailures(t *testing.T) {
r, dir, _ := newRegistry()
r.Update(ctx, "n1", NodeState{Workloads: []Workload{wp("wordpress", "100.64.0.1", "kunde.de")}})
r.Update(ctx, "n2", NodeState{Workloads: []Workload{wp("wordpress", "100.64.0.2", "andere.de")}})
dbDown := errors.New("DB weg")
dir.err["n1"] = dbDown
if err := r.Refresh(ctx); !errors.Is(err, dbDown) {
t.Errorf("Refresh: erwartet DB-Fehler, bekommen %v", err)
}
if got := routers(r); !reflect.DeepEqual(got, []string{"andere__n2__wp", "kunde__n1__wp"}) {
t.Errorf("fail-static verletzt: %v", got)
}
dir.err["n1"] = ErrUnknownNode
r.Refresh(ctx)
if got := routers(r); !reflect.DeepEqual(got, []string{"andere__n2__wp"}) {
t.Errorf("gelöschter Knoten noch aktiv: %v", got)
}
}
func TestRegistry_PolicyErrorKeepsPreviousState(t *testing.T) {
r, dir, _ := newRegistry()
r.Update(ctx, "n1", NodeState{Workloads: []Workload{wp("wordpress", "100.64.0.1", "kunde.de")}})
dir.set("n1", func(nc *NodeContext) { nc.License.TenantID = "falsch" }) // Policy lehnt ab
if err := r.Update(ctx, "n1", NodeState{}); !errors.Is(err, policy.ErrInvalidInput) {
t.Errorf("erwartet policy.ErrInvalidInput, bekommen %v", err)
}
if got := routers(r); !reflect.DeepEqual(got, []string{"kunde__n1__wp"}) {
t.Errorf("vorheriger Stand verloren: %v", got)
}
}
func TestRegistry_Handler(t *testing.T) {
r, _, _ := newRegistry()
r.Update(ctx, "n1", NodeState{Workloads: []Workload{wp("wordpress", "100.64.0.1", "kunde.de")}})
srv := httptest.NewServer(r.Handler())
defer srv.Close()
resp, err := http.Get(srv.URL + "/v1/traefik-config")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK || resp.Header.Get("Content-Type") != "application/json" {
t.Fatalf("Status %d, Content-Type %q", resp.StatusCode, resp.Header.Get("Content-Type"))
}
var got traefik.Configuration
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
t.Fatal(err)
}
if got.HTTP.Routers["kunde__n1__wp"] == nil {
t.Errorf("Router fehlt in der Antwort: %+v", got.HTTP)
}
yamlResp, _ := http.Get(srv.URL + "/v1/traefik-config?format=yaml")
yamlResp.Body.Close()
if yamlResp.Header.Get("Content-Type") != "application/yaml" {
t.Errorf("YAML: Content-Type %q", yamlResp.Header.Get("Content-Type"))
}
bad, _ := http.Get(srv.URL + "/v1/traefik-config?format=xml")
bad.Body.Close()
if bad.StatusCode != http.StatusBadRequest {
t.Errorf("unbekanntes Format: Status %d", bad.StatusCode)
}
post, _ := http.Post(srv.URL+"/v1/traefik-config", "text/plain", nil)
post.Body.Close()
if post.StatusCode != http.StatusMethodNotAllowed {
t.Errorf("POST: Status %d", post.StatusCode)
}
}
func TestRegistry_HandlerEmpty(t *testing.T) {
r, _, _ := newRegistry()
rec := httptest.NewRecorder()
r.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/traefik-config", nil))
if rec.Body.String() != "{}" {
t.Errorf("leere Konfiguration = %q, erwartet {}", rec.Body.String())
}
}
func TestRegistry_Concurrent(t *testing.T) {
r, dir, _ := newRegistry()
for i := range 20 {
dir.nodes[fmt.Sprintf("k%d", i)] = nodeCtx("kunde", "kunde.de", "*.kunde.de")
}
var wg sync.WaitGroup
for i := range 20 {
node := fmt.Sprintf("k%d", i)
wg.Go(func() {
for j := range 10 {
r.Update(ctx, node, NodeState{Workloads: []Workload{wp("w", "100.64.0.1", fmt.Sprintf("s%d.kunde.de", j))}})
r.Config()
}
})
}
wg.Go(func() {
for range 50 {
r.Refresh(ctx)
rec := httptest.NewRecorder()
r.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/traefik-config", nil))
}
})
wg.Wait()
if n := len(routers(r)); n != 20 {
t.Errorf("%d Router, erwartet 20", n)
}
}
// Container/Dienst in Wartung per Label: nur dessen Router gehen zum Lander.
func TestRegistry_ServiceMaintenanceLabel(t *testing.T) {
r, _, _ := newRegistry()
shop := wp("shop", "100.64.0.1", "shop.kunde.de")
shop.Labels["traefik.http.routers.wp.rule"] = "Host(`shop.kunde.de`)"
shop.Labels = map[string]string{
"traefik.enable": "true",
"traefik.http.routers.shop.rule": "Host(`shop.kunde.de`)",
"traefik.http.services.shop.loadbalancer.server.port": "8080",
"sultan.maintenance": "true",
"sultan.maintenance.public_reason": "Inventur",
}
r.Update(ctx, "n1", NodeState{Workloads: []Workload{wp("wordpress", "100.64.0.1", "kunde.de"), shop}})
cfg := r.Config().HTTP
if cfg.Routers["kunde__n1__shop"].Service != policy.InterceptorService {
t.Error("Dienst mit sultan.maintenance=true nicht umgeleitet")
}
if cfg.Routers["kunde__n1__wp"].Service == policy.InterceptorService {
t.Error("anderer Dienst wurde umgeleitet")
}
hdr := cfg.Middlewares["sultan__kunde__n1__shop"]["headers"].(map[string]any)["customRequestHeaders"].(map[string]any)
if hdr[policy.HeaderPublicReason] != "Inventur" {
t.Errorf("öffentlicher Grund: %v", hdr)
}
shop.Labels["sultan.maintenance"] = "false"
r.Update(ctx, "n1", NodeState{Workloads: []Workload{shop}})
if r.Config().HTTP.Routers["kunde__n1__shop"].Service == policy.InterceptorService {
t.Error("sultan.maintenance=false leitet um")
}
}
func TestRegistry_NodeMaintenanceFromDirectory(t *testing.T) {
r, dir, _ := newRegistry()
r.Update(ctx, "n1", NodeState{Workloads: []Workload{wp("wordpress", "100.64.0.1", "kunde.de")}})
dir.set("n1", func(nc *NodeContext) { nc.Maintenance = &policy.Maintenance{Until: time.Now().Add(time.Hour)} })
r.Refresh(ctx)
if r.Config().HTTP.Routers["kunde__n1__wp"].Service != policy.InterceptorService {
t.Error("Knotenwartung aus dem Directory nicht angewendet")
}
}
// Zusatzdaten (Compose, Startzeit, Health, Host-Infos) werden mit dem
// Zustand gespeichert, ändern das Routing aber nicht.
func TestRegistry_StateKeepsMetadata(t *testing.T) {
r, _, _ := newRegistry()
plain := wp("wordpress", "100.64.0.1", "kunde.de")
if err := r.Update(ctx, "n1", NodeState{Workloads: []Workload{plain}}); err != nil {
t.Fatal(err)
}
before, _ := json.Marshal(r.Config())
started := time.Date(2026, 10, 16, 8, 15, 30, 0, time.UTC)
withMeta := plain
withMeta.Meta = &WorkloadMeta{
ComposeProject: "kunde-a", ComposeService: "wordpress", ComposeDir: "/srv/kunde-a",
State: "running", StartedAt: started, Health: "healthy",
}
state := NodeState{
Workloads: []Workload{withMeta},
Host: &HostInfo{
FQDN: "webdocker11.lan.r25dc.de", OS: "Debian GNU/Linux 13 (trixie)",
DockerVersion: "29.8.2", NetBirdIP: "100.64.0.42",
},
}
if err := r.Update(ctx, "n1", state); err != nil {
t.Fatal(err)
}
if after, _ := json.Marshal(r.Config()); string(after) != string(before) {
t.Errorf("Zusatzdaten ändern das Routing:\nvorher %s\nnachher %s", before, after)
}
got, ok := r.State("n1")
if !ok || !reflect.DeepEqual(got, state) {
t.Errorf("State(n1) = %+v, %v; erwartet %+v", got, ok, state)
}
if _, ok := r.State("gibts-nicht"); ok {
t.Error("State für unbekannten Knoten")
}
// Kopie: Änderungen am Ergebnis wirken nicht auf die Registry zurück.
got.Workloads[0].Meta.ComposeDir = "/manipuliert"
got.Host.FQDN = "manipuliert"
again, _ := r.State("n1")
if again.Workloads[0].Meta.ComposeDir != "/srv/kunde-a" || again.Host.FQDN != "webdocker11.lan.r25dc.de" {
t.Errorf("State liefert keine Kopie: %+v", again)
}
}
// Ältere Agents schicken keine Zusatzdaten; JSON ohne die Felder bleibt gültig
// und erzeugt keine leeren Objekte.
func TestNodeState_JSONWithoutMetadata(t *testing.T) {
var st NodeState
if err := json.Unmarshal([]byte(`{"workloads":[{"name":"wp","host":"10.0.0.1","labels":{}}]}`), &st); err != nil {
t.Fatal(err)
}
if st.Host != nil || st.Workloads[0].Meta != nil {
t.Errorf("Zusatzdaten erfunden: %+v", st)
}
out, _ := json.Marshal(st)
if want := `{"workloads":[{"name":"wp","host":"10.0.0.1","labels":{}}]}`; string(out) != want {
t.Errorf("JSON = %s, erwartet %s", out, want)
}
}