build - prozess optimiert mit push nach zot
nginx-kill - > maint. site...
This commit is contained in:
1 parent
665135f87e
commit
64417d8353
19 files changed
+447
-61
No files matched your search
+18
-4
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+10
-3
@@ -50,7 +50,7 @@ func Run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
||||
fmt.Fprint(stdout, usage)
|
||||
return 0
|
||||
case "version":
|
||||
fmt.Fprintf(stdout, "sultan %s\n", Version)
|
||||
fmt.Fprintf(stdout, "sultan %s\nZeitzone: %s\n", Version, time.Local)
|
||||
return 0
|
||||
case "server":
|
||||
return runServer(ctx, args[1:], stdout, stderr)
|
||||
@@ -127,5 +127,12 @@ func parseServerFlags(args []string, output io.Writer, getenv func(string) strin
|
||||
// stringList ist ein mehrfach angebbares String-Flag.
|
||||
type stringList []string
|
||||
|
||||
func (l *stringList) String() string { return strings.Join(*l, ",") }
|
||||
func (l *stringList) Set(v string) error { *l = append(*l, v); return nil }
|
||||
func (l *stringList) String() string { return strings.Join(*l, ",") }
|
||||
|
||||
// Set ignoriert leere Werte (z. B. eine leere Variable in Compose).
|
||||
func (l *stringList) Set(v string) error {
|
||||
if v = strings.TrimSpace(v); v != "" {
|
||||
*l = append(*l, v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+18
-1
@@ -33,9 +33,14 @@ func TestRun_Usage(t *testing.T) {
|
||||
|
||||
func TestRun_Version(t *testing.T) {
|
||||
code, stdout, _ := run(context.Background(), "version")
|
||||
if code != 0 || strings.TrimSpace(stdout) != "sultan "+Version {
|
||||
lines := strings.Split(strings.TrimSpace(stdout), "\n")
|
||||
if code != 0 || lines[0] != "sultan "+Version {
|
||||
t.Errorf("version: code=%d stdout=%q", code, stdout)
|
||||
}
|
||||
// Die aktive Zeitzone sichtbar machen (Distroless-Images ohne tzdata fallen still auf UTC).
|
||||
if len(lines) < 2 || lines[1] != "Zeitzone: "+time.Local.String() {
|
||||
t.Errorf("Zeitzone fehlt: %q", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerFlags(t *testing.T) {
|
||||
@@ -219,3 +224,15 @@ func TestServerFlags_EnrollLimit(t *testing.T) {
|
||||
t.Errorf("--enroll-limit=-1: %d", cfg.EnrollLimit)
|
||||
}
|
||||
}
|
||||
|
||||
// Leere Werte (z. B. --public-url=${SULTAN_PUBLIC_URL} mit leerer Variable in
|
||||
// Compose) dürfen den Server nicht am Start hindern.
|
||||
func TestServerFlags_EmptyPublicURLIgnored(t *testing.T) {
|
||||
cfg, err := parseServerFlags([]string{"--public-url=", "--public-url= ", "--notify-slack="}, &bytes.Buffer{}, noEnv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cfg.PublicURLs) != 0 || len(cfg.SlackWebhooks) != 0 {
|
||||
t.Errorf("leere Werte übernommen: %q %q", cfg.PublicURLs, cfg.SlackWebhooks)
|
||||
}
|
||||
}
|
||||
+62
-4
@@ -1,6 +1,7 @@
|
||||
// Package docker ist ein minimaler Client für die Docker Engine API über
|
||||
// den Unix-Socket – genau das, was der Agent braucht: laufende Container
|
||||
// mit Labels und Ports auflisten und Container-Events verfolgen. Bewusst
|
||||
// den Unix-Socket – genau das, was der Agent braucht: Container mit Labels
|
||||
// und Ports auflisten, Portzuordnungen gestoppter Container lesen und
|
||||
// Container-Events verfolgen. Bewusst
|
||||
// ohne Docker-SDK (schwere Abhängigkeit für zwei Endpunkte).
|
||||
package docker
|
||||
|
||||
@@ -13,6 +14,9 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -85,9 +89,10 @@ func (c *Client) get(ctx context.Context, path string) (*http.Response, error) {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// List liefert die laufenden Container.
|
||||
// List liefert alle Container, auch gestoppte (State != "running"); bei
|
||||
// diesen ist Ports leer, siehe PortBindings.
|
||||
func (c *Client) List(ctx context.Context) ([]Container, error) {
|
||||
resp, err := c.get(ctx, "/containers/json")
|
||||
resp, err := c.get(ctx, "/containers/json?all=1")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -99,6 +104,59 @@ func (c *Client) List(ctx context.Context) ([]Container, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
var validID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]*$`)
|
||||
|
||||
// PortBindings liefert die konfigurierten Portzuordnungen eines Containers
|
||||
// (HostConfig.PortBindings) – auch wenn er gestoppt ist. Ohne festen
|
||||
// Host-Port ist PublicPort 0. Sortiert nach "<port>/<proto>".
|
||||
func (c *Client) PortBindings(ctx context.Context, id string) ([]Port, error) {
|
||||
if !validID.MatchString(id) {
|
||||
return nil, fmt.Errorf("docker: ungültige Container-ID %q", id)
|
||||
}
|
||||
resp, err := c.get(ctx, "/containers/"+id+"/json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var info struct {
|
||||
HostConfig struct {
|
||||
PortBindings map[string][]struct {
|
||||
HostIP string `json:"HostIp"`
|
||||
HostPort string `json:"HostPort"`
|
||||
} `json:"PortBindings"`
|
||||
} `json:"HostConfig"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
|
||||
return nil, fmt.Errorf("docker: Container %s: %w", id, err)
|
||||
}
|
||||
keys := make([]string, 0, len(info.HostConfig.PortBindings))
|
||||
for k := range info.HostConfig.PortBindings {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
var out []Port
|
||||
for _, k := range keys {
|
||||
port, proto, _ := strings.Cut(k, "/")
|
||||
private, err := strconv.ParseUint(port, 10, 16)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if proto == "" {
|
||||
proto = "tcp"
|
||||
}
|
||||
bindings := info.HostConfig.PortBindings[k]
|
||||
if len(bindings) == 0 {
|
||||
out = append(out, Port{PrivatePort: uint16(private), Type: proto})
|
||||
continue
|
||||
}
|
||||
for _, b := range bindings {
|
||||
public, _ := strconv.ParseUint(b.HostPort, 10, 16) // "" → 0
|
||||
out = append(out, Port{IP: b.HostIP, PrivatePort: uint16(private), PublicPort: uint16(public), Type: proto})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Events verfolgt Container-Events, bis ctx endet oder der Stream abbricht.
|
||||
// Der Event-Kanal wird danach geschlossen; errc liefert genau einen Wert
|
||||
// (ctx.Err() bei Abbruch, sonst den Grund).
|
||||
|
||||
@@ -33,7 +33,11 @@ func newFakeEngine(t *testing.T, handler func(w http.ResponseWriter, r *http.Req
|
||||
t.Fatal(err)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/containers/json", handler)
|
||||
mux.HandleFunc("/containers/json", func(w http.ResponseWriter, r *http.Request) {
|
||||
f.lastQ <- r.URL.RawQuery
|
||||
handler(w, r)
|
||||
})
|
||||
mux.HandleFunc("/containers/{id}/json", handler)
|
||||
mux.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) {
|
||||
f.lastQ <- r.URL.RawQuery
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -75,6 +79,10 @@ func TestList(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
// Auch gestoppte Container: Sie werden zur Wartungsseite statt zu 404.
|
||||
if q := <-f.lastQ; q != "all=1" {
|
||||
t.Errorf("Query = %q, erwartet all=1", q)
|
||||
}
|
||||
want := []Container{
|
||||
{
|
||||
ID: "abc", Names: []string{"/wordpress"}, State: "running",
|
||||
@@ -91,6 +99,45 @@ func TestList(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Gestoppte Container haben in /containers/json keine Ports; die
|
||||
// Zuordnung steht in HostConfig.PortBindings.
|
||||
func TestPortBindings(t *testing.T) {
|
||||
f := newFakeEngine(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.PathValue("id") != "abc" {
|
||||
http.Error(w, `{"message":"No such container"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Write([]byte(`{"Id":"abc","State":{"Status":"exited"},"HostConfig":{"PortBindings":{
|
||||
"80/tcp":[{"HostIp":"","HostPort":"9080"},{"HostIp":"::","HostPort":"9080"}],
|
||||
"443/tcp":[{"HostIp":"127.0.0.1","HostPort":"9443"}],
|
||||
"53/udp":[{"HostIp":"","HostPort":"5353"}],
|
||||
"8080/tcp":[{"HostIp":"","HostPort":""}],
|
||||
"9000/tcp":null}}}`))
|
||||
})
|
||||
c := &Client{Socket: f.path}
|
||||
got, err := c.PortBindings(context.Background(), "abc")
|
||||
if err != nil {
|
||||
t.Fatalf("PortBindings: %v", err)
|
||||
}
|
||||
want := []Port{
|
||||
{IP: "127.0.0.1", PrivatePort: 443, PublicPort: 9443, Type: "tcp"},
|
||||
{IP: "", PrivatePort: 53, PublicPort: 5353, Type: "udp"},
|
||||
{IP: "", PrivatePort: 80, PublicPort: 9080, Type: "tcp"},
|
||||
{IP: "::", PrivatePort: 80, PublicPort: 9080, Type: "tcp"},
|
||||
{IP: "", PrivatePort: 8080, Type: "tcp"}, // Host-Port erst beim Start vergeben
|
||||
{IP: "", PrivatePort: 9000, Type: "tcp"},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("PortBindings =\n%+v\nerwartet\n%+v", got, want)
|
||||
}
|
||||
if _, err := c.PortBindings(context.Background(), "weg"); err == nil || !strings.Contains(err.Error(), "404") {
|
||||
t.Errorf("unbekannter Container: %v", err)
|
||||
}
|
||||
if _, err := c.PortBindings(context.Background(), "../x"); err == nil {
|
||||
t.Error("ungültige ID akzeptiert")
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_Errors(t *testing.T) {
|
||||
f := newFakeEngine(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, `{"message":"kaputt"}`, http.StatusInternalServerError)
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -162,13 +163,18 @@ type Server struct {
|
||||
// TLS-Zertifikat und Master-Key. Lauschen beginnt erst mit Start.
|
||||
func New(ctx context.Context, cfg Config) (*Server, error) {
|
||||
cfg = cfg.withDefaults()
|
||||
for i, raw := range cfg.PublicURLs {
|
||||
var urls []string
|
||||
for _, raw := range cfg.PublicURLs {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
continue // leere Einträge (z. B. leere Variable in Compose) ignorieren
|
||||
}
|
||||
u, err := crypto.ValidateServerURL(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("server: --public-url: %w", err)
|
||||
}
|
||||
cfg.PublicURLs[i] = u
|
||||
urls = append(urls, u)
|
||||
}
|
||||
cfg.PublicURLs = urls
|
||||
if err := os.MkdirAll(cfg.DataDir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("server: Datenverzeichnis: %w", err)
|
||||
}
|
||||
|
||||
@@ -1200,3 +1200,15 @@ func TestConfigDefaults_EnrollLimitDisabled(t *testing.T) {
|
||||
t.Errorf("-1 (unbegrenzt) überschrieben: %d", c.EnrollLimit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicURLs_BlankEntriesIgnored(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "suldata")
|
||||
s, err := New(ctx, Config{DataDir: dir, PublicURLs: []string{"", " ", "https://a.example:8443"}, Logger: slog.New(slog.DiscardHandler)})
|
||||
if err != nil {
|
||||
t.Fatalf("leere Einträge verhindern den Start: %v", err)
|
||||
}
|
||||
defer s.Shutdown(ctx)
|
||||
if got, _ := s.PublicURLs(ctx); !reflect.DeepEqual(got, []string{"https://a.example:8443"}) {
|
||||
t.Errorf("gespeichert: %v", got)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user