build - prozess optimiert mit push nach zot

nginx-kill - > maint. site...
This commit is contained in:
br committed 2026-10-03 12:44:45 +02:00
1 parent 665135f87e
commit 64417d8353
19 files changed
+447 -61

No files matched your search

+18 -4
View File
@@ -230,27 +230,41 @@ func (a *Agent) sendLoop(ctx context.Context, hc *http.Client, host string, trig
}
// collect ermittelt den aktuellen Zustand: Docker-Container und Dienste aus
// ConfigDir samt Healthcheck-Ausfällen. Ohne Docker-Socket (Bare-Metal)
// ConfigDir samt Ausfällen (gestoppte Container, Healthchecks). Ohne Docker-Socket (Bare-Metal)
// entfallen die Container; schlägt die Docker-API fehl, wird nichts
// gesendet (sonst würden alle Container-Routen entfernt).
func (a *Agent) collect(ctx context.Context, host string) (registry.NodeState, error) {
state := registry.NodeState{Workloads: []registry.Workload{}}
if a.dockerAvailable() {
containers, err := (&docker.Client{Socket: a.cfg.DockerSocket}).List(ctx)
client := &docker.Client{Socket: a.cfg.DockerSocket}
containers, err := client.List(ctx)
if err != nil {
return state, err
}
ws, warnings := Workloads(containers, host)
// Gestoppte Container nennen in der Liste keine Ports.
for i, c := range containers {
if c.State == "running" || !enabled(c.Labels) {
continue
}
ports, err := client.PortBindings(ctx, c.ID)
if err != nil {
a.log.Debug("Portzuordnung nicht lesbar", "container", c.Name(), "err", err)
continue // inzwischen entfernt o. Ä. → Workloads warnt
}
containers[i].Ports = ports
}
ws, down, warnings := Workloads(containers, host)
for _, w := range warnings {
a.log.Warn("Container nicht übernommen", "grund", w)
}
state.Workloads = append(state.Workloads, ws...)
state.DownServices = append(state.DownServices, down...)
}
a.mu.Lock()
specs := a.fileSpecs
a.mu.Unlock()
state.Workloads = append(state.Workloads, FileWorkloads(specs, host)...)
state.DownServices = a.health.down()
state.DownServices = append(state.DownServices, a.health.down()...)
return state, nil
}
+40 -3
View File
@@ -5,6 +5,7 @@ import (
"crypto/ed25519"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net"
@@ -152,10 +153,36 @@ func newFakeDocker(t *testing.T, containers ...docker.Container) *fakeDocker {
t.Fatal(err)
}
mux := http.NewServeMux()
// Wie Docker: gestoppte Container ohne Ports in der Liste; die Zuordnung
// gibt es nur über /containers/{id}/json.
mux.HandleFunc("/containers/json", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
json.NewEncoder(w).Encode(f.containers)
list := []docker.Container{}
for _, c := range f.containers {
if c.State != "running" {
c.Ports = nil
}
list = append(list, c)
}
json.NewEncoder(w).Encode(list)
})
mux.HandleFunc("/containers/{id}/json", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
for _, c := range f.containers {
if c.ID != r.PathValue("id") {
continue
}
bindings := map[string][]map[string]string{}
for _, p := range c.Ports {
key := fmt.Sprintf("%d/%s", p.PrivatePort, p.Type)
bindings[key] = append(bindings[key], map[string]string{"HostIp": p.IP, "HostPort": fmt.Sprint(p.PublicPort)})
}
json.NewEncoder(w).Encode(map[string]any{"Id": c.ID, "HostConfig": map[string]any{"PortBindings": bindings}})
return
}
http.Error(w, `{"message":"No such container"}`, http.StatusNotFound)
})
mux.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) {
ch := make(chan docker.Event, 8)
@@ -256,8 +283,18 @@ func TestRun_EndToEnd(t *testing.T) {
t.Errorf("Knotenstatus %q", st)
}
dock.set("die") // Container gestoppt
eventually(t, "Route nach Containerstopp entfernt", func() bool { return s.traefikConfig(t) == "{}" })
// Gestoppt (docker stop, Absturz): Wartungsseite statt 404.
down := wordpress()
down.State = "exited"
dock.set("die", down)
eventually(t, "Wartungsseite nach Containerstopp", func() bool {
c := s.traefikConfig(t)
return strings.Contains(c, "kunde__node-1__wp") && strings.Contains(c, "sultan__interceptor") &&
strings.Contains(c, `"maintenance"`) && strings.Contains(c, "http://100.64.0.42:8081")
})
dock.set("destroy") // entfernt (docker compose down): Route weg
eventually(t, "Route nach Entfernen des Containers weg", func() bool { return s.traefikConfig(t) == "{}" })
dock.set("start", wordpress())
eventually(t, "Route nach Containerstart zurück", func() bool {
+71 -7
View File
@@ -17,7 +17,7 @@ const servicesPrefix = "traefik.http.services."
var unsafeName = regexp.MustCompile(`[^A-Za-z0-9_-]`)
// Workloads übersetzt laufende Container in Workloads für service_state.
// Workloads übersetzt Container in Workloads für service_state.
//
// Berücksichtigt werden nur Container mit traefik.enable=true. Weil der
// Ingress auf einem anderen Host sitzt, wird loadbalancer.server.port (Port
@@ -27,9 +27,20 @@ var unsafeName = regexp.MustCompile(`[^A-Za-z0-9_-]`)
// Containernamen. Container, deren Ports von außen nicht erreichbar sind
// (nicht veröffentlicht, nur Loopback, nur UDP), werden mit Warnung
// weggelassen.
func Workloads(containers []docker.Container, host string) ([]registry.Workload, []string) {
var out []registry.Workload
var warnings []string
//
// Gestoppte Container (State != "running", Ports aus PortBindings) bleiben
// drin; ihre Services stehen in down, damit die Policy zur Wartungsseite
// umleitet statt die Route zu entfernen. Überschneiden sich ihre Router-
// oder Service-Namen mit einem laufenden Container, entfallen sie.
func Workloads(containers []docker.Container, host string) (workloads []registry.Workload, down []string, warnings []string) {
type candidate struct {
workload registry.Workload
running bool
routers []string
services []string
}
var candidates []candidate
taken := map[string]bool{} // Namen laufender Container, "r:"/"s:" + klein
for _, c := range containers {
if !enabled(c.Labels) {
continue
@@ -39,10 +50,63 @@ func Workloads(containers []docker.Container, host string) ([]registry.Workload,
warnings = append(warnings, warning)
continue
}
out = append(out, registry.Workload{Name: c.Name(), Host: host, Labels: labels})
cand := candidate{
workload: registry.Workload{Name: c.Name(), Host: host, Labels: labels},
running: c.State == "running",
routers: labelNames(labels, "traefik.http.routers."),
services: labelNames(labels, servicesPrefix),
}
if cand.running {
for _, r := range cand.routers {
taken["r:"+strings.ToLower(r)] = true
}
for _, s := range cand.services {
taken["s:"+strings.ToLower(s)] = true
}
}
candidates = append(candidates, cand)
}
slices.SortFunc(out, func(a, b registry.Workload) int { return strings.Compare(a.Name, b.Name) })
return out, warnings
collides := func(c candidate) bool {
for _, r := range c.routers {
if taken["r:"+strings.ToLower(r)] {
return true
}
}
for _, s := range c.services {
if taken["s:"+strings.ToLower(s)] {
return true
}
}
return false
}
for _, c := range candidates {
if !c.running {
if collides(c) {
continue
}
down = append(down, c.services...)
}
workloads = append(workloads, c.workload)
}
slices.SortFunc(workloads, func(a, b registry.Workload) int { return strings.Compare(a.Name, b.Name) })
slices.Sort(down)
return workloads, slices.Compact(down), warnings
}
// labelNames liefert die Namen (Schreibweise aus dem Label) unter prefix,
// z. B. die Router aus traefik.http.routers.<name>.…
func labelNames(labels map[string]string, prefix string) []string {
var out []string
for k := range labels {
if len(k) <= len(prefix) || !strings.EqualFold(k[:len(prefix)], prefix) {
continue
}
if name, _, ok := strings.Cut(k[len(prefix):], "."); ok && !slices.Contains(out, name) {
out = append(out, name)
}
}
slices.Sort(out)
return out
}
func enabled(labels map[string]string) bool {
+81 -9
View File
@@ -27,7 +27,7 @@ func wpLabels() map[string]string {
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")
got, _, warnings := Workloads([]docker.Container{in}, "100.64.0.42")
if len(warnings) != 0 {
t.Errorf("Warnungen: %v", warnings)
}
@@ -52,7 +52,7 @@ func TestWorkloads_SkipsDisabled(t *testing.T) {
off["traefik.enable"] = "false"
none := wpLabels()
delete(none, "traefik.enable")
got, warnings := Workloads([]docker.Container{
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)),
@@ -71,7 +71,7 @@ func TestWorkloads_UnreachablePorts(t *testing.T) {
"anderer Port": {pub(8080, 8081)},
}
for name, ports := range tests {
got, warnings := Workloads([]docker.Container{ctr("wordpress", wpLabels(), ports...)}, "h")
got, _, warnings := Workloads([]docker.Container{ctr("wordpress", wpLabels(), ports...)}, "h")
if len(got) != 0 {
t.Errorf("%s: Workload trotz unerreichbarem Port: %+v", name, got)
}
@@ -82,7 +82,7 @@ func TestWorkloads_UnreachablePorts(t *testing.T) {
}
func TestWorkloads_IPv6AnyIsReachable(t *testing.T) {
got, _ := Workloads([]docker.Container{ctr("wordpress", wpLabels(),
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)
@@ -93,7 +93,7 @@ func TestWorkloads_IPv6AnyIsReachable(t *testing.T) {
// 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")
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)
}
@@ -101,7 +101,7 @@ func TestWorkloads_DefaultService(t *testing.T) {
t.Errorf("Default-Service fehlt: %v", got[0].Labels)
}
_, warnings = Workloads([]docker.Container{ctr("multi", labels, pub(3000, 13000), pub(3001, 13001))}, "h")
_, _, 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)
}
@@ -113,7 +113,7 @@ func TestWorkloads_ServiceWithoutPortLabel(t *testing.T) {
"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")
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)
}
@@ -129,7 +129,7 @@ func TestWorkloads_MultipleServicesAndCaseInsensitiveKeys(t *testing.T) {
"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")
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)
}
@@ -140,7 +140,7 @@ func TestWorkloads_MultipleServicesAndCaseInsensitiveKeys(t *testing.T) {
}
func TestWorkloads_SortedByName(t *testing.T) {
got, _ := Workloads([]docker.Container{
got, _, _ := Workloads([]docker.Container{
ctr("b", wpLabels(), pub(80, 8082)),
ctr("a", wpLabels(), pub(80, 8081)),
}, "h")
@@ -148,3 +148,75 @@ func TestWorkloads_SortedByName(t *testing.T) {
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)
}
}