Files
hamlab/internal/api/docker.go
T
Mitchell-JohnsonandClaude Fable 5.1 c017b422d5 Docker: new stacks and containers, stack down/delete, container remove, image pull/remove/prune
A new stack is a folder in a home (where stacks are already kept: Dockhand's group here,
~/stacks elsewhere) with compose.yaml and an optional root-only .env, validated by compose
before it is kept and deployed the way its neighbours were — inside dockhand, or to a node
over tcp from the hub. New container is the same dialog on a form that writes the compose.
Homes are scanned, so undeployed stacks stay listed. Delete moves a stack's files to
.hamlab-trash; nothing is forced, and volumes and data are never touched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 07:50:43 -07:00

315 lines
11 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
}
jsonOK(w, 200, map[string]any{"available": true, "tool": dockerTool(), "containers": containers,
"images": list("images", "--format", "{{json .}}"),
"volumes": list("volume", "ls", "--format", "{{json .}}"),
"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 only — layers no tag points at any more, left behind by pulls.
func (s *Server) imagePrune(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
defer cancel()
out, err := dockerRun(ctx, "image", "prune", "-f")
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"),
})
}