Files
dsh-web/internal/docker/client.go
T

257 lines
6.8 KiB
Go

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
}