Files
hamlab/internal/api/docker.go
T
Mitchell-JohnsonandClaude Opus 5 09dc846730 docker: logs follow a held connection, and networks can be removed
Two gaps the Docker tab had against Dockhand and against its own Services
tab next door.

Logs: one `docker logs -f` per viewer over a WebSocket, the shape the
Services tab's journal tail already uses, instead of asking again every two
seconds. stdout and stderr both, with stderr marked so the page can colour
what went wrong. Docker replays the backlog down two pipes in no particular
order, so the opening 300 ms is held and sorted by the stamp Docker wrote;
after that the log is live and in order. A reconnect resumes from the last
line rather than from the top — `--since` is inclusive and two lines can
share a nanosecond, so the client drops what it has already shown by line,
not by "later than". A container that has stopped ends the stream at once,
so reconnects back off to 15 s.

Networks: remove and prune, which images and volumes have had all along.
The overview now says which containers are attached to each network, so the
✕ appears only on one nothing uses, and never on bridge, host or none.
`network rm` prints the real reason and then an aggregate "exit status 1",
so the message shown is the first line, not the last.

Prune is not exercised against `core`: several networks here belong to
stacks that are merely down, and pruning would give them new subnets on
their next deploy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 21:17:37 -07:00

632 lines
22 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.
// What can be removed here is what nothing uses: an untagged image, a volume no container
// holds, a network nothing is attached to. Compose stays the one place a stack is defined.
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.
// DockerTool is whichever of docker or podman this machine has, for callers outside the
// package — the scheduled updates the CLI runs.
func DockerTool() string { return dockerTool() }
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
}
// dockerWhy picks the line worth showing out of a failed command. Some subcommands —
// `network rm` among them — print the real reason first and then an aggregated "exit
// status 1", which is what dockerRun's last-line rule would otherwise hand back.
func dockerWhy(out []byte, err error) string {
for _, l := range strings.Split(string(out), "\n") {
if l = strings.TrimSpace(l); strings.HasPrefix(l, "Error") || strings.Contains(l, "daemon") {
return l
}
}
return err.Error()
}
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, a volume or a network
// nothing uses is the only kind this page offers to remove.
imageUsed := map[string]bool{}
volumeUsers, networkUsers := map[string][]string{}, 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}}|{{range $k, $v := .NetworkSettings.Networks}}{{$k}},{{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, "|", 4)
if len(p) < 4 {
continue
}
name := strings.TrimPrefix(p[0], "/")
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], name)
}
}
for _, n := range strings.Split(p[3], ",") {
if n != "" {
networkUsers[n] = append(networkUsers[n], name)
}
}
}
}
}
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")
}
networks := list("network", "ls", "--format", "{{json .}}")
for _, n := range networks {
users := networkUsers[dstr(n, "Name")]
if users == nil {
users = []string{}
}
sort.Strings(users)
n["used_by"] = users
n["predefined"] = predefinedNetwork[dstr(n, "Name")]
}
jsonOK(w, 200, map[string]any{"available": true, "tool": dockerTool(), "containers": containers,
"images": images, "volumes": volumes, "networks": networks})
}
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]})
}
// The three networks Docker makes itself. They hold nothing, cannot be removed, and
// their absence would break every container that asks for one by name.
var predefinedNetwork = map[string]bool{"bridge": true, "host": true, "none": true}
// networkRemove deletes one network. Unlike a volume there is no data in it, and unlike an
// image nothing has to be downloaded again — a stack that wants it back gets it recreated
// on the next deploy. Docker refuses one a container is still attached to, which is the
// check that matters, so this asks for no second confirmation.
func (s *Server) networkRemove(w http.ResponseWriter, r *http.Request) {
var body struct{ Name string }
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil || !volumeRef.MatchString(body.Name) {
jsonErr(w, 400, "which network?")
return
}
if predefinedNetwork[body.Name] {
jsonErr(w, 400, body.Name+" is one of Docker's own networks and can't be removed")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second)
defer cancel()
if out, err := dockerRun(ctx, "network", "rm", body.Name); err != nil {
jsonErr(w, 409, dockerWhy(out, err))
return
}
log.Printf("docker: network rm %s (from %s)", body.Name, clientIP(r))
w.WriteHeader(204)
}
// networkPrune removes every network no container is attached to. A stack that is merely
// down counts as unattached, so its network goes too — and comes back on the next deploy,
// with a new subnet. That is the whole cost, and the caller is told it.
func (s *Server) networkPrune(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
defer cancel()
out, err := dockerRun(ctx, "network", "prune", "-f")
if err != nil {
jsonErr(w, 500, err.Error())
return
}
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
log.Printf("docker: network 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)
}