Files
hamlab/internal/api/docker.go
T
Mitchell-JohnsonandClaude Opus 5 a70f3e2ed2 Container events are Ham Lab's own record, not Docker's short memory
docker events is a live stream with a small in-memory backlog: ask for thirty days and you
get a few minutes, and a daemon restart loses the lot. That is why Dockhand kept its own
copy. A watcher now follows the stream and appends lifecycle events to a ring beside the
config, flushed every thirty seconds, so the question of what happened to a container last
week has an answer. Health-check exec events are excluded — three per probe, thousands a
day, and the health panel says it better.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 19:55:36 -07:00

555 lines
19 KiB
Go

package api
// The Docker tab: what is running, grouped the way it was deployed (compose stacks), with
// start / stop / restart, logs and inspect — plus images, volumes and networks to look at.
// It drives the docker CLI (or podman), as the user first and as root if that is refused.
// Creating, removing and pruning are deliberately absent: whatever deployed the stacks
// (Dockhand, compose) stays the one place that changes what exists.
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"regexp"
"sort"
"strconv"
"strings"
"time"
"hamlab/internal/priv"
)
type dockerContainer struct {
ID string `json:"id"`
Name string `json:"name"`
Image string `json:"image"`
State string `json:"state"` // running | exited | paused | restarting | created | dead
Status string `json:"status"` // "Up 3 hours (healthy)"
Health string `json:"health"` // healthy | unhealthy | starting | none
Ports string `json:"ports"`
Stack string `json:"stack"` // compose project, "" when started by hand
Service string `json:"service"` // compose service
Created string `json:"created"`
Mem float64 `json:"mem"` // bytes, from the container's cgroup; 0 when unknown
}
func dockerTool() string {
for _, t := range []string{"docker", "podman"} {
if _, err := exec.LookPath(t); err == nil {
return t
}
}
return ""
}
// dockerRun runs the tool as this user, and again as root if the socket said no.
func dockerRun(ctx context.Context, args ...string) ([]byte, error) {
tool := dockerTool()
if tool == "" {
return nil, errors.New("neither docker nor podman is installed here")
}
out, err := exec.CommandContext(ctx, tool, args...).CombinedOutput()
if err != nil && priv.Available() && strings.Contains(strings.ToLower(string(out)), "permission denied") {
full := priv.Wrap(append([]string{tool}, args...)...)
out, err = exec.CommandContext(ctx, full[0], full[1:]...).CombinedOutput()
}
if err != nil {
msg := strings.TrimSpace(string(out))
if i := strings.LastIndex(msg, "\n"); i >= 0 {
msg = msg[i+1:]
}
if msg == "" {
msg = err.Error()
}
return out, errors.New(msg)
}
return out, nil
}
func jsonLines(out []byte, each func(map[string]any)) {
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
var m map[string]any
if json.Unmarshal([]byte(line), &m) == nil {
each(m)
}
}
}
func dstr(m map[string]any, k string) string { s, _ := m[k].(string); return s }
func labelOf(labels, key string) string {
for _, kv := range strings.Split(labels, ",") {
if v, ok := strings.CutPrefix(kv, key+"="); ok {
return v
}
}
return ""
}
func (s *Server) dockerOverview(w http.ResponseWriter, r *http.Request) {
if dockerTool() == "" {
jsonOK(w, 200, map[string]any{"available": false, "message": "Docker isn't installed on this machine."})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
out, err := dockerRun(ctx, "ps", "-a", "--no-trunc", "--format", "{{json .}}")
if err != nil {
jsonOK(w, 200, map[string]any{"available": false, "message": err.Error()})
return
}
containers := []dockerContainer{}
jsonLines(out, func(m map[string]any) {
c := dockerContainer{ID: dstr(m, "ID"), Name: strings.Split(dstr(m, "Names"), ",")[0], Image: dstr(m, "Image"), State: dstr(m, "State"), Status: dstr(m, "Status"),
Ports: dstr(m, "Ports"), Created: dstr(m, "CreatedAt"), Health: "none", Stack: labelOf(dstr(m, "Labels"), "com.docker.compose.project"), Service: labelOf(dstr(m, "Labels"), "com.docker.compose.service")}
for _, h := range []string{"unhealthy", "healthy", "starting"} {
if strings.Contains(c.Status, "("+h+")") || strings.Contains(c.Status, "health: "+h) {
c.Health = h
break
}
}
for _, dir := range []string{"/sys/fs/cgroup/system.slice/docker-" + c.ID + ".scope", "/sys/fs/cgroup/docker/" + c.ID, "/sys/fs/cgroup/machine.slice/libpod-" + c.ID + ".scope"} {
if b, err := os.ReadFile(dir + "/memory.current"); err == nil {
c.Mem, _ = strconv.ParseFloat(strings.TrimSpace(string(b)), 64)
break
}
}
containers = append(containers, c)
})
sort.Slice(containers, func(i, j int) bool {
if containers[i].Stack != containers[j].Stack {
return containers[i].Stack < containers[j].Stack
}
return containers[i].Name < containers[j].Name
})
list := func(args ...string) []map[string]any {
rows := []map[string]any{}
if out, err := dockerRun(ctx, args...); err == nil {
jsonLines(out, func(m map[string]any) { rows = append(rows, m) })
}
return rows
}
// Who uses what, from one inspect of every container: an image or a volume nothing uses is
// the only kind this page offers to remove.
imageUsed, volumeUsers := map[string]bool{}, map[string][]string{}
if ids, err := dockerRun(ctx, "ps", "-aq"); err == nil && len(strings.Fields(string(ids))) > 0 {
const f = `{{.Name}}|{{.Image}}|{{range .Mounts}}{{if eq .Type "volume"}}{{.Name}},{{end}}{{end}}`
if out, err := dockerRun(ctx, append([]string{"inspect", "-f", f}, strings.Fields(string(ids))...)...); err == nil {
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
p := strings.SplitN(line, "|", 3)
if len(p) < 3 {
continue
}
if id := strings.TrimPrefix(p[1], "sha256:"); len(id) >= 12 {
imageUsed[id[:12]] = true
}
for _, v := range strings.Split(p[2], ",") {
if v != "" {
volumeUsers[v] = append(volumeUsers[v], strings.TrimPrefix(p[0], "/"))
}
}
}
}
}
images := list("images", "--format", "{{json .}}")
for _, im := range images {
im["in_use"] = imageUsed[dstr(im, "ID")]
}
volumes := list("volume", "ls", "--format", "{{json .}}")
for _, v := range volumes {
users := volumeUsers[dstr(v, "Name")]
if users == nil {
users = []string{}
}
sort.Strings(users)
v["used_by"] = users
v["anonymous"] = strings.Contains(dstr(v, "Labels"), "com.docker.volume.anonymous")
}
jsonOK(w, 200, map[string]any{"available": true, "tool": dockerTool(), "containers": containers,
"images": images, "volumes": volumes, "networks": list("network", "ls", "--format", "{{json .}}")})
}
var dockerRef = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$`)
func (s *Server) dockerAction(w http.ResponseWriter, r *http.Request) {
id, action := r.PathValue("id"), r.PathValue("action")
if !dockerRef.MatchString(id) || (action != "start" && action != "stop" && action != "restart" && action != "remove") {
jsonErr(w, 400, "start, stop, restart or remove a container by name or ID")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second)
defer cancel()
verb := action
if action == "remove" { // never forced: a running container has to be stopped first, on purpose
verb = "rm"
}
if _, err := dockerRun(ctx, verb, id); err != nil {
code := 500
if action == "remove" {
code = 409
}
jsonErr(w, code, err.Error())
return
}
log.Printf("docker: %s %s (from %s)", action, id, clientIP(r))
w.WriteHeader(204)
}
var imageRef = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._/:@-]{0,254}$`)
// imagePull runs `docker pull` as a task the page follows.
func (s *Server) imagePull(w http.ResponseWriter, r *http.Request) {
var body struct{ Image string }
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil || !imageRef.MatchString(strings.TrimSpace(body.Image)) {
jsonErr(w, 400, "an image looks like nginx:alpine or ghcr.io/owner/name:tag")
return
}
ref := strings.TrimSpace(body.Image)
t, err := s.tasks.Start("docker:pull", "pull "+ref, exec.Command(dockerTool(), "pull", ref))
if err != nil {
jsonErr(w, 500, err.Error())
return
}
log.Printf("docker: pull %s as task %s (from %s)", ref, t.ID, clientIP(r))
jsonOK(w, 202, t.Snapshot(false))
}
// imageRemove is `docker rmi`, unforced: an image something still uses is refused by Docker, and that is the answer.
func (s *Server) imageRemove(w http.ResponseWriter, r *http.Request) {
var body struct{ Image string }
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil || !imageRef.MatchString(strings.TrimSpace(body.Image)) {
jsonErr(w, 400, "which image?")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second)
defer cancel()
if _, err := dockerRun(ctx, "rmi", strings.TrimSpace(body.Image)); err != nil {
jsonErr(w, 409, err.Error())
return
}
log.Printf("docker: rmi %s (from %s)", body.Image, clientIP(r))
w.WriteHeader(204)
}
// imagePrune removes dangling images — layers no tag points at any more, left behind by pulls —
// or, asked for {"all": true}, every image no container uses. Those can all be pulled again.
func (s *Server) imagePrune(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
defer cancel()
var body struct{ All bool }
_ = json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body)
args := []string{"image", "prune", "-f"}
if body.All {
args = append(args, "-a")
}
out, err := dockerRun(ctx, args...)
if err != nil {
jsonErr(w, 500, err.Error())
return
}
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
log.Printf("docker: image prune (from %s)", clientIP(r))
jsonOK(w, 200, map[string]string{"result": lines[len(lines)-1]})
}
// dockerLogs is the tail of a container's log; ?since=<RFC3339> asks only for what is new,
// which is how the page follows a log without holding a connection open.
func (s *Server) dockerLogs(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if !dockerRef.MatchString(id) {
jsonErr(w, 400, "bad container")
return
}
args := []string{"logs", "--timestamps"}
if since := r.URL.Query().Get("since"); since != "" {
if _, err := time.Parse(time.RFC3339Nano, since); err != nil {
jsonErr(w, 400, "since is an RFC 3339 time")
return
}
args = append(args, "--since", since)
} else {
n, _ := strconv.Atoi(r.URL.Query().Get("n"))
if n <= 0 || n > 5000 {
n = 300
}
args = append(args, "--tail", strconv.Itoa(n))
}
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
out, err := dockerRun(ctx, append(args, id)...)
if err != nil {
code := 500
if strings.Contains(strings.ToLower(err.Error()), "no such container") {
code = 404
}
jsonErr(w, code, err.Error())
return
}
type line struct {
T string `json:"t"`
M string `json:"m"`
}
lines := []line{}
for _, l := range strings.Split(strings.TrimRight(string(out), "\n"), "\n") {
if l == "" {
continue
}
t, m, ok := strings.Cut(l, " ")
if _, err := time.Parse(time.RFC3339Nano, t); !ok || err != nil {
t, m = "", l
}
lines = append(lines, line{t, m})
}
jsonOK(w, 200, map[string]any{"lines": lines})
}
// dockerInspect is the parts of `docker inspect` worth reading on a phone.
func (s *Server) dockerInspect(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if !dockerRef.MatchString(id) {
jsonErr(w, 400, "bad container")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
out, err := dockerRun(ctx, "inspect", id)
var all []map[string]any
if err != nil || json.Unmarshal(out, &all) != nil || len(all) == 0 {
jsonErr(w, 500, fmt.Sprint("inspect failed: ", err))
return
}
c := all[0]
dig := func(m map[string]any, path ...string) any {
var cur any = m
for _, p := range path {
mm, ok := cur.(map[string]any)
if !ok {
return nil
}
cur = mm[p]
}
return cur
}
env := []string{}
if list, ok := dig(c, "Config", "Env").([]any); ok {
for _, e := range list {
k, _, _ := strings.Cut(fmt.Sprint(e), "=")
env = append(env, k) // names only: values are where the secrets live
}
}
sort.Strings(env)
jsonOK(w, 200, map[string]any{
"image": dig(c, "Config", "Image"), "command": dig(c, "Config", "Cmd"), "entrypoint": dig(c, "Config", "Entrypoint"),
"restart": dig(c, "HostConfig", "RestartPolicy", "Name"), "network_mode": dig(c, "HostConfig", "NetworkMode"),
"memory_limit": dig(c, "HostConfig", "Memory"), "started": dig(c, "State", "StartedAt"), "exit_code": dig(c, "State", "ExitCode"),
"error": dig(c, "State", "Error"), "restarts": c["RestartCount"], "mounts": c["Mounts"], "ports": dig(c, "NetworkSettings", "Ports"),
"networks": dig(c, "NetworkSettings", "Networks"), "env_names": env,
"compose_file": dig(c, "Config", "Labels", "com.docker.compose.project.config_files"),
})
}
var volumeRef = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,254}$`)
// volumeRemove deletes one volume and what is in it, which nothing brings back. So it is never
// forced (Docker refuses a volume a container still uses), it is one at a time, and the request
// has to name the volume twice — a slip of a script can't delete one by accident.
func (s *Server) volumeRemove(w http.ResponseWriter, r *http.Request) {
var body struct{ Name, Confirm string }
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil || !volumeRef.MatchString(body.Name) {
jsonErr(w, 400, "which volume?")
return
}
if body.Confirm != body.Name {
jsonErr(w, 400, "name the volume again in \"confirm\" to delete it")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second)
defer cancel()
if _, err := dockerRun(ctx, "volume", "rm", body.Name); err != nil {
jsonErr(w, 409, err.Error())
return
}
log.Printf("docker: volume rm %s (from %s)", body.Name, clientIP(r))
w.WriteHeader(204)
}
// volumePrune removes unused *anonymous* volumes only: the nameless ones an image's VOLUME line
// leaves behind each time its container is recreated. Named volumes are somebody's data, and a
// stack that is merely down leaves its volumes "unused" — those go one at a time, by name.
func (s *Server) volumePrune(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
defer cancel()
out, err := dockerRun(ctx, "volume", "prune", "-f")
if err != nil {
jsonErr(w, 500, err.Error())
return
}
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
log.Printf("docker: volume prune (from %s)", clientIP(r))
jsonOK(w, 200, map[string]string{"result": lines[len(lines)-1]})
}
// ---- a container's health, its recent life, its console, and its limits ----
// dockerHealth is the health check's verdict and the last few probe results. Docker keeps
// the last five itself, which is all anyone wants: is it failing, and what did it say.
func (s *Server) dockerHealth(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if !dockerRef.MatchString(id) {
jsonErr(w, 400, "bad container")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
out, err := dockerRun(ctx, "inspect", "--format", "{{json .State}}", id)
if err != nil {
jsonErr(w, 500, err.Error())
return
}
var st struct {
Status string `json:"Status"`
Health *struct {
Status string `json:"Status"`
FailingStreak int `json:"FailingStreak"`
Log []struct {
Start time.Time `json:"Start"`
End time.Time `json:"End"`
ExitCode int `json:"ExitCode"`
Output string `json:"Output"`
} `json:"Log"`
} `json:"Health"`
}
if json.Unmarshal(out, &st) != nil {
jsonErr(w, 500, "could not read the container's state")
return
}
if st.Health == nil {
jsonOK(w, 200, map[string]any{"configured": false, "state": st.Status})
return
}
jsonOK(w, 200, map[string]any{
"configured": true, "state": st.Status, "status": st.Health.Status,
"failing_streak": st.Health.FailingStreak, "log": st.Health.Log,
})
}
// dockerExec opens a shell inside a container, as a terminal session like any other — so
// it reattaches, resizes and scrolls back the same way, and works on a managed machine
// through the same proxy.
func (s *Server) dockerExec(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if !dockerRef.MatchString(id) {
jsonErr(w, 400, "bad container")
return
}
tool := dockerTool()
if tool == "" {
jsonErr(w, 500, "neither docker nor podman is installed here")
return
}
var body struct {
Shell string `json:"shell"`
}
_ = json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body)
shell := strings.TrimSpace(body.Shell)
if shell == "" {
// most images have sh; bash is nicer where it exists, so try it first and fall back
shell = "if command -v bash >/dev/null 2>&1; then exec bash; else exec sh; fi"
} else if strings.ContainsAny(shell, "\n\r") {
jsonErr(w, 400, "bad shell")
return
}
argv := []string{tool, "exec", "-it", id, "sh", "-c", shell}
if priv.Available() && !dockerReachable() {
argv = priv.Wrap(argv...)
}
sess, err := s.mgr.CreateCmd("", "sh "+shortName(id), argv)
if err != nil {
jsonErr(w, 500, err.Error())
return
}
log.Printf("docker exec into %s as terminal %s by %s", id, sess.ID, clientIP(r))
jsonOK(w, 200, map[string]string{"id": sess.ID, "name": "sh " + shortName(id)})
}
func shortName(id string) string {
if len(id) > 20 {
return id[:20]
}
return id
}
// dockerReachable reports whether docker answers without help.
func dockerReachable() bool {
tool := dockerTool()
if tool == "" {
return false
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return exec.CommandContext(ctx, tool, "version", "--format", "{{.Server.Version}}").Run() == nil
}
// dockerUpdate changes a running container's limits and restart policy. `docker update`
// does this live, without recreating it and without touching the compose file — which is
// also the catch, and the caller is told: a deploy from compose puts back whatever compose
// says.
func (s *Server) dockerUpdate(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if !dockerRef.MatchString(id) {
jsonErr(w, 400, "bad container")
return
}
var body struct {
Memory string `json:"memory"` // "512m", "2g", or "" to leave alone; "0" removes the limit
CPUs string `json:"cpus"` // "1.5", or "0" for no limit
Restart string `json:"restart"` // no | on-failure | unless-stopped | always
}
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil {
jsonErr(w, 400, "bad request")
return
}
args := []string{"update"}
if m := strings.TrimSpace(body.Memory); m != "" {
if !regexp.MustCompile(`^[0-9]+([bkmgBKMG])?$`).MatchString(m) {
jsonErr(w, 400, "memory: a number, optionally with b, k, m or g — 0 removes the limit")
return
}
args = append(args, "--memory", m, "--memory-swap", m)
}
if c := strings.TrimSpace(body.CPUs); c != "" {
if !regexp.MustCompile(`^[0-9]+(\.[0-9]+)?$`).MatchString(c) {
jsonErr(w, 400, "cpus: a number like 1.5 — 0 removes the limit")
return
}
args = append(args, "--cpus", c)
}
if p := strings.TrimSpace(body.Restart); p != "" {
switch p {
case "no", "on-failure", "unless-stopped", "always":
default:
jsonErr(w, 400, "restart: no, on-failure, unless-stopped or always")
return
}
args = append(args, "--restart", p)
}
if len(args) == 1 {
jsonErr(w, 400, "nothing to change")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
if _, err := dockerRun(ctx, append(args, id)...); err != nil {
jsonErr(w, 500, err.Error())
return
}
log.Printf("docker update %s %v by %s", id, args[1:], clientIP(r))
w.WriteHeader(204)
}