Dockhand is being retired, so the Docker tab has to cover what it actually earned its keep doing. Eight pieces, all per container or per machine: - Stats: CPU, memory and IO over time, from sensors already collected. - Health: the container's own healthcheck, with the last few probe outputs — which is where a failing check says why. - Events: `docker events` keeps a minute of backlog and nothing more, so Ham Lab follows the stream and keeps a ring of its own. A restart at 4am is still there at 9. - Shell: `docker exec` into a container, in the terminal already here. - Limits: memory, CPU and restart policy changed live, without hand-editing the compose file for a number you want to try. - Updates: what the registry has that this machine does not, for every image at once rather than one stack at a time. - The same across every machine, asked of the hub — which is the only thing that can reach them all. - Schedules: a real line in /etc/cron.d/hamlab-docker calling the binary, in the same shape as the ZFS snapshot schedules. Dockhand updated DNS and ingress nightly from a ticker inside a web app; a cron line keeps running when the console does not, and is visible to anyone reading the machine the normal way. internal/compose is new and shared, so a deploy from the console and a deploy from cron invoke compose identically — including the case where compose has to run inside the orchestrator's own container, where the paths are that container's and not the host's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
559 lines
19 KiB
Go
559 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.
|
|
// 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
|
|
}
|
|
|
|
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)
|
|
}
|