180 lines
4.6 KiB
Go
180 lines
4.6 KiB
Go
package beach
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const defaultDockerHost = "/var/run/docker.sock"
|
|
|
|
// Container is one row in the containers table.
|
|
type Container struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Image string `json:"image"`
|
|
State string `json:"state"` // running, exited, ...
|
|
Status string `json:"status"` // "Up 3 hours (healthy)"
|
|
Ports []string `json:"ports"`
|
|
Created int64 `json:"created"`
|
|
}
|
|
|
|
// Docker talks to the Engine API over a unix socket or plain TCP.
|
|
type Docker struct {
|
|
base string
|
|
client *http.Client
|
|
}
|
|
|
|
// NewDocker accepts "/var/run/docker.sock", "unix:///path", "tcp://host:2375" or "host:2375".
|
|
func NewDocker(host string) *Docker {
|
|
host = strings.TrimSpace(host)
|
|
d := &Docker{base: "http://docker"}
|
|
tr := &http.Transport{}
|
|
switch {
|
|
case strings.HasPrefix(host, "tcp://"), strings.HasPrefix(host, "http://"):
|
|
d.base = "http://" + strings.TrimPrefix(strings.TrimPrefix(host, "tcp://"), "http://")
|
|
case strings.HasPrefix(host, "/"), strings.HasPrefix(host, "unix://"):
|
|
path := strings.TrimPrefix(host, "unix://")
|
|
tr.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) {
|
|
return (&net.Dialer{Timeout: 3 * time.Second}).DialContext(ctx, "unix", path)
|
|
}
|
|
default:
|
|
d.base = "http://" + host
|
|
}
|
|
d.client = &http.Client{Transport: tr, Timeout: 30 * time.Second}
|
|
return d
|
|
}
|
|
|
|
func (d *Docker) do(ctx context.Context, method, path string) ([]byte, error) {
|
|
req, err := http.NewRequestWithContext(ctx, method, d.base+path, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := d.client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
|
|
if resp.StatusCode >= 300 && resp.StatusCode != http.StatusNotModified {
|
|
var e struct{ Message string }
|
|
json.Unmarshal(body, &e)
|
|
if e.Message == "" {
|
|
e.Message = resp.Status
|
|
}
|
|
return nil, fmt.Errorf("docker: %s", e.Message)
|
|
}
|
|
return body, nil
|
|
}
|
|
|
|
func (d *Docker) List(ctx context.Context) ([]Container, error) {
|
|
body, err := d.do(ctx, "GET", "/containers/json?all=1")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return parseContainers(body)
|
|
}
|
|
|
|
func parseContainers(body []byte) ([]Container, error) {
|
|
var raw []struct {
|
|
ID string
|
|
Names []string
|
|
Image string
|
|
State string
|
|
Status string
|
|
Created int64
|
|
Ports []struct {
|
|
PrivatePort int
|
|
PublicPort int
|
|
Type string
|
|
}
|
|
}
|
|
if err := json.Unmarshal(body, &raw); err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]Container, 0, len(raw))
|
|
for _, r := range raw {
|
|
c := Container{ID: r.ID, Image: r.Image, State: r.State, Status: r.Status, Created: r.Created, Ports: []string{}}
|
|
if len(r.Names) > 0 {
|
|
c.Name = strings.TrimPrefix(r.Names[0], "/")
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, p := range r.Ports {
|
|
if p.PublicPort == 0 {
|
|
continue
|
|
}
|
|
s := fmt.Sprintf("%d:%d/%s", p.PublicPort, p.PrivatePort, p.Type)
|
|
if !seen[s] { // IPv4 and IPv6 bindings repeat
|
|
seen[s] = true
|
|
c.Ports = append(c.Ports, s)
|
|
}
|
|
}
|
|
out = append(out, c)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
if (out[i].State == "running") != (out[j].State == "running") {
|
|
return out[i].State == "running"
|
|
}
|
|
return out[i].Name < out[j].Name
|
|
})
|
|
return out, nil
|
|
}
|
|
|
|
// Action is one of start, stop, restart.
|
|
func (d *Docker) Action(ctx context.Context, id, action string) error {
|
|
switch action {
|
|
case "start", "stop", "restart":
|
|
default:
|
|
return fmt.Errorf("unknown action %q", action)
|
|
}
|
|
_, err := d.do(ctx, "POST", "/containers/"+url.PathEscape(id)+"/"+action)
|
|
return err
|
|
}
|
|
|
|
func (d *Docker) Logs(ctx context.Context, id string, tail int) (string, error) {
|
|
if tail < 1 || tail > 5000 {
|
|
tail = 200
|
|
}
|
|
// the multiplexed format is only used for containers without a TTY
|
|
var info struct{ Config struct{ Tty bool } }
|
|
if b, err := d.do(ctx, "GET", "/containers/"+url.PathEscape(id)+"/json"); err == nil {
|
|
json.Unmarshal(b, &info)
|
|
}
|
|
body, err := d.do(ctx, "GET", fmt.Sprintf("/containers/%s/logs?stdout=1&stderr=1×tamps=1&tail=%d", url.PathEscape(id), tail))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if info.Config.Tty {
|
|
return string(body), nil
|
|
}
|
|
return demux(body), nil
|
|
}
|
|
|
|
// demux strips the 8-byte stream headers Docker puts in front of each log frame.
|
|
func demux(b []byte) string {
|
|
var out bytes.Buffer
|
|
for len(b) >= 8 {
|
|
if b[0] > 2 || b[1] != 0 || b[2] != 0 || b[3] != 0 { // not framed; treat as plain text
|
|
out.Write(b)
|
|
return out.String()
|
|
}
|
|
n := int(binary.BigEndian.Uint32(b[4:8]))
|
|
b = b[8:]
|
|
if n > len(b) {
|
|
n = len(b)
|
|
}
|
|
out.Write(b[:n])
|
|
b = b[n:]
|
|
}
|
|
return out.String()
|
|
}
|