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") } }