A named volume is deleted by typing its name, one at a time; the API wants it named twice; nothing is forced. Prune touches anonymous volumes only. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
397 lines
14 KiB
Go
397 lines
14 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]})
|
|
}
|