package docker import ( "bytes" "context" "encoding/binary" "encoding/json" "errors" "io" "net" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" "time" ) func testClient(t *testing.T, handler http.HandlerFunc) *Client { t.Helper() server := httptest.NewServer(handler) t.Cleanup(server.Close) client := New(strings.Replace(server.URL, "http://", "tcp://", 1)) t.Cleanup(client.http.CloseIdleConnections) return client } func TestDo(t *testing.T) { for _, path := range []string{"/containers/create?name=test", "containers/create?name=test", "/v1.47/containers/create?name=test"} { t.Run(path, func(t *testing.T) { client := testClient(t, func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || r.URL.RequestURI() != "/v1.47/containers/create?name=test" || r.Header.Get("Content-Type") != "application/json" { t.Errorf("unexpected request: %s %s %v", r.Method, r.URL, r.Header) } var body map[string]string if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body["Image"] != "test" { t.Errorf("body = %v, err = %v", body, err) } w.WriteHeader(http.StatusCreated) io.WriteString(w, `{"Id":"container"}`) }) var out struct{ ID string } if err := client.Do(context.Background(), http.MethodPost, path, map[string]string{"Image": "test"}, &out); err != nil || out.ID != "container" { t.Fatalf("out = %v, err = %v", out, err) } }) } } func TestDoNoContentAndInvalidJSON(t *testing.T) { client := testClient(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) }) var out any if err := client.Do(context.Background(), "POST", "/stop", nil, &out); err != nil { t.Fatal(err) } if err := client.Do(context.Background(), "POST", "/stop", make(chan int), nil); err == nil { t.Fatal("expected marshal error") } client = testClient(t, func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "invalid") }) if err := client.Do(context.Background(), "GET", "/test", nil, &out); err == nil { t.Fatal("expected JSON decode error") } } func TestAPIErrors(t *testing.T) { for _, tc := range []struct { name, body, want string status int }{ {"json", `{"message":"not found"}`, "not found", 404}, {"text", "unavailable", "unavailable", 503}, {"empty", "", "500 Internal Server Error", 500}, {"bounded", strings.Repeat("x", maxErrorBytes*2), strings.Repeat("x", maxErrorBytes), 500}, {"redirect", "no redirect", "no redirect", 302}, } { t.Run(tc.name, func(t *testing.T) { client := testClient(t, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Location", "/elsewhere") w.WriteHeader(tc.status) io.WriteString(w, tc.body) }) err := client.Do(context.Background(), "GET", "/test", nil, nil) var apiErr *APIError if !errors.As(err, &apiErr) || apiErr.StatusCode != tc.status || apiErr.Message != tc.want { t.Fatalf("unexpected API error: %v", err) } if !strings.Contains(err.Error(), http.StatusText(tc.status)) && tc.name == "empty" { t.Fatal(err) } }) } } func TestUnixAndCancellation(t *testing.T) { socket := filepath.Join(t.TempDir(), "docker.sock") listener, err := net.Listen("unix", socket) if err != nil { t.Fatal(err) } server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1.47/info" { t.Error(r.URL.Path) } io.WriteString(w, `{"ok":true}`) })) server.Listener.Close() server.Listener = listener server.Start() defer server.Close() client := New("unix://" + socket) defer client.http.CloseIdleConnections() var out map[string]bool if err := client.Do(context.Background(), "GET", "/info", nil, &out); err != nil || !out["ok"] { t.Fatalf("%v %v", out, err) } ctx, cancel := context.WithCancel(context.Background()) cancel() transport := client.http.Transport.(*http.Transport) if conn, err := transport.DialContext(ctx, "tcp", "ignored"); err == nil { conn.Close() t.Fatal("unix dial ignored cancellation") } if err := client.Do(ctx, "GET", "/info", nil, nil); !errors.Is(err, context.Canceled) { t.Fatalf("canceled request: %v", err) } } func TestInspect(t *testing.T) { client := testClient(t, func(w http.ResponseWriter, r *http.Request) { if r.URL.EscapedPath() != "/v1.47/containers/a%2Fb/json" { t.Error(r.URL.EscapedPath()) } io.WriteString(w, `{"Id":"abc","Name":"/example","Config":{"Tty":true},"State":{"Running":true,"Status":"running","Health":{"Status":"healthy"}},"NetworkSettings":{"Networks":{"bridge":{"IPAddress":"172.18.0.2"}}}}`) }) container, err := client.Inspect(context.Background(), "a/b") if err != nil { t.Fatal(err) } if container.ID != "abc" || !container.State.Running || !container.Config.Tty || container.State.Health.Status != "healthy" || container.NetworkSettings.Networks["bridge"].IPAddress != "172.18.0.2" { t.Fatalf("%+v", container) } } func frame(stream byte, text string) []byte { data := make([]byte, 8, 8+len(text)) data[0] = stream binary.BigEndian.PutUint32(data[4:], uint32(len(text))) return append(data, text...) } func TestLogs(t *testing.T) { for _, tc := range []struct { name string data []byte want string fail bool }{ {"multiplexed", append(frame(1, "out\n"), frame(2, "err\n")...), "out\nerr\n", false}, {"tty", []byte("plain text\n"), "plain text\n", false}, {"short tty", []byte("ok"), "ok", false}, {"empty", nil, "", false}, {"empty frame", append(frame(1, ""), frame(2, "ok")...), "ok", false}, {"limited frame", frame(1, strings.Repeat("x", maxLogBytes+10)), strings.Repeat("x", maxLogBytes), false}, {"limited tty", bytes.Repeat([]byte("x"), maxLogBytes+10), strings.Repeat("x", maxLogBytes), false}, {"limited multiple frames", append(frame(1, strings.Repeat("x", maxLogBytes-1)), frame(2, "yz")...), strings.Repeat("x", maxLogBytes-1) + "y", false}, {"short header", []byte{1, 0}, "", true}, {"short payload", frame(1, "test")[:10], "te", true}, {"invalid header", []byte{1, 1, 0, 0, 0, 0, 0, 0}, "", true}, } { t.Run(tc.name, func(t *testing.T) { client := testClient(t, func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1.47/containers/example/logs" || r.URL.Query().Get("stdout") != "1" || r.URL.Query().Get("stderr") != "1" || r.URL.Query().Get("follow") != "0" { t.Error(r.URL) } w.Write(tc.data) }) got, err := client.Logs(context.Background(), "example") if (err != nil) != tc.fail || got != tc.want { t.Fatalf("got %d bytes want %d, err=%v", len(got), len(tc.want), err) } }) } } func TestBuild(t *testing.T) { client := testClient(t, func(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" || r.URL.Path != "/v1.47/build" || r.Header.Get("Content-Type") != "application/x-tar" || r.URL.Query().Get("t") != "test:1" { t.Errorf("%s %s %v", r.Method, r.URL, r.Header) } var args map[string]string if err := json.Unmarshal([]byte(r.URL.Query().Get("buildargs")), &args); err != nil || args["DSH_VERSION"] != "1.2.3" { t.Errorf("%v %v", args, err) } body, _ := io.ReadAll(r.Body) if string(body) != "caller tar bytes" { t.Errorf("tar changed: %q", body) } for _, chunk := range []string{`{"stream":"step 1\n"}`, "\r\n\n", `{"au`, `x":{"ID":"sha256:image"}}`} { io.WriteString(w, chunk) w.(http.Flusher).Flush() } }) var logs []string id, err := client.Build(context.Background(), strings.NewReader("caller tar bytes"), "1.2.3", "test:1", func(s string) { logs = append(logs, s) }) if err != nil || id != "sha256:image" || len(logs) != 1 || logs[0] != "step 1\n" { t.Fatalf("id=%q logs=%v err=%v", id, logs, err) } } func TestBuildErrors(t *testing.T) { for _, tc := range []struct{ name, data, want string }{ {"error", `{"error":"build failed"}`, "build failed"}, {"detail", `{"errorDetail":{"message":"detailed failure"}}`, "detailed failure"}, {"both", `{"error":"generic","errorDetail":{"message":"specific"}}`, "specific"}, {"late error", "{\"aux\":{\"ID\":\"partial\"}}\n{\"error\":\"late failure\"}", "late failure"}, {"malformed", `{"stream":`, "docker build event"}, {"too large", strings.Repeat("x", (1<<20)+1), "token too long"}, {"missing ID", `{"stream":"done"}`, "without image ID"}, } { t.Run(tc.name, func(t *testing.T) { client := testClient(t, func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, tc.data) }) id, err := client.Build(context.Background(), nil, "1", "test", nil) if err == nil || !strings.Contains(err.Error(), tc.want) || id != "" { t.Fatalf("id=%q err=%v", id, err) } }) } } func TestBuildCancellation(t *testing.T) { client := testClient(t, func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "{\"stream\":\"started\"}\n") w.(http.Flusher).Flush() <-r.Context().Done() }) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() _, err := client.Build(ctx, nil, "1", "test", func(string) { cancel() }) if !errors.Is(err, context.Canceled) { t.Fatalf("err=%v", err) } }