package agent import ( "reflect" "strings" "testing" "time" "sultan/pkg/docker" "sultan/pkg/registry" ) func ctr(name string, labels map[string]string, ports ...docker.Port) docker.Container { return docker.Container{ID: name + "-id", Names: []string{"/" + name}, State: "running", Labels: labels, Ports: ports} } func pub(private, public uint16) docker.Port { return docker.Port{IP: "0.0.0.0", PrivatePort: private, PublicPort: public, Type: "tcp"} } func wpLabels() map[string]string { return map[string]string{ "traefik.enable": "true", "traefik.http.routers.wp.rule": "Host(`kunde.de`)", "traefik.http.services.wp.loadbalancer.server.port": "80", } } func TestWorkloads_TranslatesPublishedPort(t *testing.T) { in := ctr("wordpress", wpLabels(), pub(80, 8081), pub(443, 8443)) got, _, warnings := Workloads([]docker.Container{in}, "100.64.0.42") if len(warnings) != 0 { t.Errorf("Warnungen: %v", warnings) } want := []registry.Workload{{ Name: "wordpress", Host: "100.64.0.42", Labels: map[string]string{ "traefik.enable": "true", "traefik.http.routers.wp.rule": "Host(`kunde.de`)", "traefik.http.services.wp.loadbalancer.server.port": "8081", }, Meta: ®istry.WorkloadMeta{State: "running"}, }} if !reflect.DeepEqual(got, want) { t.Errorf("Workloads =\n%+v\nerwartet\n%+v", got, want) } if in.Labels["traefik.http.services.wp.loadbalancer.server.port"] != "80" { t.Error("Eingabe-Labels wurden verändert") } } func TestWorkloads_SkipsDisabled(t *testing.T) { off := wpLabels() off["traefik.enable"] = "false" none := wpLabels() delete(none, "traefik.enable") got, _, warnings := Workloads([]docker.Container{ ctr("aus", off, pub(80, 8081)), ctr("ohne", none, pub(80, 8082)), ctr("db", map[string]string{}, pub(5432, 5432)), }, "h") if len(got) != 0 || len(warnings) != 0 { t.Errorf("got=%v warnings=%v", got, warnings) } } func TestWorkloads_UnreachablePorts(t *testing.T) { tests := map[string][]docker.Port{ "nicht veröffentlicht": {{PrivatePort: 80, Type: "tcp"}}, "nur IPv4-Loopback": {{IP: "127.0.0.1", PrivatePort: 80, PublicPort: 8081, Type: "tcp"}}, "nur IPv6-Loopback": {{IP: "::1", PrivatePort: 80, PublicPort: 8081, Type: "tcp"}}, "nur UDP": {{IP: "0.0.0.0", PrivatePort: 80, PublicPort: 8081, Type: "udp"}}, "anderer Port": {pub(8080, 8081)}, } for name, ports := range tests { got, _, warnings := Workloads([]docker.Container{ctr("wordpress", wpLabels(), ports...)}, "h") if len(got) != 0 { t.Errorf("%s: Workload trotz unerreichbarem Port: %+v", name, got) } if len(warnings) != 1 || !strings.Contains(warnings[0], "wordpress") || !strings.Contains(warnings[0], "80") { t.Errorf("%s: Warnungen = %v", name, warnings) } } } func TestWorkloads_IPv6AnyIsReachable(t *testing.T) { got, _, _ := Workloads([]docker.Container{ctr("wordpress", wpLabels(), docker.Port{IP: "::", PrivatePort: 80, PublicPort: 8081, Type: "tcp"})}, "h") if len(got) != 1 || got[0].Labels["traefik.http.services.wp.loadbalancer.server.port"] != "8081" { t.Errorf("got = %+v", got) } } // Wie Traefiks Docker-Provider: ohne Service-Labels gibt es einen Service // mit dem Containernamen und dem einzigen veröffentlichten Port. func TestWorkloads_DefaultService(t *testing.T) { labels := map[string]string{"traefik.enable": "true", "traefik.http.routers.app.rule": "Host(`app.kunde.de`)"} got, _, warnings := Workloads([]docker.Container{ctr("my.app", labels, pub(3000, 13000))}, "h") if len(warnings) != 0 || len(got) != 1 { t.Fatalf("got=%+v warnings=%v", got, warnings) } if got[0].Labels["traefik.http.services.my-app.loadbalancer.server.port"] != "13000" { t.Errorf("Default-Service fehlt: %v", got[0].Labels) } _, _, warnings = Workloads([]docker.Container{ctr("multi", labels, pub(3000, 13000), pub(3001, 13001))}, "h") if len(warnings) != 1 || !strings.Contains(warnings[0], "multi") { t.Errorf("mehrere Ports ohne Port-Label: Warnungen = %v", warnings) } } func TestWorkloads_ServiceWithoutPortLabel(t *testing.T) { labels := map[string]string{ "traefik.enable": "true", "traefik.http.routers.wp.rule": "Host(`kunde.de`)", "traefik.http.services.wp.loadbalancer.passhostheader": "true", } got, _, warnings := Workloads([]docker.Container{ctr("wordpress", labels, pub(80, 8081))}, "h") if len(warnings) != 0 || len(got) != 1 || got[0].Labels["traefik.http.services.wp.loadbalancer.server.port"] != "8081" { t.Errorf("got=%+v warnings=%v", got, warnings) } } func TestWorkloads_MultipleServicesAndCaseInsensitiveKeys(t *testing.T) { labels := map[string]string{ "Traefik.Enable": "TRUE", "traefik.http.routers.web.rule": "Host(`kunde.de`)", "traefik.http.routers.web.service": "web", "traefik.http.routers.api.rule": "Host(`api.kunde.de`)", "traefik.http.routers.api.service": "api", "traefik.http.services.web.loadbalancer.server.port": "80", "Traefik.HTTP.Services.Api.LoadBalancer.Server.Port": "9000", } got, _, warnings := Workloads([]docker.Container{ctr("app", labels, pub(80, 8080), pub(9000, 19000))}, "h") if len(warnings) != 0 || len(got) != 1 { t.Fatalf("got=%+v warnings=%v", got, warnings) } l := got[0].Labels if l["traefik.http.services.web.loadbalancer.server.port"] != "8080" || l["Traefik.HTTP.Services.Api.LoadBalancer.Server.Port"] != "19000" { t.Errorf("Labels = %v", l) } } func TestWorkloads_SortedByName(t *testing.T) { got, _, _ := Workloads([]docker.Container{ ctr("b", wpLabels(), pub(80, 8082)), ctr("a", wpLabels(), pub(80, 8081)), }, "h") if len(got) != 2 || got[0].Name != "a" || got[1].Name != "b" { t.Errorf("Reihenfolge: %+v", got) } } func stopped(c docker.Container, state string) docker.Container { c.State = state return c } // Gestoppt (nicht entfernt): Route bleibt, Services gelten als down → die // Policy leitet zur Wartungsseite statt 404. Die Ports kommen bei gestoppten // Containern aus PortBindings (der Agent setzt sie vorher in Ports ein). func TestWorkloads_StoppedContainerIsDown(t *testing.T) { for _, state := range []string{"exited", "created", "restarting", "paused", "dead"} { got, down, warnings := Workloads([]docker.Container{stopped(ctr("nginx", wpLabels(), pub(80, 9080)), state)}, "h") if len(warnings) != 0 || len(got) != 1 { t.Fatalf("%s: got=%+v warnings=%v", state, got, warnings) } if got[0].Labels["traefik.http.services.wp.loadbalancer.server.port"] != "9080" { t.Errorf("%s: Labels = %v", state, got[0].Labels) } if !reflect.DeepEqual(down, []string{"wp"}) { t.Errorf("%s: down = %v, erwartet [wp]", state, down) } } // Default-Service und mehrere Services, alle down, sortiert. labels := map[string]string{"traefik.enable": "true", "traefik.http.routers.app.rule": "Host(`app.kunde.de`)"} _, down, _ := Workloads([]docker.Container{stopped(ctr("my.app", labels, pub(3000, 13000)), "exited")}, "h") if !reflect.DeepEqual(down, []string{"my-app"}) { t.Errorf("Default-Service: down = %v", down) } multi := map[string]string{ "traefik.enable": "true", "traefik.http.routers.web.rule": "Host(`kunde.de`)", "traefik.http.routers.web.service": "web", "traefik.http.services.web.loadbalancer.server.port": "80", "traefik.http.services.Api.loadbalancer.server.port": "9000", } _, down, _ = Workloads([]docker.Container{stopped(ctr("app", multi, pub(80, 8080), pub(9000, 19000)), "exited")}, "h") if !reflect.DeepEqual(down, []string{"Api", "web"}) { t.Errorf("mehrere Services: down = %v", down) } // Laufend: nichts down. if _, down, _ := Workloads([]docker.Container{ctr("nginx", wpLabels(), pub(80, 9080))}, "h"); len(down) != 0 { t.Errorf("laufend: down = %v", down) } } // Ein gestoppter Container mit denselben Router- oder Service-Namen wie ein // laufender (z. B. alte Kopie) darf diesen nicht verdrängen. func TestWorkloads_RunningWinsOverStopped(t *testing.T) { routerOnly := map[string]string{"traefik.enable": "true", "traefik.http.routers.WP.rule": "Host(`alt.kunde.de`)", "traefik.http.services.other.loadbalancer.server.port": "80"} serviceOnly := map[string]string{"traefik.enable": "true", "traefik.http.routers.x.rule": "Host(`x.kunde.de`)", "traefik.http.routers.x.service": "wp", "traefik.http.services.wp.loadbalancer.server.port": "80"} for name, labels := range map[string]map[string]string{"gleiche Labels": wpLabels(), "gleicher Router": routerOnly, "gleicher Service": serviceOnly} { got, down, warnings := Workloads([]docker.Container{ stopped(ctr("a-alt", labels, pub(80, 9081)), "exited"), ctr("b-neu", wpLabels(), pub(80, 9080)), }, "h") if len(got) != 1 || got[0].Name != "b-neu" || len(down) != 0 || len(warnings) != 0 { t.Errorf("%s: got=%+v down=%v warnings=%v", name, got, down, warnings) } } // Ohne Überschneidung bleiben beide. other := map[string]string{"traefik.enable": "true", "traefik.http.routers.alt.rule": "Host(`alt.kunde.de`)", "traefik.http.services.alt.loadbalancer.server.port": "80"} got, down, _ := Workloads([]docker.Container{stopped(ctr("a-alt", other, pub(80, 9081)), "exited"), ctr("b-neu", wpLabels(), pub(80, 9080))}, "h") if len(got) != 2 || !reflect.DeepEqual(down, []string{"alt"}) { t.Errorf("ohne Überschneidung: got=%+v down=%v", got, down) } } // Compose-Projekt, -Verzeichnis, Zustand, Startzeit und Health kommen // automatisch aus Docker – ohne zusätzliche Labels. func TestWorkloads_Meta(t *testing.T) { started := time.Date(2026, 10, 16, 8, 15, 30, 0, time.UTC) labels := wpLabels() labels["com.docker.compose.project"] = "kunde-a" labels["com.docker.compose.service"] = "wordpress" labels["com.docker.compose.project.working_dir"] = "/srv/kunde-a" compose := ctr("kunde-a-wordpress-1", labels, pub(80, 8081)) compose.StartedAt = started compose.Health = "healthy" plain := ctr("solo", map[string]string{ "traefik.enable": "true", "traefik.http.routers.so.rule": "Host(`solo.kunde.de`)", }, pub(80, 8082)) plain.State = "exited" got, _, warnings := Workloads([]docker.Container{compose, plain}, "100.64.0.42") if len(warnings) != 0 || len(got) != 2 { t.Fatalf("Workloads = %+v, Warnungen %v", got, warnings) } want := map[string]*registry.WorkloadMeta{ "kunde-a-wordpress-1": { ComposeProject: "kunde-a", ComposeService: "wordpress", ComposeDir: "/srv/kunde-a", State: "running", StartedAt: started, Health: "healthy", }, "solo": {State: "exited"}, } for _, w := range got { if !reflect.DeepEqual(w.Meta, want[w.Name]) { t.Errorf("%s: Meta = %+v, erwartet %+v", w.Name, w.Meta, want[w.Name]) } } }