CI / build (push) Successful in 30s
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>
153 lines
4.2 KiB
Go
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)
|
|
}
|