Initial commit: DSH Web Manager
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
apiVersion = "/v1.47"
|
||||
maxErrorBytes = 64 << 10
|
||||
maxLogBytes = 1 << 20
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
type APIError struct {
|
||||
StatusCode int
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
return fmt.Sprintf("docker API: HTTP %d: %s", e.StatusCode, e.Message)
|
||||
}
|
||||
|
||||
func New(host string) *Client {
|
||||
if host == "" {
|
||||
host = "unix:///var/run/docker.sock"
|
||||
}
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
if strings.HasPrefix(host, "unix://") {
|
||||
socket := strings.TrimPrefix(host, "unix://")
|
||||
transport.Proxy = nil
|
||||
transport.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, "unix", socket)
|
||||
}
|
||||
host = "http://docker"
|
||||
} else if strings.HasPrefix(host, "tcp://") {
|
||||
host = "http://" + strings.TrimPrefix(host, "tcp://")
|
||||
}
|
||||
return &Client{baseURL: strings.TrimRight(host, "/"), http: &http.Client{
|
||||
Transport: transport,
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse },
|
||||
}}
|
||||
}
|
||||
|
||||
// Stream transfers ownership of a successful response body to the caller.
|
||||
func (c *Client) Stream(ctx context.Context, method, path string, body io.Reader, contentType string) (*http.Response, error) {
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
if path != apiVersion && !strings.HasPrefix(path, apiVersion+"/") && !strings.HasPrefix(path, apiVersion+"?") {
|
||||
path = apiVersion + path
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return resp, nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBytes))
|
||||
message := strings.TrimSpace(string(data))
|
||||
var detail struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if json.Unmarshal(data, &detail) == nil && detail.Message != "" {
|
||||
message = detail.Message
|
||||
}
|
||||
if message == "" {
|
||||
message = resp.Status
|
||||
}
|
||||
return nil, &APIError{StatusCode: resp.StatusCode, Message: message}
|
||||
}
|
||||
|
||||
func (c *Client) Do(ctx context.Context, method, path string, body any, out any) error {
|
||||
var input io.Reader
|
||||
if body != nil {
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
input = bytes.NewReader(data)
|
||||
}
|
||||
resp, err := c.Stream(ctx, method, path, input, "application/json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if out == nil || resp.StatusCode == http.StatusNoContent {
|
||||
return nil
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
|
||||
type Container struct {
|
||||
ID string `json:"Id"`
|
||||
Name string
|
||||
Image string
|
||||
Config struct {
|
||||
Image string
|
||||
Tty bool
|
||||
Labels map[string]string
|
||||
Env []string
|
||||
}
|
||||
State struct {
|
||||
Status string
|
||||
Running bool
|
||||
Paused bool
|
||||
Restarting bool
|
||||
ExitCode int
|
||||
Error string
|
||||
Health *struct{ Status string }
|
||||
}
|
||||
NetworkSettings struct {
|
||||
IPAddress string
|
||||
Networks map[string]struct {
|
||||
IPAddress string
|
||||
Gateway string
|
||||
NetworkID string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Inspect(ctx context.Context, id string) (*Container, error) {
|
||||
var container Container
|
||||
if err := c.Do(ctx, http.MethodGet, "/containers/"+url.PathEscape(id)+"/json", nil, &container); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &container, nil
|
||||
}
|
||||
|
||||
func (c *Client) Logs(ctx context.Context, id string) (string, error) {
|
||||
resp, err := c.Stream(ctx, http.MethodGet, "/containers/"+url.PathEscape(id)+"/logs?stdout=1&stderr=1&follow=0", nil, "")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
reader := bufio.NewReader(resp.Body)
|
||||
header, err := reader.Peek(8)
|
||||
if err != nil && err != io.EOF {
|
||||
return "", err
|
||||
}
|
||||
if len(header) == 0 || header[0] > 2 {
|
||||
data, err := io.ReadAll(io.LimitReader(reader, maxLogBytes))
|
||||
return string(data), err
|
||||
}
|
||||
var output bytes.Buffer
|
||||
for output.Len() < maxLogBytes {
|
||||
var frame [8]byte
|
||||
_, err := io.ReadFull(reader, frame[:])
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return output.String(), fmt.Errorf("docker logs header: %w", err)
|
||||
}
|
||||
if frame[0] > 2 || frame[1] != 0 || frame[2] != 0 || frame[3] != 0 {
|
||||
return output.String(), fmt.Errorf("docker logs: invalid stream header")
|
||||
}
|
||||
size := int64(binary.BigEndian.Uint32(frame[4:]))
|
||||
remaining := int64(maxLogBytes - output.Len())
|
||||
if size > remaining {
|
||||
size = remaining
|
||||
}
|
||||
if _, err := io.CopyN(&output, reader, size); err != nil {
|
||||
return output.String(), fmt.Errorf("docker logs payload: %w", err)
|
||||
}
|
||||
}
|
||||
return output.String(), nil
|
||||
}
|
||||
|
||||
func (c *Client) Build(ctx context.Context, tar io.Reader, version, tag string, onLog func(string)) (string, error) {
|
||||
args, err := json.Marshal(map[string]string{"DSH_VERSION": version})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
query := url.Values{"t": {tag}, "buildargs": {string(args)}, "rm": {"1"}}
|
||||
resp, err := c.Stream(ctx, http.MethodPost, "/build?"+query.Encode(), tar, "application/x-tar")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
scanner.Buffer(make([]byte, 4096), 1<<20)
|
||||
var imageID string
|
||||
for scanner.Scan() {
|
||||
line := bytes.TrimSpace(scanner.Bytes())
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
var event struct {
|
||||
Stream string `json:"stream"`
|
||||
Error string `json:"error"`
|
||||
ErrorDetail struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"errorDetail"`
|
||||
Aux struct {
|
||||
ID string `json:"ID"`
|
||||
} `json:"aux"`
|
||||
}
|
||||
if err := json.Unmarshal(line, &event); err != nil {
|
||||
return "", fmt.Errorf("docker build event: %w", err)
|
||||
}
|
||||
if event.ErrorDetail.Message != "" {
|
||||
return "", fmt.Errorf("docker build: %s", event.ErrorDetail.Message)
|
||||
}
|
||||
if event.Error != "" {
|
||||
return "", fmt.Errorf("docker build: %s", event.Error)
|
||||
}
|
||||
if event.Stream != "" && onLog != nil {
|
||||
onLog(event.Stream)
|
||||
}
|
||||
if event.Aux.ID != "" {
|
||||
imageID = event.Aux.ID
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return "", fmt.Errorf("docker build stream: %w", err)
|
||||
}
|
||||
if imageID == "" {
|
||||
return "", fmt.Errorf("docker build: response ended without image ID")
|
||||
}
|
||||
return imageID, nil
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user