Files

243 lines
9.0 KiB
Go

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.BuildRelease(context.Background(), strings.NewReader("caller tar bytes"), "1.2.3", "test:1", "release", "repository", "commit", 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.BuildRelease(context.Background(), nil, "1", "test", "release", "repository", "commit", 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.BuildRelease(ctx, nil, "1", "test", "release", "repository", "commit", func(string) { cancel() })
if !errors.Is(err, context.Canceled) {
t.Fatalf("err=%v", err)
}
}