erste module drin.

zwischenstand: Architektur aktualisiert, TOFU implementiert,
This commit is contained in:
br committed 2026-10-01 22:22:13 +02:00
1 parent 9d5789c5d7
commit c8df9d7651
41 files changed
+6455 -36

No files matched your search

+125
View File
@@ -0,0 +1,125 @@
// Package traefik erzeugt die dynamische Traefik-Konfiguration, die der
// Sultan-Server über den HTTP-Provider (GET /v1/traefik-config) ausliefert.
//
// Die Typen bilden die Teilmenge von Traefiks "dynamic configuration" ab,
// die Sultan aus Docker-Labels bzw. /etc/sultan.d-Dateien erzeugt. Die
// Ausgabe ist JSON oder YAML; Traefiks HTTP-Provider akzeptiert beides.
package traefik
import (
"bytes"
"encoding/json"
"fmt"
"strings"
"gopkg.in/yaml.v3"
)
// Configuration ist die Wurzel der dynamischen Traefik-Konfiguration.
type Configuration struct {
HTTP *HTTPConfiguration `json:"http,omitempty" yaml:"http,omitempty"`
}
// HTTPConfiguration enthält Router, Services und Middlewares der HTTP-Ebene.
type HTTPConfiguration struct {
Routers map[string]*Router `json:"routers,omitempty" yaml:"routers,omitempty"`
Services map[string]*Service `json:"services,omitempty" yaml:"services,omitempty"`
Middlewares map[string]Middleware `json:"middlewares,omitempty" yaml:"middlewares,omitempty"`
}
// Router entspricht einem Traefik-HTTP-Router.
type Router struct {
EntryPoints []string `json:"entryPoints,omitempty" yaml:"entryPoints,omitempty"`
Middlewares []string `json:"middlewares,omitempty" yaml:"middlewares,omitempty"`
Service string `json:"service" yaml:"service"`
Rule string `json:"rule" yaml:"rule"`
Priority int `json:"priority,omitempty" yaml:"priority,omitempty"`
TLS *RouterTLS `json:"tls,omitempty" yaml:"tls,omitempty"`
}
// RouterTLS aktiviert TLS für einen Router. Ein leeres, nicht-nil RouterTLS
// wird als "tls: {}" gerendert und schaltet TLS mit Standardwerten ein.
type RouterTLS struct {
Options string `json:"options,omitempty" yaml:"options,omitempty"`
CertResolver string `json:"certResolver,omitempty" yaml:"certResolver,omitempty"`
}
// Service entspricht einem Traefik-HTTP-Service.
type Service struct {
LoadBalancer *ServersLoadBalancer `json:"loadBalancer,omitempty" yaml:"loadBalancer,omitempty"`
}
// ServersLoadBalancer verteilt Anfragen auf die angegebenen Server.
type ServersLoadBalancer struct {
Servers []Server `json:"servers" yaml:"servers"`
PassHostHeader *bool `json:"passHostHeader,omitempty" yaml:"passHostHeader,omitempty"`
}
// Server ist ein einzelnes Backend-Ziel.
type Server struct {
URL string `json:"url" yaml:"url"`
}
// Middleware ist eine Traefik-Middleware als verschachtelte Map, z. B.
// {"redirectRegex": {"regex": "...", "permanent": "true"}}.
//
// Die Blattwerte bleiben die unveränderten Label-Strings. Traefik wandelt sie
// beim Einlesen genauso in Bool-, Zahl- und Listenwerte um wie Labels
// (Listen kommagetrennt), deshalb braucht Sultan nicht jeden Middleware-Typ zu
// kennen.
type Middleware map[string]any
// Format ist das Ausgabeformat der Konfiguration.
type Format string
const (
FormatJSON Format = "json"
FormatYAML Format = "yaml"
)
// ParseFormat wandelt z. B. einen Query-Parameter in ein Format um.
// Ein leerer String ergibt FormatJSON.
func ParseFormat(s string) (Format, error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "", "json":
return FormatJSON, nil
case "yaml", "yml":
return FormatYAML, nil
default:
return "", fmt.Errorf("traefik: unbekanntes Format %q", s)
}
}
// ContentType liefert den passenden HTTP-Content-Type.
func (f Format) ContentType() string {
if f == FormatYAML {
return "application/yaml"
}
return "application/json"
}
// Marshal rendert die Konfiguration im angegebenen Format. Eine nil-
// Konfiguration ergibt ein leeres Objekt, das Traefik als "keine Routen"
// akzeptiert.
func (c *Configuration) Marshal(f Format) ([]byte, error) {
if c == nil {
c = &Configuration{}
}
switch f {
case FormatJSON:
return json.Marshal(c)
case FormatYAML:
var buf bytes.Buffer
enc := yaml.NewEncoder(&buf)
enc.SetIndent(2)
if err := enc.Encode(c); err != nil {
return nil, err
}
if err := enc.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
default:
return nil, fmt.Errorf("traefik: unbekanntes Format %q", f)
}
}
+146
View File
@@ -0,0 +1,146 @@
package traefik
import (
"encoding/json"
"testing"
"gopkg.in/yaml.v3"
)
func mustJSON(t *testing.T, v any) string {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
return string(b)
}
func sampleConfig() *Configuration {
return &Configuration{HTTP: &HTTPConfiguration{
Routers: map[string]*Router{
"r": {EntryPoints: []string{"web"}, Service: "s", Rule: "Host(`a.de`)", TLS: &RouterTLS{}},
},
Services: map[string]*Service{
"s": {LoadBalancer: &ServersLoadBalancer{Servers: []Server{{URL: "http://10.0.0.1:80"}}}},
},
}}
}
func TestMarshal_JSON(t *testing.T) {
got, err := sampleConfig().Marshal(FormatJSON)
if err != nil {
t.Fatalf("unerwarteter Fehler: %v", err)
}
want := `{"http":{"routers":{"r":{"entryPoints":["web"],"service":"s","rule":"Host(` + "`a.de`" + `)","tls":{}}},` +
`"services":{"s":{"loadBalancer":{"servers":[{"url":"http://10.0.0.1:80"}]}}}}}`
if string(got) != want {
t.Errorf("JSON weicht ab\n got: %s\nwant: %s", got, want)
}
}
func TestMarshal_YAML(t *testing.T) {
got, err := sampleConfig().Marshal(FormatYAML)
if err != nil {
t.Fatalf("unerwarteter Fehler: %v", err)
}
want := "http:\n" +
" routers:\n" +
" r:\n" +
" entryPoints:\n" +
" - web\n" +
" service: s\n" +
" rule: Host(`a.de`)\n" +
" tls: {}\n" +
" services:\n" +
" s:\n" +
" loadBalancer:\n" +
" servers:\n" +
" - url: http://10.0.0.1:80\n"
if string(got) != want {
t.Errorf("YAML weicht ab\n got:\n%s\nwant:\n%s", got, want)
}
}
func TestMarshal_Empty(t *testing.T) {
for _, c := range []*Configuration{nil, {}} {
j, err := c.Marshal(FormatJSON)
if err != nil || string(j) != "{}" {
t.Errorf("JSON = %q, %v; erwartet {}", j, err)
}
y, err := c.Marshal(FormatYAML)
if err != nil || string(y) != "{}\n" {
t.Errorf("YAML = %q, %v; erwartet {}\\n", y, err)
}
}
}
func TestMarshal_UnknownFormat(t *testing.T) {
if _, err := sampleConfig().Marshal("toml"); err == nil {
t.Error("erwartet Fehler für unbekanntes Format")
}
}
// Die Ausgabe muss sich verlustfrei zurücklesen lassen – inklusive der
// verschachtelten Middleware-Maps und der optionalen Felder.
func TestMarshal_RoundTrip(t *testing.T) {
orig, err := FromLabels("100.64.0.42", wordpressLabels())
if err != nil {
t.Fatalf("FromLabels: %v", err)
}
orig.HTTP.Services["wp"].LoadBalancer.PassHostHeader = boolPtr(false)
orig.HTTP.Routers["wp"].Priority = 7
decoders := map[Format]func([]byte, any) error{
FormatJSON: json.Unmarshal,
FormatYAML: yaml.Unmarshal,
}
for f, decode := range decoders {
t.Run(string(f), func(t *testing.T) {
b, err := orig.Marshal(f)
if err != nil {
t.Fatalf("Marshal: %v", err)
}
var back Configuration
if err := decode(b, &back); err != nil {
t.Fatalf("Unmarshal: %v\n%s", err, b)
}
// Vergleich über JSON statt reflect.DeepEqual: yaml.v3 dekodiert
// verschachtelte Maps in den Typ der äußeren Map (Middleware statt
// map[string]any), inhaltlich ist das identisch.
if got, want := mustJSON(t, &back), mustJSON(t, orig); got != want {
t.Errorf("Round-Trip weicht ab\n got: %s\nwant: %s", got, want)
}
})
}
}
func TestParseFormat(t *testing.T) {
tests := []struct {
in string
want Format
wantErr bool
}{
{"", FormatJSON, false},
{"json", FormatJSON, false},
{" JSON ", FormatJSON, false},
{"yaml", FormatYAML, false},
{"YML", FormatYAML, false},
{"toml", "", true},
}
for _, tt := range tests {
got, err := ParseFormat(tt.in)
if (err != nil) != tt.wantErr || got != tt.want {
t.Errorf("ParseFormat(%q) = %q, %v; erwartet %q, Fehler=%v", tt.in, got, err, tt.want, tt.wantErr)
}
}
}
func TestFormat_ContentType(t *testing.T) {
if got := FormatJSON.ContentType(); got != "application/json" {
t.Errorf("JSON: %q", got)
}
if got := FormatYAML.ContentType(); got != "application/yaml" {
t.Errorf("YAML: %q", got)
}
}
+303
View File
@@ -0,0 +1,303 @@
package traefik
import (
"errors"
"fmt"
"net"
"sort"
"strconv"
"strings"
)
var (
// ErrUnsupportedLabel meldet ein Traefik-Label, das Sultan (noch) nicht übersetzt.
ErrUnsupportedLabel = errors.New("nicht unterstütztes Label")
// ErrInvalidLabel meldet ein Label mit ungültigem Wert.
ErrInvalidLabel = errors.New("ungültiges Label")
// ErrIncomplete meldet eine Konfiguration, der Pflichtangaben fehlen.
ErrIncomplete = errors.New("unvollständige Konfiguration")
)
const labelPrefix = "traefik."
// FromLabels übersetzt die flachen Traefik-Labels eines Containers (bzw.
// einer /etc/sultan.d-Datei) in eine verschachtelte Konfiguration.
//
// host ist die Adresse, unter der der Ingress das Backend erreicht (LAN- oder
// NetBird-IP); sie wird zusammen mit loadbalancer.server.port in die
// Server-URLs eingesetzt.
//
// Wie bei Traefik mit exposedByDefault=false wird nur übersetzt, wenn
// traefik.enable=true gesetzt ist; sonst ist das Ergebnis leer. Label-Schlüssel
// werden ohne Beachtung der Groß-/Kleinschreibung erkannt, Router-, Service-
// und Middleware-Namen behalten ihre Schreibweise. traefik.docker.* wird
// ignoriert. Alle gefundenen Fehler werden gesammelt per errors.Join
// zurückgegeben.
func FromLabels(host string, labels map[string]string) (*Configuration, error) {
enabled, err := isEnabled(labels)
if err != nil {
return nil, err
}
if !enabled {
return &Configuration{}, nil
}
b := builder{
routers: map[string]*Router{},
services: map[string]*serviceSpec{},
middlewares: map[string]Middleware{},
}
keys := make([]string, 0, len(labels))
for k := range labels {
keys = append(keys, k)
}
sort.Strings(keys)
for _, key := range keys {
if !strings.HasPrefix(strings.ToLower(key), labelPrefix) {
continue
}
if err := b.apply(key, labels[key]); err != nil {
b.errs = append(b.errs, fmt.Errorf("%s: %w", key, err))
}
}
cfg := b.build(host)
if len(b.errs) > 0 {
return nil, errors.Join(b.errs...)
}
return cfg, nil
}
func isEnabled(labels map[string]string) (bool, error) {
for k, v := range labels {
if strings.EqualFold(k, labelPrefix+"enable") {
enabled, err := strconv.ParseBool(strings.TrimSpace(v))
if err != nil {
return false, fmt.Errorf("%s: %w: %q ist kein Bool", k, ErrInvalidLabel, v)
}
return enabled, nil
}
}
return false, nil
}
type serviceSpec struct {
port string
scheme string
passHostHeader *bool
}
type builder struct {
routers map[string]*Router
services map[string]*serviceSpec
middlewares map[string]Middleware
errs []error
}
func (b *builder) apply(key, value string) error {
parts := strings.Split(key[len(labelPrefix):], ".")
switch strings.ToLower(parts[0]) {
case "enable", "docker":
return nil
case "http":
default:
return ErrUnsupportedLabel
}
if len(parts) < 4 {
return ErrUnsupportedLabel
}
kind, name, attr := strings.ToLower(parts[1]), parts[2], parts[3:]
if name == "" {
return fmt.Errorf("%w: leerer Name", ErrInvalidLabel)
}
switch kind {
case "routers":
r, ok := b.routers[name]
if !ok {
r = &Router{}
b.routers[name] = r
}
return setRouterAttr(r, strings.ToLower(strings.Join(attr, ".")), value)
case "services":
s, ok := b.services[name]
if !ok {
s = &serviceSpec{}
b.services[name] = s
}
return setServiceAttr(s, strings.ToLower(strings.Join(attr, ".")), value)
case "middlewares":
m, ok := b.middlewares[name]
if !ok {
m = Middleware{}
b.middlewares[name] = m
}
return setMiddlewareAttr(m, attr, value)
default:
return ErrUnsupportedLabel
}
}
func setRouterAttr(r *Router, attr, value string) error {
switch attr {
case "rule":
r.Rule = strings.TrimSpace(value)
case "entrypoints":
r.EntryPoints = splitList(value)
case "middlewares":
r.Middlewares = splitList(value)
case "service":
r.Service = strings.TrimSpace(value)
case "priority":
p, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil {
return fmt.Errorf("%w: %q ist keine Zahl", ErrInvalidLabel, value)
}
r.Priority = p
case "tls":
on, err := strconv.ParseBool(strings.TrimSpace(value))
if err != nil {
return fmt.Errorf("%w: %q ist kein Bool", ErrInvalidLabel, value)
}
if on && r.TLS == nil {
r.TLS = &RouterTLS{}
}
case "tls.certresolver":
ensureTLS(r).CertResolver = strings.TrimSpace(value)
case "tls.options":
ensureTLS(r).Options = strings.TrimSpace(value)
default:
return ErrUnsupportedLabel
}
return nil
}
func ensureTLS(r *Router) *RouterTLS {
if r.TLS == nil {
r.TLS = &RouterTLS{}
}
return r.TLS
}
func setServiceAttr(s *serviceSpec, attr, value string) error {
value = strings.TrimSpace(value)
switch attr {
case "loadbalancer.server.port":
p, err := strconv.Atoi(value)
if err != nil || p < 1 || p > 65535 {
return fmt.Errorf("%w: %q ist kein gültiger Port", ErrInvalidLabel, value)
}
s.port = strconv.Itoa(p)
case "loadbalancer.server.scheme":
scheme := strings.ToLower(value)
switch scheme {
case "http", "https", "h2c":
s.scheme = scheme
default:
return fmt.Errorf("%w: Schema %q nicht erlaubt (http, https, h2c)", ErrInvalidLabel, value)
}
case "loadbalancer.passhostheader":
v, err := strconv.ParseBool(value)
if err != nil {
return fmt.Errorf("%w: %q ist kein Bool", ErrInvalidLabel, value)
}
s.passHostHeader = &v
default:
return ErrUnsupportedLabel
}
return nil
}
// setMiddlewareAttr legt value unter dem Pfad attr in m ab. attr[0] ist der
// Middleware-Typ (z. B. redirectregex), der Rest die Optionen.
func setMiddlewareAttr(m Middleware, attr []string, value string) error {
node := map[string]any(m)
for i, p := range attr {
if p == "" {
return fmt.Errorf("%w: leeres Pfadsegment", ErrInvalidLabel)
}
if i == len(attr)-1 {
if _, isMap := node[p].(map[string]any); isMap {
return fmt.Errorf("%w: %q ist bereits ein Objekt", ErrInvalidLabel, p)
}
node[p] = value
return nil
}
switch child := node[p].(type) {
case nil:
next := map[string]any{}
node[p] = next
node = next
case map[string]any:
node = child
default:
return fmt.Errorf("%w: %q ist bereits ein Wert", ErrInvalidLabel, p)
}
}
return nil
}
func (b *builder) build(host string) *Configuration {
if len(b.routers) == 0 && len(b.services) == 0 && len(b.middlewares) == 0 {
return &Configuration{}
}
http := &HTTPConfiguration{}
if len(b.services) > 0 {
if strings.TrimSpace(host) == "" {
b.errs = append(b.errs, fmt.Errorf("%w: keine Backend-Adresse (host) angegeben", ErrIncomplete))
}
http.Services = map[string]*Service{}
for name, s := range b.services {
if s.port == "" {
b.errs = append(b.errs, fmt.Errorf("service %q: %w: loadbalancer.server.port fehlt", name, ErrIncomplete))
continue
}
scheme := s.scheme
if scheme == "" {
scheme = "http"
}
http.Services[name] = &Service{LoadBalancer: &ServersLoadBalancer{
Servers: []Server{{URL: scheme + "://" + net.JoinHostPort(host, s.port)}},
PassHostHeader: s.passHostHeader,
}}
}
}
if len(b.routers) > 0 {
http.Routers = b.routers
for name, r := range b.routers {
if r.Rule == "" {
b.errs = append(b.errs, fmt.Errorf("router %q: %w: rule fehlt", name, ErrIncomplete))
}
if r.Service != "" {
continue
}
// Wie Traefiks Docker-Provider: ohne explizite Angabe wird der
// Router an den einzigen Service des Containers gebunden.
if len(b.services) != 1 {
b.errs = append(b.errs, fmt.Errorf("router %q: %w: service fehlt und ist bei %d Services nicht eindeutig", name, ErrIncomplete, len(b.services)))
continue
}
for svc := range b.services {
r.Service = svc
}
}
}
if len(b.middlewares) > 0 {
http.Middlewares = b.middlewares
}
return &Configuration{HTTP: http}
}
func splitList(value string) []string {
var out []string
for _, v := range strings.Split(value, ",") {
if v = strings.TrimSpace(v); v != "" {
out = append(out, v)
}
}
return out
}
+360
View File
@@ -0,0 +1,360 @@
package traefik
import (
"errors"
"reflect"
"testing"
)
func boolPtr(b bool) *bool { return &b }
// Labels aus dem Compose-Beispiel der Architektur-Spec (Abschnitt 7.2).
func wordpressLabels() map[string]string {
return map[string]string{
"traefik.enable": "true",
"traefik.http.routers.wp.rule": "Host(`kunde.de`) || Host(`www.kunde.de`)",
"traefik.http.routers.wp.entrypoints": "websecure",
"traefik.http.routers.wp.tls": "true",
"traefik.http.routers.wp.tls.certresolver": "dns-resolver",
"traefik.http.middlewares.wp-www.redirectregex.regex": `^https?://kunde\.de/(.*)`,
"traefik.http.middlewares.wp-www.redirectregex.replacement": "https://www.kunde.de/${1}",
"traefik.http.middlewares.wp-www.redirectregex.permanent": "true",
"traefik.http.routers.wp.middlewares": "wp-www",
"traefik.http.services.wp.loadbalancer.server.port": "80",
"com.docker.compose.project": "kunde",
}
}
func TestFromLabels_SpecExample(t *testing.T) {
got, err := FromLabels("100.64.0.42", wordpressLabels())
if err != nil {
t.Fatalf("unerwarteter Fehler: %v", err)
}
want := &Configuration{HTTP: &HTTPConfiguration{
Routers: map[string]*Router{
"wp": {
EntryPoints: []string{"websecure"},
Middlewares: []string{"wp-www"},
Service: "wp",
Rule: "Host(`kunde.de`) || Host(`www.kunde.de`)",
TLS: &RouterTLS{CertResolver: "dns-resolver"},
},
},
Services: map[string]*Service{
"wp": {LoadBalancer: &ServersLoadBalancer{Servers: []Server{{URL: "http://100.64.0.42:80"}}}},
},
Middlewares: map[string]Middleware{
"wp-www": {"redirectregex": map[string]any{
"regex": `^https?://kunde\.de/(.*)`,
"replacement": "https://www.kunde.de/${1}",
"permanent": "true",
}},
},
}}
if !reflect.DeepEqual(got, want) {
t.Errorf("Konfiguration weicht ab\n got: %s\nwant: %s", mustJSON(t, got), mustJSON(t, want))
}
}
func TestFromLabels_Enable(t *testing.T) {
tests := []struct {
name string
labels map[string]string
wantErr bool
}{
{"fehlt", map[string]string{"traefik.http.routers.a.rule": "Host(`a.de`)"}, false},
{"false", map[string]string{"traefik.enable": "false", "traefik.http.routers.a.rule": "x"}, false},
{"leere Labels", nil, false},
{"ungültig", map[string]string{"traefik.enable": "jein"}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := FromLabels("10.0.0.1", tt.labels)
if tt.wantErr {
if !errors.Is(err, ErrInvalidLabel) {
t.Fatalf("erwartet ErrInvalidLabel, bekommen %v", err)
}
return
}
if err != nil {
t.Fatalf("unerwarteter Fehler: %v", err)
}
if got == nil || got.HTTP != nil {
t.Errorf("erwartet leere Konfiguration, bekommen %+v", got)
}
})
}
}
func TestFromLabels_EnabledWithoutHTTPLabels(t *testing.T) {
got, err := FromLabels("10.0.0.1", map[string]string{
"traefik.enable": "true",
"traefik.docker.network": "proxy",
})
if err != nil {
t.Fatalf("unerwarteter Fehler: %v", err)
}
if got.HTTP != nil {
t.Errorf("erwartet leere Konfiguration, bekommen %+v", got.HTTP)
}
}
func TestFromLabels_CaseInsensitiveKeys(t *testing.T) {
got, err := FromLabels("10.0.0.1", map[string]string{
"Traefik.Enable": "TRUE",
"TRAEFIK.HTTP.Routers.MyApp.Rule": "Host(`a.de`)",
"traefik.http.SERVICES.MyApp.LoadBalancer.Server.Port": "8080",
})
if err != nil {
t.Fatalf("unerwarteter Fehler: %v", err)
}
r, ok := got.HTTP.Routers["MyApp"]
if !ok {
t.Fatalf("Router MyApp fehlt (Name muss Schreibweise behalten): %+v", got.HTTP.Routers)
}
if r.Service != "MyApp" {
t.Errorf("Service = %q, erwartet MyApp", r.Service)
}
if u := got.HTTP.Services["MyApp"].LoadBalancer.Servers[0].URL; u != "http://10.0.0.1:8080" {
t.Errorf("URL = %q", u)
}
}
func TestFromLabels_RouterAttributes(t *testing.T) {
got, err := FromLabels("10.0.0.1", map[string]string{
"traefik.enable": "true",
"traefik.http.routers.r.rule": " Host(`a.de`) ",
"traefik.http.routers.r.entrypoints": "web, websecure ,,",
"traefik.http.routers.r.middlewares": "auth@file,compress",
"traefik.http.routers.r.priority": " 42 ",
"traefik.http.routers.r.service": "api@internal",
"traefik.http.routers.r.tls.options": "modern@file",
"traefik.http.routers.plain.rule": "Host(`b.de`)",
"traefik.http.routers.plain.service": "noop@internal",
"traefik.http.routers.plain.tls": "false",
"traefik.http.routers.tlsonly.rule": "Host(`c.de`)",
"traefik.http.routers.tlsonly.service": "noop@internal",
"traefik.http.routers.tlsonly.tls": "true",
})
if err != nil {
t.Fatalf("unerwarteter Fehler: %v", err)
}
want := map[string]*Router{
"r": {
EntryPoints: []string{"web", "websecure"},
Middlewares: []string{"auth@file", "compress"},
Service: "api@internal",
Rule: "Host(`a.de`)",
Priority: 42,
TLS: &RouterTLS{Options: "modern@file"},
},
"plain": {Service: "noop@internal", Rule: "Host(`b.de`)"},
"tlsonly": {Service: "noop@internal", Rule: "Host(`c.de`)", TLS: &RouterTLS{}},
}
if !reflect.DeepEqual(got.HTTP.Routers, want) {
t.Errorf("Router weichen ab\n got: %s\nwant: %s", mustJSON(t, got.HTTP.Routers), mustJSON(t, want))
}
if got.HTTP.Services != nil {
t.Errorf("keine Services erwartet, bekommen %+v", got.HTTP.Services)
}
}
func TestFromLabels_ServiceAttributes(t *testing.T) {
tests := []struct {
name string
host string
labels map[string]string
want ServersLoadBalancer
}{
{
name: "Standardschema http",
host: "192.168.1.10",
labels: map[string]string{"traefik.http.services.s.loadbalancer.server.port": "80"},
want: ServersLoadBalancer{Servers: []Server{{URL: "http://192.168.1.10:80"}}},
},
{
name: "https und passHostHeader",
host: "100.64.1.2",
labels: map[string]string{
"traefik.http.services.s.loadbalancer.server.port": "8443",
"traefik.http.services.s.loadbalancer.server.scheme": "HTTPS",
"traefik.http.services.s.loadbalancer.passhostheader": "false",
},
want: ServersLoadBalancer{
Servers: []Server{{URL: "https://100.64.1.2:8443"}},
PassHostHeader: boolPtr(false),
},
},
{
name: "h2c",
host: "backend.lan",
labels: map[string]string{
"traefik.http.services.s.loadbalancer.server.port": "9000",
"traefik.http.services.s.loadbalancer.server.scheme": "h2c",
},
want: ServersLoadBalancer{Servers: []Server{{URL: "h2c://backend.lan:9000"}}},
},
{
name: "IPv6 in Klammern",
host: "fd00::1",
labels: map[string]string{"traefik.http.services.s.loadbalancer.server.port": "80"},
want: ServersLoadBalancer{Servers: []Server{{URL: "http://[fd00::1]:80"}}},
},
{
name: "Port mit führender Null normalisiert",
host: "10.0.0.1",
labels: map[string]string{"traefik.http.services.s.loadbalancer.server.port": "080"},
want: ServersLoadBalancer{Servers: []Server{{URL: "http://10.0.0.1:80"}}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.labels["traefik.enable"] = "true"
got, err := FromLabels(tt.host, tt.labels)
if err != nil {
t.Fatalf("unerwarteter Fehler: %v", err)
}
if lb := got.HTTP.Services["s"].LoadBalancer; !reflect.DeepEqual(*lb, tt.want) {
t.Errorf("LoadBalancer = %+v, erwartet %+v", *lb, tt.want)
}
})
}
}
func TestFromLabels_DefaultServiceBinding(t *testing.T) {
got, err := FromLabels("10.0.0.1", map[string]string{
"traefik.enable": "true",
"traefik.http.routers.a.rule": "Host(`a.de`)",
"traefik.http.routers.b.rule": "Host(`b.de`)",
"traefik.http.services.app.loadbalancer.server.port": "3000",
})
if err != nil {
t.Fatalf("unerwarteter Fehler: %v", err)
}
for _, name := range []string{"a", "b"} {
if s := got.HTTP.Routers[name].Service; s != "app" {
t.Errorf("Router %s: Service = %q, erwartet app", name, s)
}
}
}
func TestFromLabels_Middlewares(t *testing.T) {
got, err := FromLabels("", map[string]string{
"traefik.enable": "true",
"traefik.http.middlewares.gzip.compress": "true",
"traefik.http.middlewares.hdr.headers.customrequestheaders.X-Forwarded-Proto": "https",
"traefik.http.middlewares.hdr.headers.customrequestheaders.X-Tenant": "kunde",
"traefik.http.middlewares.hdr.headers.stsseconds": "31536000",
"traefik.http.middlewares.allow.ipallowlist.sourcerange": "10.0.0.0/8, 192.168.0.0/16",
})
if err != nil {
t.Fatalf("unerwarteter Fehler: %v", err)
}
want := map[string]Middleware{
"gzip": {"compress": "true"},
"hdr": {"headers": map[string]any{
"customrequestheaders": map[string]any{
"X-Forwarded-Proto": "https",
"X-Tenant": "kunde",
},
"stsseconds": "31536000",
}},
"allow": {"ipallowlist": map[string]any{"sourcerange": "10.0.0.0/8, 192.168.0.0/16"}},
}
if !reflect.DeepEqual(got.HTTP.Middlewares, want) {
t.Errorf("Middlewares weichen ab\n got: %s\nwant: %s", mustJSON(t, got.HTTP.Middlewares), mustJSON(t, want))
}
if got.HTTP.Routers != nil || got.HTTP.Services != nil {
t.Errorf("nur Middlewares erwartet, bekommen %+v", got.HTTP)
}
}
func TestFromLabels_Errors(t *testing.T) {
tests := []struct {
name string
host string
labels map[string]string
want error
}{
{"TCP nicht unterstützt", "h", map[string]string{"traefik.tcp.routers.db.rule": "HostSNI(`*`)"}, ErrUnsupportedLabel},
{"UDP nicht unterstützt", "h", map[string]string{"traefik.udp.services.dns.loadbalancer.server.port": "53"}, ErrUnsupportedLabel},
{"unbekannte HTTP-Art", "h", map[string]string{"traefik.http.serverstransports.x.insecureskipverify": "true"}, ErrUnsupportedLabel},
{"Schlüssel zu kurz", "h", map[string]string{"traefik.http.routers.r": "x"}, ErrUnsupportedLabel},
{"nur Präfix", "h", map[string]string{"traefik.": "x"}, ErrUnsupportedLabel},
{"unbekanntes Router-Attribut", "h", map[string]string{"traefik.http.routers.r.rule": "x", "traefik.http.routers.r.service": "s@file", "traefik.http.routers.r.tls.domains[0].main": "a.de"}, ErrUnsupportedLabel},
{"unbekanntes Service-Attribut", "h", map[string]string{"traefik.http.services.s.loadbalancer.server.port": "80", "traefik.http.services.s.loadbalancer.sticky.cookie": "true"}, ErrUnsupportedLabel},
{"leerer Name", "h", map[string]string{"traefik.http.routers..rule": "x"}, ErrInvalidLabel},
{"Priority keine Zahl", "h", map[string]string{"traefik.http.routers.r.rule": "x", "traefik.http.routers.r.service": "s@file", "traefik.http.routers.r.priority": "hoch"}, ErrInvalidLabel},
{"TLS kein Bool", "h", map[string]string{"traefik.http.routers.r.rule": "x", "traefik.http.routers.r.service": "s@file", "traefik.http.routers.r.tls": "vielleicht"}, ErrInvalidLabel},
{"Port keine Zahl", "h", map[string]string{"traefik.http.services.s.loadbalancer.server.port": "http"}, ErrInvalidLabel},
{"Port 0", "h", map[string]string{"traefik.http.services.s.loadbalancer.server.port": "0"}, ErrInvalidLabel},
{"Port zu groß", "h", map[string]string{"traefik.http.services.s.loadbalancer.server.port": "65536"}, ErrInvalidLabel},
{"ungültiges Schema", "h", map[string]string{"traefik.http.services.s.loadbalancer.server.port": "80", "traefik.http.services.s.loadbalancer.server.scheme": "ftp"}, ErrInvalidLabel},
{"passHostHeader kein Bool", "h", map[string]string{"traefik.http.services.s.loadbalancer.server.port": "80", "traefik.http.services.s.loadbalancer.passhostheader": "ja"}, ErrInvalidLabel},
{"Middleware Wert dann Objekt", "h", map[string]string{"traefik.http.middlewares.m.headers": "x", "traefik.http.middlewares.m.headers.stsseconds": "1"}, ErrInvalidLabel},
{"Middleware leeres Segment", "h", map[string]string{"traefik.http.middlewares.m.headers..x": "1"}, ErrInvalidLabel},
{"Middleware ohne Typ", "h", map[string]string{"traefik.http.middlewares.m": "x"}, ErrUnsupportedLabel},
{"Rule fehlt", "h", map[string]string{"traefik.http.routers.r.service": "s@file"}, ErrIncomplete},
{"Port fehlt", "h", map[string]string{"traefik.http.services.s.loadbalancer.server.scheme": "https"}, ErrIncomplete},
{"Host fehlt", " ", map[string]string{"traefik.http.services.s.loadbalancer.server.port": "80"}, ErrIncomplete},
{"Service fehlt ohne Services", "h", map[string]string{"traefik.http.routers.r.rule": "x"}, ErrIncomplete},
{"Service mehrdeutig", "h", map[string]string{
"traefik.http.routers.r.rule": "x",
"traefik.http.services.a.loadbalancer.server.port": "80",
"traefik.http.services.b.loadbalancer.server.port": "81",
}, ErrIncomplete},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.labels["traefik.enable"] = "true"
got, err := FromLabels(tt.host, tt.labels)
if !errors.Is(err, tt.want) {
t.Fatalf("erwartet %v, bekommen %v", tt.want, err)
}
if got != nil {
t.Errorf("bei Fehler erwartet nil-Konfiguration, bekommen %+v", got)
}
})
}
}
func TestFromLabels_Middlewares_ObjectThenValue(t *testing.T) {
// Sortierte Verarbeitung: "...headers.a" kommt vor "...headers.a.b" – hier
// wird der umgekehrte Fall (erst Objekt, dann Wert am selben Pfad) geprüft.
m := Middleware{}
if err := setMiddlewareAttr(m, []string{"headers", "a", "b"}, "1"); err != nil {
t.Fatalf("unerwarteter Fehler: %v", err)
}
if err := setMiddlewareAttr(m, []string{"headers", "a"}, "2"); !errors.Is(err, ErrInvalidLabel) {
t.Errorf("erwartet ErrInvalidLabel, bekommen %v", err)
}
}
func TestFromLabels_CollectsAllErrors(t *testing.T) {
_, err := FromLabels("h", map[string]string{
"traefik.enable": "true",
"traefik.tcp.routers.db.rule": "HostSNI(`*`)",
"traefik.http.services.s.loadbalancer.server.port": "nope",
"traefik.http.routers.r.service": "s",
})
for _, want := range []error{ErrUnsupportedLabel, ErrInvalidLabel, ErrIncomplete} {
if !errors.Is(err, want) {
t.Errorf("Fehler enthält %v nicht: %v", want, err)
}
}
}
func TestSplitList(t *testing.T) {
tests := map[string][]string{
"": nil,
" , ,": nil,
"a": {"a"},
"a,b": {"a", "b"},
" a , b ,, c ": {"a", "b", "c"},
}
for in, want := range tests {
if got := splitList(in); !reflect.DeepEqual(got, want) {
t.Errorf("splitList(%q) = %#v, erwartet %#v", in, got, want)
}
}
}
+55
View File
@@ -0,0 +1,55 @@
package traefik
import (
"errors"
"fmt"
"reflect"
)
// ErrConflict meldet zwei unterschiedliche Definitionen unter demselben Namen.
var ErrConflict = errors.New("Namenskonflikt")
// Merge führt die Konfigurationen mehrerer Knoten zu einer zusammen.
// nil-Einträge werden übersprungen. Identische Definitionen unter demselben
// Namen sind erlaubt, unterschiedliche ergeben ErrConflict; das Vergeben
// eindeutiger Namen (z. B. pro Mandant/Knoten) ist Aufgabe des Aufrufers.
// Die Eingaben werden nicht verändert, das Ergebnis teilt sich aber die
// Router-, Service- und Middleware-Objekte mit ihnen.
func Merge(configs ...*Configuration) (*Configuration, error) {
out := &HTTPConfiguration{
Routers: map[string]*Router{},
Services: map[string]*Service{},
Middlewares: map[string]Middleware{},
}
var errs []error
for _, c := range configs {
if c == nil || c.HTTP == nil {
continue
}
errs = append(errs, mergeInto(out.Routers, c.HTTP.Routers, "router")...)
errs = append(errs, mergeInto(out.Services, c.HTTP.Services, "service")...)
errs = append(errs, mergeInto(out.Middlewares, c.HTTP.Middlewares, "middleware")...)
}
if len(errs) > 0 {
return nil, errors.Join(errs...)
}
if len(out.Routers) == 0 && len(out.Services) == 0 && len(out.Middlewares) == 0 {
return &Configuration{}, nil
}
return &Configuration{HTTP: out}, nil
}
func mergeInto[V any](dst, src map[string]V, kind string) []error {
var errs []error
for name, v := range src {
existing, ok := dst[name]
if !ok {
dst[name] = v
continue
}
if !reflect.DeepEqual(existing, v) {
errs = append(errs, fmt.Errorf("%s %q: %w", kind, name, ErrConflict))
}
}
return errs
}
+90
View File
@@ -0,0 +1,90 @@
package traefik
import (
"errors"
"reflect"
"testing"
)
func nodeConfig(t *testing.T, host, name, domain string) *Configuration {
t.Helper()
c, err := FromLabels(host, map[string]string{
"traefik.enable": "true",
"traefik.http.routers." + name + ".rule": "Host(`" + domain + "`)",
"traefik.http.services." + name + ".loadbalancer.server.port": "80",
"traefik.http.middlewares." + name + "-gzip.compress": "true",
})
if err != nil {
t.Fatalf("FromLabels: %v", err)
}
return c
}
func TestMerge_Disjoint(t *testing.T) {
a := nodeConfig(t, "10.0.0.1", "a", "a.de")
b := nodeConfig(t, "10.0.0.2", "b", "b.de")
got, err := Merge(a, nil, &Configuration{}, b)
if err != nil {
t.Fatalf("unerwarteter Fehler: %v", err)
}
for _, name := range []string{"a", "b"} {
if got.HTTP.Routers[name] == nil || got.HTTP.Services[name] == nil || got.HTTP.Middlewares[name+"-gzip"] == nil {
t.Errorf("%s fehlt in %s", name, mustJSON(t, got))
}
}
if n := len(a.HTTP.Routers); n != 1 {
t.Errorf("Eingabe wurde verändert: %d Router in a", n)
}
}
func TestMerge_IdenticalDuplicateAllowed(t *testing.T) {
a := nodeConfig(t, "10.0.0.1", "a", "a.de")
again := nodeConfig(t, "10.0.0.1", "a", "a.de")
got, err := Merge(a, again)
if err != nil {
t.Fatalf("unerwarteter Fehler: %v", err)
}
if !reflect.DeepEqual(got, a) {
t.Errorf("erwartet %s, bekommen %s", mustJSON(t, a), mustJSON(t, got))
}
}
func TestMerge_Conflicts(t *testing.T) {
a := nodeConfig(t, "10.0.0.1", "a", "a.de")
sameNameOtherHost := nodeConfig(t, "10.0.0.2", "a", "a.de")
sameNameOtherRule := nodeConfig(t, "10.0.0.1", "a", "evil.de")
otherMiddleware := &Configuration{HTTP: &HTTPConfiguration{
Middlewares: map[string]Middleware{"a-gzip": {"compress": "false"}},
}}
tests := map[string]*Configuration{
"Service": sameNameOtherHost,
"Router": sameNameOtherRule,
"Middleware": otherMiddleware,
}
for name, other := range tests {
t.Run(name, func(t *testing.T) {
got, err := Merge(a, other)
if !errors.Is(err, ErrConflict) {
t.Fatalf("erwartet ErrConflict, bekommen %v", err)
}
if got != nil {
t.Errorf("bei Konflikt erwartet nil, bekommen %+v", got)
}
})
}
}
func TestMerge_Empty(t *testing.T) {
for _, in := range [][]*Configuration{nil, {nil}, {{}}, {{HTTP: &HTTPConfiguration{}}}} {
got, err := Merge(in...)
if err != nil {
t.Fatalf("unerwarteter Fehler: %v", err)
}
if got == nil || got.HTTP != nil {
t.Errorf("erwartet leere Konfiguration, bekommen %+v", got)
}
}
}