Docker: a Stacks section — edit a compose file, validate, deploy; fix the sideways scroll on phones

Stacks are found from container labels; the file is resolved on the host or through an
orchestrator's mounts, and compose runs where the file makes sense (inside Dockhand here),
so a redeploy is identical to how the stack was deployed. Save validates with compose and
keeps a .hamlab-bak; Deploy / Update / Restart / Stop run as followed tasks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-19 15:23:50 -07:00
co-authored by Claude Fable 5.1
parent 08fb9d6e38
commit 9c4f9dfa9f
6 changed files with 377 additions and 17 deletions
+4
View File
@@ -129,6 +129,10 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
m.HandleFunc("POST /api/nodes/{name}/sensors/query", s.sensorAuth(s.proxyNodeSensors))
m.HandleFunc("POST /api/tokens/read", s.auth(s.issueReadToken))
m.HandleFunc("GET /api/docker", s.auth(s.dockerOverview))
m.HandleFunc("GET /api/docker/stacks", s.auth(s.stacksList))
m.HandleFunc("GET /api/docker/stacks/{name}", s.auth(s.stackFile))
m.HandleFunc("PUT /api/docker/stacks/{name}", s.auth(s.stackSave))
m.HandleFunc("POST /api/docker/stacks/{name}/{action}", s.auth(s.stackAction))
m.HandleFunc("POST /api/docker/containers/{id}/{action}", s.auth(s.dockerAction))
m.HandleFunc("GET /api/docker/containers/{id}/logs", s.auth(s.dockerLogs))
m.HandleFunc("GET /api/docker/containers/{id}/inspect", s.auth(s.dockerInspect))
+287
View File
@@ -0,0 +1,287 @@
package api
// Stacks: the compose projects behind the containers, with their files to edit and a way to
// redeploy them.
//
// A stack is found from its containers' labels (project, config file). The file may not be
// where the label says: an orchestrator that runs compose from inside its own container —
// Dockhand, Dockge, Portainer — records a path in *its* filesystem. So the path is looked
// up twice: as it stands on this host, and through every container's mounts
// (/app/data/stacks/… in `dockhand` is /home/core/apps/dockhand/stacks/… here). Compose is
// then run where the file makes sense — inside that container when it came from one, so
// relative paths, the .env beside it and the labels all stay exactly as they were deployed —
// else on the host. A stack whose file is on no path this machine can see (deployed from
// another host over tcp) is listed read-only, and says so.
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
"hamlab/internal/priv"
)
type stack struct {
Name string `json:"name"`
Containers []string `json:"containers"`
Running int `json:"running"`
File string `json:"file"` // as the label has it
HostFile string `json:"host_file"` // where it is on this machine; "" when it isn't
Via string `json:"via"` // container compose runs inside; "" = on the host
Editable bool `json:"editable"`
Note string `json:"note,omitempty"`
HasEnv bool `json:"has_env"`
}
type mountMap struct{ container, dest, src string }
func stackMounts(ctx context.Context) []mountMap {
out, err := dockerRun(ctx, "ps", "-q")
if err != nil || len(bytes.TrimSpace(out)) == 0 {
return nil
}
insp, err := dockerRun(ctx, append([]string{"inspect"}, strings.Fields(string(out))...)...)
if err != nil {
return nil
}
var list []struct {
Name string
Mounts []struct{ Type, Source, Destination string }
}
if json.Unmarshal(insp, &list) != nil {
return nil
}
var maps []mountMap
for _, c := range list {
for _, m := range c.Mounts {
if m.Type == "bind" && m.Destination != "/" && m.Source != "" {
maps = append(maps, mountMap{strings.TrimPrefix(c.Name, "/"), filepath.Clean(m.Destination), filepath.Clean(m.Source)})
}
}
}
sort.Slice(maps, func(i, j int) bool { return len(maps[i].dest) > len(maps[j].dest) }) // the most specific mount wins
return maps
}
func readable(p string) bool {
if _, err := os.Stat(p); err == nil {
return true
}
if priv.Available() { // stacks kept by a root-owned orchestrator are often unreadable to us
full := priv.Wrap("test", "-f", p)
return exec.Command(full[0], full[1:]...).Run() == nil
}
return false
}
func (s *Server) findStacks(ctx context.Context) ([]stack, error) {
out, err := dockerRun(ctx, "ps", "-a", "--no-trunc", "--format", "{{json .}}")
if err != nil {
return nil, err
}
by := map[string]*stack{}
jsonLines(out, func(m map[string]any) {
labels := dstr(m, "Labels")
name := labelOf(labels, "com.docker.compose.project")
if name == "" {
return
}
st := by[name]
if st == nil {
st = &stack{Name: name, File: strings.Split(labelOf(labels, "com.docker.compose.project.config_files"), ",")[0]}
by[name] = st
}
st.Containers = append(st.Containers, strings.Split(dstr(m, "Names"), ",")[0])
if dstr(m, "State") == "running" {
st.Running++
}
})
maps := stackMounts(ctx)
stacks := []stack{}
for _, st := range by {
sort.Strings(st.Containers)
switch {
case st.File == "":
st.Note = "its containers don't say which file deployed them"
case readable(st.File):
st.HostFile = st.File
default:
for _, m := range maps {
if rest, ok := strings.CutPrefix(st.File, m.dest+"/"); ok && readable(filepath.Join(m.src, rest)) {
st.HostFile, st.Via = filepath.Join(m.src, rest), m.container
break
}
}
if st.HostFile == "" {
st.Note = "deployed from somewhere this machine can't see (" + st.File + "); edit it there"
}
}
if st.HostFile != "" {
st.Editable = true
st.HasEnv = readable(filepath.Join(filepath.Dir(st.HostFile), ".env"))
}
stacks = append(stacks, *st)
}
sort.Slice(stacks, func(i, j int) bool { return stacks[i].Name < stacks[j].Name })
return stacks, nil
}
func (s *Server) stackByName(ctx context.Context, name string) (stack, error) {
all, err := s.findStacks(ctx)
if err != nil {
return stack{}, err
}
for _, st := range all {
if st.Name == name {
return st, nil
}
}
return stack{}, errors.New("no such stack")
}
func (s *Server) stacksList(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
stacks, err := s.findStacks(ctx)
if err != nil {
jsonErr(w, 500, err.Error())
return
}
jsonOK(w, 200, map[string]any{"stacks": stacks})
}
func (s *Server) stackFile(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
st, err := s.stackByName(ctx, r.PathValue("name"))
if err != nil || !st.Editable {
jsonErr(w, 404, "that stack's compose file isn't on this machine")
return
}
b, err := os.ReadFile(st.HostFile)
if err != nil && priv.Available() {
full := priv.Wrap("cat", st.HostFile)
b, err = exec.CommandContext(ctx, full[0], full[1:]...).Output()
}
if err != nil {
jsonErr(w, 500, err.Error())
return
}
jsonOK(w, 200, map[string]any{"stack": st, "text": string(b)})
}
// composeCmd is `docker compose …` for this stack, run where its file makes sense.
func composeCmd(ctx context.Context, st stack, file string, args ...string) *exec.Cmd {
tool := dockerTool()
if st.Via != "" {
return exec.CommandContext(ctx, tool, append([]string{"exec", st.Via, "docker", "compose", "-p", st.Name, "-f", file}, args...)...)
}
cmd := exec.CommandContext(ctx, tool, append([]string{"compose", "-p", st.Name, "-f", file}, args...)...)
cmd.Dir = filepath.Dir(file)
return cmd
}
// stackSave validates the new text with compose itself, keeps the old file beside it as
// <file>.hamlab-bak, and writes. It does not deploy: that is its own button.
func (s *Server) stackSave(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
defer cancel()
st, err := s.stackByName(ctx, r.PathValue("name"))
if err != nil || !st.Editable {
jsonErr(w, 404, "that stack's compose file isn't on this machine")
return
}
var body struct {
Text string `json:"text"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil || strings.TrimSpace(body.Text) == "" {
jsonErr(w, 400, "the file can't be empty")
return
}
// Validate a candidate beside the real file, so `.env` and relative paths resolve as they will.
cand := st.HostFile + ".hamlab-new"
err = s.fsLocal.Write(ctx, cand, strings.NewReader(body.Text)) // ours to write, or root's
if err != nil {
err = s.fsSudo.Write(ctx, cand, strings.NewReader(body.Text))
}
if err != nil {
jsonErr(w, 500, "couldn't write beside the compose file: "+err.Error())
return
}
inner := cand
if st.Via != "" {
inner = st.File + ".hamlab-new"
}
if msg, err := composeCmd(ctx, st, inner, "config", "-q").CombinedOutput(); err != nil {
_ = s.fsSudo.Delete(ctx, cand, false)
jsonErr(w, 400, "compose rejects it: "+strings.TrimSpace(string(msg)))
return
}
for _, mv := range [][]string{{"cp", "-p", st.HostFile, st.HostFile + ".hamlab-bak"}, {"mv", cand, st.HostFile}} {
full := priv.Wrap(mv...)
if msg, err := exec.CommandContext(ctx, full[0], full[1:]...).CombinedOutput(); err != nil {
jsonErr(w, 500, strings.TrimSpace(string(msg)))
return
}
}
log.Printf("stack %s: compose file saved (from %s)", st.Name, clientIP(r))
w.WriteHeader(204)
}
// stackAction runs deploy (up -d), update (pull, then up -d), restart or stop as a task
// whose output the page follows.
func (s *Server) stackAction(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
st, err := s.stackByName(ctx, r.PathValue("name"))
if err != nil || !st.Editable {
jsonErr(w, 404, "that stack's compose file isn't on this machine")
return
}
file := st.HostFile
if st.Via != "" {
file = st.File
}
var script string
base := func(args string) string {
if st.Via != "" {
return fmt.Sprintf("%s exec %s docker compose -p %s -f %s %s", dockerTool(), priv.Quote([]string{st.Via}), priv.Quote([]string{st.Name}), priv.Quote([]string{file}), args)
}
return fmt.Sprintf("cd %s && %s compose -p %s -f %s %s", priv.Quote([]string{filepath.Dir(file)}), dockerTool(), priv.Quote([]string{st.Name}), priv.Quote([]string{file}), args)
}
switch r.PathValue("action") {
case "deploy":
script = base("up -d --remove-orphans")
case "update":
script = base("pull") + " && " + base("up -d --remove-orphans")
case "restart":
script = base("restart")
case "stop":
script = base("stop")
default:
jsonErr(w, 400, "deploy, update, restart or stop")
return
}
if t, busy := s.tasks.RunningOf("stack:" + st.Name); busy {
jsonErr(w, 409, "this stack is already busy (task "+t.ID+")")
return
}
t, err := s.tasks.Start("stack:"+st.Name, r.PathValue("action")+" "+st.Name, exec.Command("sh", "-c", script+" 2>&1"))
if err != nil {
jsonErr(w, 500, err.Error())
return
}
log.Printf("stack %s: %s started as task %s (from %s)", st.Name, r.PathValue("action"), t.ID, clientIP(r))
jsonOK(w, 202, t.Snapshot(false))
}