Stacks: Duplicate, Labels and Network in the form, task verdict badges, a short Where; Cancel really cancels

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-20 13:12:54 -07:00
co-authored by Claude Fable 5.1
parent a229f2b597
commit 1e992ca1eb
8 changed files with 183 additions and 36 deletions
+32 -1
View File
@@ -753,6 +753,16 @@ func (s *Server) stackAction(w http.ResponseWriter, r *http.Request) {
jsonErr(w, 500, err.Error())
return
}
if st.Via != "" {
// compose runs inside st.Via, started by `docker exec`: stopping the exec client does not
// stop what it started in there, so a cancel has to go in after it.
via, pattern := st.Via, "compose -p "+regexp.QuoteMeta(st.Name)+" -f "
t.OnCancel(func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = exec.CommandContext(ctx, dockerTool(), "exec", via, "pkill", "-TERM", "-f", pattern).Run()
})
}
stackUpdatesForget(r.PathValue("node") + "/" + st.Name)
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))
@@ -929,7 +939,10 @@ func (s *Server) stackCreate(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
defer cancel()
raw, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
var body struct{ Name, Text, Env, Home string }
var body struct {
Name, Text, Env, Home string
EnvFrom string `json:"env_from"` // a stack being duplicated: its .env is copied here, on the machine, unseen
}
if err != nil || json.Unmarshal(raw, &body) != nil {
jsonErr(w, 400, "bad request")
return
@@ -975,6 +988,24 @@ func (s *Server) stackCreate(w http.ResponseWriter, r *http.Request) {
return
}
}
if body.EnvFrom != "" && strings.TrimSpace(body.Env) == "" {
var from *stack
for i := range all {
if all[i].Name == body.EnvFrom && all[i].Editable && all[i].HasEnv {
from = &all[i]
}
}
if from == nil {
jsonErr(w, 400, body.EnvFrom+" has no .env this console can read")
return
}
b, err := readAsRoot(ctx, from.envPath())
if err != nil {
jsonErr(w, 500, "reading "+body.EnvFrom+"'s .env: "+err.Error())
return
}
body.Env = string(b)
}
dir := filepath.Join(home.Dir, name)
if readable(dir) || exec.Command("test", "-e", dir).Run() == nil {
jsonErr(w, 409, dir+" already exists")
+37 -6
View File
@@ -12,6 +12,7 @@ import (
"os/exec"
"sort"
"sync"
"syscall"
"time"
)
@@ -31,10 +32,11 @@ type Task struct {
ExitCode int
Err string
mu sync.Mutex
buf bytes.Buffer
subs map[chan []byte]struct{}
cancel context.CancelFunc
mu sync.Mutex
buf bytes.Buffer
subs map[chan []byte]struct{}
cancel context.CancelFunc
onCancel func()
}
type Snapshot struct {
@@ -71,6 +73,15 @@ func (m *Manager) Start(kind, title string, cmd *exec.Cmd) (*Task, error) {
subs: map[chan []byte]struct{}{}, cancel: cancel}
w := &writer{t: t}
cmd.Stdout, cmd.Stderr = w, w
// Its own process group: a task is usually `sh -c "a && b"`, and cancelling must reach a and
// b, not just the shell — killing only the shell left compose running and, because compose
// still held the output pipe, left the task "running" too. WaitDelay is the backstop for
// anything that escapes the group and keeps the pipe open.
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.SysProcAttr.Setpgid = true
cmd.WaitDelay = 5 * time.Second
if err := cmd.Start(); err != nil {
cancel()
return nil, err
@@ -87,8 +98,20 @@ func (m *Manager) Start(kind, title string, cmd *exec.Cmd) (*Task, error) {
select {
case err = <-done:
case <-ctx.Done():
_ = cmd.Process.Kill()
err = <-done
t.mu.Lock()
hook := t.onCancel
t.mu.Unlock()
if hook != nil {
go hook()
}
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM) // asked first: compose and apt both stop cleanly
select {
case err = <-done:
case <-time.After(4 * time.Second):
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
_ = cmd.Process.Kill()
err = <-done
}
t.mu.Lock()
t.Err = "cancelled"
t.mu.Unlock()
@@ -207,6 +230,14 @@ func (t *Task) Subscribe() (backlog []byte, ch chan []byte, unsub func()) {
func (t *Task) Cancel() { t.cancel() }
// OnCancel sets something to do, besides stopping the task's own processes, when the task is
// cancelled: whatever it started that isn't its child (a command inside a container).
func (t *Task) OnCancel(fn func()) {
t.mu.Lock()
t.onCancel = fn
t.mu.Unlock()
}
type writer struct{ t *Task }
func (w *writer) Write(p []byte) (int, error) {