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) { return c.build(ctx, tar, version, tag, nil, onLog) } func (c *Client) BuildRelease(ctx context.Context, tar io.Reader, version, tag, releaseID, repository, commit string, onLog func(string)) (string, error) { return c.build(ctx, tar, version, tag, map[string]string{"dsh-mgr.release": releaseID, "dsh-mgr.source": repository, "dsh-mgr.commit": commit}, onLog) } func (c *Client) build(ctx context.Context, tar io.Reader, version, tag string, labels map[string]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"}} if labels != nil { data, _ := json.Marshal(labels) query.Set("labels", string(data)) } 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 }