263 lines
8.7 KiB
Go
263 lines
8.7 KiB
Go
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)
|
|
}
|
|
}
|