package docker import ( "context" "encoding/json" "net" "net/http" "os" "path/filepath" "reflect" "strings" "testing" "time" ) // fakeEngine bedient die Docker-API über einen Unix-Socket. type fakeEngine struct { path string events chan Event // was der Server über /events streamt lastQ chan string } func newFakeEngine(t *testing.T, handler func(w http.ResponseWriter, r *http.Request)) *fakeEngine { t.Helper() // Unix-Socket-Pfade sind kurz begrenzt; TempDir kann zu lang sein. dir, err := os.MkdirTemp("", "dk") if err != nil { t.Fatal(err) } f := &fakeEngine{path: filepath.Join(dir, "d.sock"), events: make(chan Event, 8), lastQ: make(chan string, 8)} ln, err := net.Listen("unix", f.path) if err != nil { t.Fatal(err) } mux := http.NewServeMux() 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("/info", handler) mux.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) { f.lastQ <- r.URL.RawQuery w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) w.(http.Flusher).Flush() enc := json.NewEncoder(w) for { select { case <-r.Context().Done(): return case ev, ok := <-f.events: if !ok { return } enc.Encode(ev) w.(http.Flusher).Flush() } } }) srv := &http.Server{Handler: mux} go srv.Serve(ln) t.Cleanup(func() { srv.Close(); os.RemoveAll(dir) }) return f } const containersJSON = `[ {"Id":"abc","Names":["/wordpress"],"State":"running", "Labels":{"traefik.enable":"true","traefik.http.routers.wp.rule":"Host(` + "`kunde.de`" + `)"}, "Ports":[{"IP":"0.0.0.0","PrivatePort":80,"PublicPort":8081,"Type":"tcp"},{"PrivatePort":443,"Type":"tcp"}]}, {"Id":"def","Names":["/db"],"State":"running","Labels":{},"Ports":[]} ]` func TestList(t *testing.T) { f := newFakeEngine(t, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Write([]byte(containersJSON)) }) got, err := (&Client{Socket: f.path}).List(context.Background()) 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", Labels: map[string]string{"traefik.enable": "true", "traefik.http.routers.wp.rule": "Host(`kunde.de`)"}, Ports: []Port{{IP: "0.0.0.0", PrivatePort: 80, PublicPort: 8081, Type: "tcp"}, {PrivatePort: 443, Type: "tcp"}}, }, {ID: "def", Names: []string{"/db"}, State: "running", Labels: map[string]string{}, Ports: []Port{}}, } if !reflect.DeepEqual(got, want) { t.Errorf("List =\n%+v\nerwartet\n%+v", got, want) } if got[0].Name() != "wordpress" { t.Errorf("Name() = %q", got[0].Name()) } } // 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) }) if _, err := (&Client{Socket: f.path}).List(context.Background()); err == nil || !strings.Contains(err.Error(), "500") { t.Errorf("Status 500: %v", err) } if _, err := (&Client{Socket: "/gibt/es/nicht.sock"}).List(context.Background()); err == nil { t.Error("fehlender Socket: erwartet Fehler") } } func TestEvents(t *testing.T) { f := newFakeEngine(t, func(http.ResponseWriter, *http.Request) {}) ctx, cancel := context.WithCancel(context.Background()) events, errc := (&Client{Socket: f.path}).Events(ctx) q := <-f.lastQ if !strings.Contains(q, "filters=") || !strings.Contains(q, "container") { t.Errorf("Events ohne Container-Filter: %q", q) } f.events <- Event{Type: "container", Action: "start", Actor: Actor{ID: "abc", Attributes: map[string]string{"name": "wordpress"}}} f.events <- Event{Type: "container", Action: "die", Actor: Actor{ID: "abc"}} for _, want := range []string{"start", "die"} { select { case ev := <-events: if ev.Action != want || ev.Actor.ID != "abc" { t.Errorf("Event = %+v, erwartet %s", ev, want) } case <-time.After(2 * time.Second): t.Fatalf("Event %s nicht empfangen", want) } } cancel() select { case _, ok := <-events: if ok { t.Error("Kanal nach Abbruch nicht geschlossen") } case <-time.After(2 * time.Second): t.Fatal("Events endet nicht nach Abbruch") } if err := <-errc; err != nil && err != context.Canceled { t.Errorf("Fehler nach Abbruch: %v", err) } } // Endet der Event-Stream serverseitig, meldet Events das über errc. func TestEvents_StreamEnds(t *testing.T) { f := newFakeEngine(t, func(http.ResponseWriter, *http.Request) {}) events, errc := (&Client{Socket: f.path}).Events(context.Background()) <-f.lastQ close(f.events) for range events { } if err := <-errc; err == nil { t.Error("erwartet Fehler bei beendetem Stream") } } // Inspect liefert Ports (auch gestoppter Container), Startzeit und // Health-Status in einem Aufruf. func TestInspect(t *testing.T) { f := newFakeEngine(t, func(w http.ResponseWriter, r *http.Request) { switch r.PathValue("id") { case "abc": w.Write([]byte(`{"Id":"abc","State":{"Status":"running","StartedAt":"2026-10-16T08:15:30.123456789Z", "Health":{"Status":"healthy"}}, "HostConfig":{"PortBindings":{"80/tcp":[{"HostIp":"","HostPort":"9080"}]}}}`)) case "nie": // Nie gestartet, kein Healthcheck: Docker liefert die Nullzeit. w.Write([]byte(`{"Id":"nie","State":{"Status":"created","StartedAt":"0001-01-01T00:00:00Z"},"HostConfig":{}}`)) default: http.Error(w, `{"message":"No such container"}`, http.StatusNotFound) } }) c := &Client{Socket: f.path} got, err := c.Inspect(context.Background(), "abc") if err != nil { t.Fatalf("Inspect: %v", err) } want := Details{ Ports: []Port{{PrivatePort: 80, PublicPort: 9080, Type: "tcp"}}, StartedAt: time.Date(2026, 10, 16, 8, 15, 30, 123456789, time.UTC), Health: "healthy", } if !reflect.DeepEqual(got, want) { t.Errorf("Inspect =\n%+v\nerwartet\n%+v", got, want) } got, err = c.Inspect(context.Background(), "nie") if err != nil { t.Fatalf("Inspect: %v", err) } if !got.StartedAt.IsZero() || got.Health != "" || len(got.Ports) != 0 { t.Errorf("nie gestartet: %+v", got) } if _, err := c.Inspect(context.Background(), "weg"); err == nil || !strings.Contains(err.Error(), "404") { t.Errorf("unbekannter Container: %v", err) } if _, err := c.Inspect(context.Background(), "../x"); err == nil { t.Error("ungültige ID akzeptiert") } } // Info liefert das Betriebssystem des Hosts (nicht des Agent-Containers) und // die Docker-Version. func TestInfo(t *testing.T) { f := newFakeEngine(t, func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`{"Name":"webdocker11","OperatingSystem":"Debian GNU/Linux 13 (trixie)","ServerVersion":"29.8.2","NCPU":4}`)) }) got, err := (&Client{Socket: f.path}).Info(context.Background()) if err != nil { t.Fatalf("Info: %v", err) } want := Info{Name: "webdocker11", OperatingSystem: "Debian GNU/Linux 13 (trixie)", ServerVersion: "29.8.2"} if got != want { t.Errorf("Info = %+v, erwartet %+v", got, want) } }