server läuft
This commit is contained in:
1 parent
c8df9d7651
commit
42f0c0accb
21 files changed
+4185
-10
No files matched your search
@@ -0,0 +1,311 @@
|
||||
// Package registry ist der Dynamic Config Aggregator des Servers (Spec
|
||||
// 5.2): Er hält den zuletzt gemeldeten Zustand (service_state) jedes
|
||||
// Knotens, übersetzt ihn über traefik.FromLabels, wendet die Policy an,
|
||||
// führt alle Knoten zusammen und liefert das Ergebnis an Traefik aus
|
||||
// (GET /v1/traefik-config).
|
||||
//
|
||||
// Neu berechnet wird bei jedem Update/Remove und bei Refresh (z. B.
|
||||
// periodisch oder nach Änderungen an Mandant/Lizenz, und weil Stufen wie
|
||||
// Ablauf oder Mahnstufe zeitabhängig sind).
|
||||
package registry
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"sync"
|
||||
|
||||
"sultan/pkg/license"
|
||||
"sultan/pkg/policy"
|
||||
"sultan/pkg/traefik"
|
||||
)
|
||||
|
||||
// ErrUnknownNode meldet das Directory für Knoten, die es nicht (mehr) gibt.
|
||||
var ErrUnknownNode = errors.New("registry: unbekannter Knoten")
|
||||
|
||||
// Zusätzliche Verstoßarten, die vor der Policy entstehen.
|
||||
const (
|
||||
// ViolationLabels: Die Labels eines Workloads ließen sich nicht übersetzen.
|
||||
ViolationLabels policy.ViolationKind = "labels"
|
||||
// ViolationNameConflict: Zwei Workloads eines Knotens nutzen denselben
|
||||
// Namen (Workload oder Router/Service/Middleware).
|
||||
ViolationNameConflict policy.ViolationKind = "name_conflict"
|
||||
)
|
||||
|
||||
// Workload ist ein Container bzw. ein Dienst aus /etc/sultan.d mit seinen
|
||||
// Traefik-Labels. Host ist die vom Agenten ermittelte Backend-Adresse
|
||||
// (LAN- oder NetBird-IP).
|
||||
type Workload struct {
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
Labels map[string]string `json:"labels"`
|
||||
}
|
||||
|
||||
// NodeState ist der vollständige Zustand eines Knotens (Payload von
|
||||
// service_state). Er ersetzt den vorherigen komplett.
|
||||
type NodeState struct {
|
||||
Workloads []Workload `json:"workloads"`
|
||||
// DownServices: Services (Originalnamen), deren Healthcheck fehlschlägt.
|
||||
DownServices []string `json:"down_services,omitempty"`
|
||||
}
|
||||
|
||||
// NodeContext ist, was die Policy über einen Knoten wissen muss.
|
||||
type NodeContext struct {
|
||||
Tenant policy.Tenant
|
||||
License license.License
|
||||
Drained bool
|
||||
}
|
||||
|
||||
// Directory liefert den NodeContext; für gelöschte/unbekannte Knoten
|
||||
// ErrUnknownNode (ggf. verpackt).
|
||||
type Directory interface {
|
||||
NodeContext(ctx context.Context, nodeID string) (NodeContext, error)
|
||||
}
|
||||
|
||||
// Violation ist ein Verstoß mit Herkunft, für das Audit-Log.
|
||||
type Violation struct {
|
||||
NodeID string
|
||||
Workload string // leer bei Verstößen aus der Policy
|
||||
Kind policy.ViolationKind
|
||||
Router string
|
||||
Detail string
|
||||
Observed bool
|
||||
}
|
||||
|
||||
// Registry hält den Zustand aller Knoten. Sie ist für parallele Nutzung
|
||||
// geeignet; die Felder dürfen nach der ersten Verwendung nicht mehr
|
||||
// geändert werden.
|
||||
type Registry struct {
|
||||
Directory Directory
|
||||
Policy *policy.Engine
|
||||
// OnViolation wird für jeden neu aufgetretenen Verstoß einmal aufgerufen
|
||||
// (nicht bei jeder Neuberechnung erneut).
|
||||
OnViolation func(Violation)
|
||||
|
||||
mu sync.Mutex
|
||||
nodes map[string]*nodeEntry
|
||||
config *traefik.Configuration
|
||||
json []byte
|
||||
yaml []byte
|
||||
}
|
||||
|
||||
type nodeEntry struct {
|
||||
state NodeState
|
||||
config *traefik.Configuration
|
||||
violations map[Violation]bool
|
||||
}
|
||||
|
||||
// Update übernimmt den gemeldeten Zustand eines Knotens. Schlägt die
|
||||
// Auswertung fehl (Directory, Policy), bleibt der vorherige Stand aktiv.
|
||||
func (r *Registry) Update(ctx context.Context, nodeID string, state NodeState) error {
|
||||
if nodeID == "" {
|
||||
return fmt.Errorf("registry: Knoten-ID fehlt")
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
entry, err := r.evaluate(ctx, nodeID, state)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.store(nodeID, entry)
|
||||
return r.rebuild()
|
||||
}
|
||||
|
||||
// Remove entfernt einen Knoten (z. B. Stream getrennt, Knoten gelöscht).
|
||||
func (r *Registry) Remove(nodeID string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if _, ok := r.nodes[nodeID]; !ok {
|
||||
return
|
||||
}
|
||||
delete(r.nodes, nodeID)
|
||||
r.rebuild()
|
||||
}
|
||||
|
||||
// Refresh wertet alle Knoten neu aus. Unbekannte Knoten werden entfernt;
|
||||
// bei anderen Fehlern bleibt der letzte gültige Stand des Knotens erhalten
|
||||
// (fail-static) und der Fehler wird zurückgegeben.
|
||||
func (r *Registry) Refresh(ctx context.Context) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
var errs []error
|
||||
for _, id := range r.nodeIDs() {
|
||||
entry, err := r.evaluate(ctx, id, r.nodes[id].state)
|
||||
switch {
|
||||
case errors.Is(err, ErrUnknownNode):
|
||||
delete(r.nodes, id)
|
||||
case err != nil:
|
||||
errs = append(errs, fmt.Errorf("Knoten %s: %w", id, err))
|
||||
default:
|
||||
r.store(id, entry)
|
||||
}
|
||||
}
|
||||
if err := r.rebuild(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// Config liefert die aktuell ausgelieferte Konfiguration (nie nil).
|
||||
func (r *Registry) Config() *traefik.Configuration {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.config == nil {
|
||||
return &traefik.Configuration{}
|
||||
}
|
||||
return r.config
|
||||
}
|
||||
|
||||
// Nodes liefert die Knoten mit gespeichertem Zustand, sortiert.
|
||||
func (r *Registry) Nodes() []string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.nodeIDs()
|
||||
}
|
||||
|
||||
// Handler liefert die Konfiguration für Traefiks HTTP-Provider aus
|
||||
// (?format=json|yaml, Default JSON).
|
||||
func (r *Registry) Handler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodGet && req.Method != http.MethodHead {
|
||||
w.Header().Set("Allow", "GET, HEAD")
|
||||
http.Error(w, "nur GET", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
format, err := traefik.ParseFormat(req.URL.Query().Get("format"))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
body := r.json
|
||||
if format == traefik.FormatYAML {
|
||||
body = r.yaml
|
||||
}
|
||||
r.mu.Unlock()
|
||||
if body == nil { // noch nie berechnet
|
||||
body, _ = (&traefik.Configuration{}).Marshal(format)
|
||||
}
|
||||
w.Header().Set("Content-Type", format.ContentType())
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Write(body)
|
||||
})
|
||||
}
|
||||
|
||||
// evaluate berechnet das Ergebnis eines Knotens, ohne den Zustand zu ändern.
|
||||
func (r *Registry) evaluate(ctx context.Context, nodeID string, state NodeState) (*nodeEntry, error) {
|
||||
nc, err := r.Directory.NodeContext(ctx, nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entry := &nodeEntry{state: state, violations: map[Violation]bool{}}
|
||||
add := func(v Violation) { v.NodeID = nodeID; entry.violations[v] = true }
|
||||
|
||||
workloads := slices.Clone(state.Workloads)
|
||||
slices.SortStableFunc(workloads, func(a, b Workload) int { return cmp.Compare(a.Name, b.Name) })
|
||||
merged := &traefik.Configuration{}
|
||||
seen := map[string]bool{}
|
||||
for _, w := range workloads {
|
||||
if seen[w.Name] {
|
||||
add(Violation{Workload: w.Name, Kind: ViolationNameConflict, Detail: "Workload-Name doppelt"})
|
||||
continue
|
||||
}
|
||||
seen[w.Name] = true
|
||||
c, err := traefik.FromLabels(w.Host, w.Labels)
|
||||
if err != nil {
|
||||
add(Violation{Workload: w.Name, Kind: ViolationLabels, Detail: err.Error()})
|
||||
continue
|
||||
}
|
||||
m, err := traefik.Merge(merged, c)
|
||||
if err != nil {
|
||||
add(Violation{Workload: w.Name, Kind: ViolationNameConflict, Detail: err.Error()})
|
||||
continue
|
||||
}
|
||||
merged = m
|
||||
}
|
||||
|
||||
down := map[string]bool{}
|
||||
for _, s := range state.DownServices {
|
||||
down[s] = true
|
||||
}
|
||||
res, err := r.Policy.Apply(policy.Input{
|
||||
Tenant: nc.Tenant,
|
||||
License: nc.License,
|
||||
NodeID: nodeID,
|
||||
NodeDrained: nc.Drained,
|
||||
Config: merged,
|
||||
DownServices: down,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, v := range res.Violations {
|
||||
add(Violation{Kind: v.Kind, Router: v.Router, Detail: v.Detail, Observed: v.Observed})
|
||||
}
|
||||
entry.config = res.Config
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// store übernimmt entry und meldet nur Verstöße, die vorher nicht bestanden.
|
||||
func (r *Registry) store(nodeID string, entry *nodeEntry) {
|
||||
if r.nodes == nil {
|
||||
r.nodes = map[string]*nodeEntry{}
|
||||
}
|
||||
var previous map[Violation]bool
|
||||
if old := r.nodes[nodeID]; old != nil {
|
||||
previous = old.violations
|
||||
}
|
||||
r.nodes[nodeID] = entry
|
||||
if r.OnViolation == nil {
|
||||
return
|
||||
}
|
||||
var fresh []Violation
|
||||
for v := range entry.violations {
|
||||
if !previous[v] {
|
||||
fresh = append(fresh, v)
|
||||
}
|
||||
}
|
||||
slices.SortFunc(fresh, func(a, b Violation) int {
|
||||
return cmp.Or(cmp.Compare(a.Workload, b.Workload), cmp.Compare(a.Router, b.Router), cmp.Compare(a.Kind, b.Kind))
|
||||
})
|
||||
for _, v := range fresh {
|
||||
r.OnViolation(v)
|
||||
}
|
||||
}
|
||||
|
||||
// rebuild führt alle Knoten zusammen und rendert die Ausgabe vor.
|
||||
func (r *Registry) rebuild() error {
|
||||
ids := r.nodeIDs()
|
||||
configs := make([]*traefik.Configuration, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
configs = append(configs, r.nodes[id].config)
|
||||
}
|
||||
merged, err := traefik.Merge(configs...)
|
||||
if err != nil {
|
||||
// Durch den Namensraum der Policy nicht zu erwarten; die bisherige
|
||||
// Ausgabe bleibt dann stehen.
|
||||
return fmt.Errorf("registry: Zusammenführen: %w", err)
|
||||
}
|
||||
j, err := merged.Marshal(traefik.FormatJSON)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
y, err := merged.Marshal(traefik.FormatYAML)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.config, r.json, r.yaml = merged, j, y
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Registry) nodeIDs() []string {
|
||||
ids := make([]string, 0, len(r.nodes))
|
||||
for id := range r.nodes {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
slices.Sort(ids)
|
||||
return ids
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user