CI / build (push) Successful in 40s
Tiles now show the public URL and, when set, the internal URL as a second link. Label names ignore case and - versus _. Containers HODA can't fully use are listed above the tiles with the reason: labels without enable, a missing url, or an unknown label name with a suggestion (category -> group). To find labels without enable, discovery reads all containers. A "Copy labels" button copies every label as a commented template, and falls back to the copy command where the clipboard API is missing (plain http on the LAN). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
154 lines
4.2 KiB
Go
154 lines
4.2 KiB
Go
// Package docker is a small read-only client for the two Docker Engine API
|
|
// calls HODA makes: listing containers and following container events. It
|
|
// stands in for the full SDK so the image carries no third-party code.
|
|
package docker
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Container is the part of a GET /containers/json entry that HODA reads.
|
|
type Container struct {
|
|
ID string `json:"Id"`
|
|
Names []string `json:"Names"`
|
|
Image string `json:"Image"`
|
|
State string `json:"State"`
|
|
Status string `json:"Status"`
|
|
Labels map[string]string `json:"Labels"`
|
|
}
|
|
|
|
// Name is the container name without Docker's leading slash.
|
|
func (c Container) Name() string {
|
|
if len(c.Names) > 0 {
|
|
return strings.TrimPrefix(c.Names[0], "/")
|
|
}
|
|
if len(c.ID) > 12 {
|
|
return c.ID[:12]
|
|
}
|
|
return c.ID
|
|
}
|
|
|
|
// Event is the part of a GET /events message that HODA reads.
|
|
type Event struct {
|
|
Type string `json:"Type"`
|
|
Action string `json:"Action"`
|
|
}
|
|
|
|
// Client talks to one Docker Engine API endpoint.
|
|
type Client struct {
|
|
base string
|
|
api *http.Client // bounded requests
|
|
stream *http.Client // the long-lived events stream
|
|
}
|
|
|
|
// New returns a client for host, which takes the forms DOCKER_HOST does:
|
|
// unix:///var/run/docker.sock, tcp://socket-proxy:2375 or http(s)://host:port.
|
|
func New(host string) (*Client, error) {
|
|
u, err := url.Parse(host)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("DOCKER_HOST %q: %w", host, err)
|
|
}
|
|
transport := &http.Transport{
|
|
MaxIdleConns: 4,
|
|
IdleConnTimeout: 90 * time.Second,
|
|
TLSHandshakeTimeout: 5 * time.Second,
|
|
}
|
|
var base string
|
|
switch u.Scheme {
|
|
case "unix":
|
|
socket := u.Path
|
|
transport.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) {
|
|
var d net.Dialer
|
|
return d.DialContext(ctx, "unix", socket)
|
|
}
|
|
base = "http://docker"
|
|
case "tcp", "http":
|
|
base = "http://" + u.Host
|
|
case "https":
|
|
base = "https://" + u.Host
|
|
default:
|
|
return nil, fmt.Errorf("DOCKER_HOST %q: scheme must be unix, tcp, http or https", host)
|
|
}
|
|
if u.Scheme != "unix" && u.Host == "" {
|
|
return nil, fmt.Errorf("DOCKER_HOST %q: missing host", host)
|
|
}
|
|
return &Client{
|
|
base: base,
|
|
api: &http.Client{Transport: transport, Timeout: 10 * time.Second},
|
|
stream: &http.Client{Transport: transport},
|
|
}, nil
|
|
}
|
|
|
|
// ListContainers returns every container, running or not, that has the label
|
|
// key; an empty key returns them all.
|
|
func (c *Client) ListContainers(ctx context.Context, labelKey string) ([]Container, error) {
|
|
q := url.Values{"all": {"1"}}
|
|
if labelKey != "" {
|
|
q.Set("filters", filters(map[string][]string{"label": {labelKey}}))
|
|
}
|
|
resp, err := c.get(ctx, c.api, "/containers/json?"+q.Encode())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list containers: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
var out []Container
|
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
|
return nil, fmt.Errorf("list containers: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// Events follows events of containers that have the label key and calls fn
|
|
// for each one. It returns when ctx ends or the stream breaks.
|
|
func (c *Client) Events(ctx context.Context, labelKey string, fn func(Event)) error {
|
|
q := url.Values{
|
|
"filters": {filters(map[string][]string{"type": {"container"}, "label": {labelKey}})},
|
|
}
|
|
resp, err := c.get(ctx, c.stream, "/events?"+q.Encode())
|
|
if err != nil {
|
|
return fmt.Errorf("events: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
dec := json.NewDecoder(resp.Body)
|
|
for {
|
|
var ev Event
|
|
if err := dec.Decode(&ev); err != nil {
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
return fmt.Errorf("events: %w", err)
|
|
}
|
|
fn(ev)
|
|
}
|
|
}
|
|
|
|
func (c *Client) get(ctx context.Context, client *http.Client, path string) (*http.Response, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.base+path, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
defer resp.Body.Close()
|
|
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
|
return nil, fmt.Errorf("%s: %s", resp.Status, strings.TrimSpace(string(msg)))
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
func filters(f map[string][]string) string {
|
|
b, _ := json.Marshal(f)
|
|
return string(b)
|
|
}
|