Stacks: deploy with every compose file (labels from inspect, not comma-joined ps); refuse a stale overwrite; empty list without Docker. Alerts: firing state survives a restart without re-announcing; vanished alerts are recorded as cleared with the reason; rule values validated; 200 rules. Docker: 404 for a removed container's logs. Skins: four palettes lifted to readable contrast. Phone: bigger toggles and swatches. Installer: deploy republishes; an older published build is never installed silently. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
374 lines
12 KiB
Go
374 lines
12 KiB
Go
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
|
|
// <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"`
|
|
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))
|
|
}
|