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" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "log" "net/http" "os" "os/exec" "path/filepath" "sort" "strconv" "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 (the first of Files) HostFile string `json:"host_file"` // where it is on this machine; "" when it isn't Files []string `json:"files"` // every file it was deployed from, in order: -f a -f b HostFiles []string `json:"host_files"` // the same, on this machine 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) { // Labels come from inspect, not `ps`: ps joins every label with commas, and the label that // matters here — the list of compose files — is itself a comma-separated list. ids, err := dockerRun(ctx, "ps", "-aq") if err != nil { return nil, err } by := map[string]*stack{} if len(bytes.TrimSpace(ids)) > 0 { const f = `{{.Name}}|{{.State.Status}}|{{index .Config.Labels "com.docker.compose.project"}}|{{index .Config.Labels "com.docker.compose.project.config_files"}}` out, err := dockerRun(ctx, append([]string{"inspect", "-f", f}, strings.Fields(string(ids))...)...) if err != nil { return nil, err } for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { p := strings.SplitN(line, "|", 4) if len(p) < 4 || p[2] == "" { continue } st := by[p[2]] if st == nil { st = &stack{Name: p[2]} if p[3] != "" { st.Files = strings.Split(p[3], ",") st.File = st.Files[0] } by[p[2]] = st } st.Containers = append(st.Containers, strings.TrimPrefix(p[0], "/")) if p[1] == "running" { st.Running++ } } } maps := stackMounts(ctx) stacks := []stack{} for _, st := range by { sort.Strings(st.Containers) // Every file must be found — a stack deployed from `-f compose.yaml -f override.yaml` and // redeployed from the first alone would quietly lose everything the override set. resolve := func(f string) (host, via string) { if readable(f) { return f, "" } for _, m := range maps { if rest, ok := strings.CutPrefix(f, m.dest+"/"); ok && readable(filepath.Join(m.src, rest)) { return filepath.Join(m.src, rest), m.container } } return "", "" } if st.File == "" { st.Note = "its containers don't say which file deployed them" } else { for i, f := range st.Files { host, via := resolve(f) if host == "" || (i > 0 && via != st.Via) { st.HostFiles, st.Via = nil, "" st.Note = "deployed from somewhere this machine can't see (" + f + "); edit it there" break } st.HostFiles, st.Via = append(st.HostFiles, host), via } if len(st.HostFiles) > 0 { st.HostFile = st.HostFiles[0] } } 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() if dockerTool() == "" { // no Docker is an empty answer, not a failure jsonOK(w, 200, map[string]any{"stacks": []stack{}}) return } stacks, err := s.findStacks(ctx) if err != nil { jsonErr(w, 500, err.Error()) return } jsonOK(w, 200, map[string]any{"stacks": stacks}) } func readAsRoot(ctx context.Context, path string) ([]byte, error) { b, err := os.ReadFile(path) if err != nil && priv.Available() { full := priv.Wrap("cat", path) b, err = exec.CommandContext(ctx, full[0], full[1:]...).Output() } return b, err } func sumOf(b []byte) string { h := sha256.Sum256(b); return hex.EncodeToString(h[:8]) } // fileIndex is which of the stack's files a request means: ?file=1 is the first override. func fileIndex(r *http.Request, st stack) (int, bool) { i, err := strconv.Atoi(r.URL.Query().Get("file")) if r.URL.Query().Get("file") == "" { return 0, true } return i, err == nil && i >= 0 && i < len(st.HostFiles) } 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 } i, ok := fileIndex(r, st) if !ok { jsonErr(w, 400, "no such file in this stack") return } b, err := readAsRoot(ctx, st.HostFiles[i]) if err != nil { jsonErr(w, 500, err.Error()) return } // sum is what the editor hands back when it saves, so a file changed meanwhile is noticed jsonOK(w, 200, map[string]any{"stack": st, "text": string(b), "sum": sumOf(b), "index": i}) } // composeCmd is `docker compose …` for this stack, run where its file makes sense. // files are given as compose will see them: the label's paths inside a container, host paths otherwise. func composeCmd(ctx context.Context, st stack, files []string, args ...string) *exec.Cmd { tool := dockerTool() base := []string{"compose", "-p", st.Name} for _, f := range files { base = append(base, "-f", f) } if st.Via != "" { return exec.CommandContext(ctx, tool, append(append([]string{"exec", st.Via, "docker"}, base...), args...)...) } cmd := exec.CommandContext(ctx, tool, append(base, args...)...) cmd.Dir = filepath.Dir(files[0]) return cmd } // seenFiles is the stack's file list as compose runs it, with file i replaced by a candidate. func seenFiles(st stack, i int, candidate string) []string { out := append([]string{}, st.HostFiles...) if st.Via != "" { out = append([]string{}, st.Files...) } if i >= 0 { out[i] = candidate } return out } // stackSave validates the new text with compose itself, keeps the old file beside it as // .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"` Sum string `json:"sum"` // the sum the editor was given when it opened the file } 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 } i, ok := fileIndex(r, st) if !ok { jsonErr(w, 400, "no such file in this stack") return } target := st.HostFiles[i] if now, err := readAsRoot(ctx, target); err == nil && body.Sum != "" && sumOf(now) != body.Sum { jsonErr(w, 409, "the file has changed on disk since you opened it (Dockhand, or another editor). Reload it to see the change; your text is still in the box.") return } // Validate a candidate beside the real file, so `.env` and relative paths resolve as they will. cand := target + ".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.Files[i] + ".hamlab-new" } if msg, err := composeCmd(ctx, st, seenFiles(st, i, 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", target, target + ".hamlab-bak"}, {"mv", cand, target}} { 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 } files := seenFiles(st, -1, "") var fl []string for _, f := range files { fl = append(fl, "-f", f) } flags := priv.Quote(append([]string{"-p", st.Name}, fl...)) var script string base := func(args string) string { if st.Via != "" { return fmt.Sprintf("%s exec %s docker compose %s %s", dockerTool(), priv.Quote([]string{st.Via}), flags, args) } return fmt.Sprintf("cd %s && %s compose %s %s", priv.Quote([]string{filepath.Dir(files[0])}), dockerTool(), flags, 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)) }