Files
HODA/internal/docker/client.go
T
TMCandClaude Opus 5.5 9d6a921c34
CI / build (push) Successful in 30s
Start HODA: a homelab dashboard built from Docker labels
Apps opt in with sema.dashboard.* labels in their own compose file. The
primary reads its host's containers through a read-only socket proxy,
follows Docker events, and merges in what the agent on the second host
reports. Icons come from the icon label, the dashboard-icons set or the
app's favicon, with a letter as the fallback.

Go with only the standard library, a distroless image, and a Gitea
Actions workflow copied from FiMa's: checks in the Dockerfile's check
stage, a Trivy scan, and a push to git.mariosem.es/msemes/hoda.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 21:18:20 +01:00

153 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.
func (c *Client) ListContainers(ctx context.Context, labelKey string) ([]Container, error) {
q := url.Values{
"all": {"1"},
"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)
}