Docker: new stacks and containers, stack down/delete, container remove, image pull/remove/prune
A new stack is a folder in a home (where stacks are already kept: Dockhand's group here, ~/stacks elsewhere) with compose.yaml and an optional root-only .env, validated by compose before it is kept and deployed the way its neighbours were — inside dockhand, or to a node over tcp from the hub. New container is the same dialog on a form that writes the compose. Homes are scanned, so undeployed stacks stay listed. Delete moves a stack's files to .hamlab-trash; nothing is forced, and volumes and data are never touched. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
df6c1dcebf
commit
c017b422d5
+64
-3
@@ -11,6 +11,8 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -143,19 +145,78 @@ 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") {
|
||||
jsonErr(w, 400, "start, stop or restart a container by name or ID")
|
||||
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()
|
||||
if _, err := dockerRun(ctx, action, id); err != nil {
|
||||
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 only — layers no tag points at any more, left behind by pulls.
|
||||
func (s *Server) imagePrune(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
|
||||
defer cancel()
|
||||
out, err := dockerRun(ctx, "image", "prune", "-f")
|
||||
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) {
|
||||
|
||||
@@ -130,6 +130,12 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
||||
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("POST /api/docker/stacks", s.auth(s.stackCreate))
|
||||
m.HandleFunc("GET /api/docker/stacks/homes", s.auth(s.stackHomes))
|
||||
m.HandleFunc("POST /api/docker/stacks/{name}/delete", s.auth(s.stackDelete))
|
||||
m.HandleFunc("POST /api/docker/images/pull", s.auth(s.imagePull))
|
||||
m.HandleFunc("POST /api/docker/images/remove", s.auth(s.imageRemove))
|
||||
m.HandleFunc("POST /api/docker/images/prune", s.auth(s.imagePrune))
|
||||
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))
|
||||
@@ -138,6 +144,9 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
||||
// The same, asked of a node through this hub: the hub answers for the stacks whose files
|
||||
// it holds (deployed to the node over tcp) and passes everything else to the node.
|
||||
m.HandleFunc("GET /api/nodes/{node}/docker/stacks", s.auth(s.stacksList))
|
||||
m.HandleFunc("POST /api/nodes/{node}/docker/stacks", s.auth(s.stackCreate))
|
||||
m.HandleFunc("GET /api/nodes/{node}/docker/stacks/homes", s.auth(s.stackHomes))
|
||||
m.HandleFunc("POST /api/nodes/{node}/docker/stacks/{name}/delete", s.auth(s.stackDelete))
|
||||
m.HandleFunc("GET /api/nodes/{node}/docker/stacks/{name}", s.auth(s.stackFile))
|
||||
m.HandleFunc("PUT /api/nodes/{node}/docker/stacks/{name}", s.auth(s.stackSave))
|
||||
m.HandleFunc("GET /api/nodes/{node}/docker/stacks/{name}/diff", s.auth(s.stackDiff))
|
||||
|
||||
+371
-1
@@ -33,6 +33,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -179,10 +180,151 @@ func (s *Server) findStacks(ctx context.Context) ([]stack, error) {
|
||||
st.resolve(maps)
|
||||
stacks = append(stacks, *st)
|
||||
}
|
||||
stacks = append(stacks, scanHomes(ctx, s.homesOf(stacks, true), stacks)...)
|
||||
sort.Slice(stacks, func(i, j int) bool { return stacks[i].Name < stacks[j].Name })
|
||||
return stacks, nil
|
||||
}
|
||||
|
||||
// ---- where stacks live ----
|
||||
|
||||
// A home is a directory of stacks, one folder each: where new ones are made, and where ones
|
||||
// that aren't deployed (never were, or were taken down) are still found. Homes aren't
|
||||
// configured; they are wherever this machine's stacks already are. With none at all, ~/stacks.
|
||||
type stackHome struct {
|
||||
Dir string `json:"dir"` // on the machine that holds the files
|
||||
Inner string `json:"inner"` // the same directory as compose sees it (inside Via); Dir otherwise
|
||||
Via string `json:"via"` // container compose runs inside
|
||||
Remote string `json:"remote"` // DOCKER_HOST, when the containers run on another machine
|
||||
HeldBy string `json:"held_by"` // the machine holding the files, when it isn't the one running them
|
||||
Stacks int `json:"stacks"`
|
||||
Default bool `json:"default"`
|
||||
}
|
||||
|
||||
func (s *Server) homesOf(stacks []stack, withFallback bool) []stackHome {
|
||||
type tally struct {
|
||||
home stackHome
|
||||
deployed int
|
||||
byVia map[string]int
|
||||
inner map[string]string
|
||||
}
|
||||
by := map[string]*tally{}
|
||||
for _, st := range stacks {
|
||||
if !st.Editable || len(st.HostFiles) == 0 || len(st.Files) == 0 {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Dir(filepath.Dir(st.HostFiles[0]))
|
||||
if strings.Count(dir, "/") < 2 { // "/" or "/tmp": not somewhere to make folders in
|
||||
continue
|
||||
}
|
||||
key := dir + "|" + st.Remote
|
||||
t := by[key]
|
||||
if t == nil {
|
||||
t = &tally{home: stackHome{Dir: dir, Remote: st.Remote, HeldBy: st.HeldBy}, byVia: map[string]int{}, inner: map[string]string{}}
|
||||
by[key] = t
|
||||
}
|
||||
t.home.Stacks++
|
||||
if len(st.Containers) > 0 {
|
||||
t.deployed++
|
||||
t.byVia[st.Via]++
|
||||
}
|
||||
t.inner[st.Via] = dir
|
||||
if st.Via != "" {
|
||||
t.inner[st.Via] = filepath.Dir(filepath.Dir(st.Files[0]))
|
||||
}
|
||||
}
|
||||
// A folder is a home when stacks are kept there as a habit: two or more deployed from it.
|
||||
// One stack on its own (Dockhand's own compose file in ~/apps/dockhand) says nothing about
|
||||
// where the next should go, and would have every compose file near it listed as a stack.
|
||||
// New stacks are deployed the way most of their neighbours were.
|
||||
var homes, lone []stackHome
|
||||
for _, t := range by {
|
||||
best, n := "", -1
|
||||
for via, c := range t.byVia {
|
||||
if c > n || (c == n && via > best) {
|
||||
best, n = via, c
|
||||
}
|
||||
}
|
||||
if n < 0 {
|
||||
for via := range t.inner {
|
||||
best = via
|
||||
}
|
||||
}
|
||||
t.home.Via, t.home.Inner = best, t.inner[best]
|
||||
if t.home.Inner == "" {
|
||||
t.home.Inner = t.home.Dir
|
||||
}
|
||||
if t.deployed >= 2 {
|
||||
homes = append(homes, t.home)
|
||||
} else {
|
||||
lone = append(lone, t.home)
|
||||
}
|
||||
}
|
||||
if len(homes) == 0 {
|
||||
homes = lone
|
||||
}
|
||||
sort.Slice(homes, func(i, j int) bool {
|
||||
if homes[i].Stacks != homes[j].Stacks {
|
||||
return homes[i].Stacks > homes[j].Stacks
|
||||
}
|
||||
return homes[i].Dir < homes[j].Dir
|
||||
})
|
||||
if len(homes) == 0 && withFallback {
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
homes = append(homes, stackHome{Dir: filepath.Join(home, "stacks"), Inner: filepath.Join(home, "stacks")})
|
||||
}
|
||||
}
|
||||
if len(homes) > 0 {
|
||||
homes[0].Default = true
|
||||
}
|
||||
return homes
|
||||
}
|
||||
|
||||
var composeNames = []string{"compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml"}
|
||||
|
||||
// scanHomes finds the stacks in these homes that have no containers: a folder with a compose file.
|
||||
func scanHomes(ctx context.Context, homes []stackHome, known []stack) []stack {
|
||||
have := map[string]bool{}
|
||||
for _, st := range known {
|
||||
have[st.Name] = true
|
||||
for _, f := range st.HostFiles {
|
||||
have[filepath.Dir(f)] = true
|
||||
}
|
||||
}
|
||||
var out []stack
|
||||
for _, h := range homes {
|
||||
args := []string{h.Dir, "-mindepth", "2", "-maxdepth", "2", "-type", "f", "("}
|
||||
for i, n := range composeNames {
|
||||
if i > 0 {
|
||||
args = append(args, "-o")
|
||||
}
|
||||
args = append(args, "-name", n)
|
||||
}
|
||||
args = append(args, ")")
|
||||
found, err := exec.CommandContext(ctx, "find", args...).Output()
|
||||
if err != nil && priv.Available() {
|
||||
full := priv.Wrap(append([]string{"find"}, args...)...)
|
||||
found, _ = exec.CommandContext(ctx, full[0], full[1:]...).Output()
|
||||
}
|
||||
for _, f := range strings.Split(strings.TrimSpace(string(found)), "\n") {
|
||||
if f == "" {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Dir(f)
|
||||
name := strings.ToLower(filepath.Base(dir))
|
||||
if have[name] || have[dir] || strings.HasPrefix(filepath.Base(dir), ".") {
|
||||
continue
|
||||
}
|
||||
have[name], have[dir] = true, true
|
||||
st := stack{Name: name, Containers: []string{}, Images: []string{}, HostFiles: []string{f}, HostFile: f, Via: h.Via, Remote: h.Remote, HeldBy: h.HeldBy, Editable: true, Note: "not deployed"}
|
||||
st.Files = []string{filepath.Join(h.Inner, filepath.Base(dir), filepath.Base(f))}
|
||||
st.File = st.Files[0]
|
||||
st.HasEnv = readable(st.envPath())
|
||||
out = append(out, st)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func contains(list []string, s string) bool {
|
||||
for _, x := range list {
|
||||
if x == s {
|
||||
@@ -223,6 +365,14 @@ func (s *Server) hubStacks(ctx context.Context, node string) ([]stack, error) {
|
||||
}
|
||||
st.Remote, st.HeldBy = remote, s.host
|
||||
}
|
||||
var held []stack
|
||||
for _, st := range res.Stacks {
|
||||
if st.HeldBy != "" {
|
||||
held = append(held, st)
|
||||
}
|
||||
}
|
||||
res.Stacks = append(res.Stacks, scanHomes(ctx, s.homesOf(held, false), res.Stacks)...)
|
||||
sort.Slice(res.Stacks, func(i, j int) bool { return res.Stacks[i].Name < res.Stacks[j].Name })
|
||||
return res.Stacks, nil
|
||||
}
|
||||
|
||||
@@ -584,8 +734,10 @@ func (s *Server) stackAction(w http.ResponseWriter, r *http.Request) {
|
||||
script = base("restart")
|
||||
case "stop":
|
||||
script = base("stop")
|
||||
case "down": // remove its containers and networks; its files, volumes and bind-mounted data stay
|
||||
script = base("down --remove-orphans")
|
||||
default:
|
||||
jsonErr(w, 400, "deploy, update, restart or stop")
|
||||
jsonErr(w, 400, "deploy, update, restart, stop or down")
|
||||
return
|
||||
}
|
||||
if t, busy := s.tasks.RunningOf("stack:" + st.Name); busy {
|
||||
@@ -704,3 +856,221 @@ func (s *Server) taskOnHub(local http.HandlerFunc) http.HandlerFunc {
|
||||
s.proxyNode(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- making and unmaking stacks ----
|
||||
|
||||
var stackNameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,62}$`)
|
||||
|
||||
// homesFor is where a new stack could go, seen from this request: this machine's homes, or —
|
||||
// on a hub route — the node's own homes plus the ones this hub holds for it.
|
||||
func (s *Server) homesFor(ctx context.Context, r *http.Request) ([]stackHome, error) {
|
||||
node := r.PathValue("node")
|
||||
if node == "" {
|
||||
stacks, err := s.findStacks(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.homesOf(stacks, true), nil
|
||||
}
|
||||
stacks, err := s.hubStacks(ctx, node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var held []stack
|
||||
for _, st := range stacks {
|
||||
if st.HeldBy != "" {
|
||||
held = append(held, st)
|
||||
}
|
||||
}
|
||||
homes := s.homesOf(held, false)
|
||||
if code, b, err := s.nodes.Do(ctx, node, "GET", "/api/docker/stacks/homes", nil, nil); err == nil && code == 200 {
|
||||
var res struct{ Homes []stackHome }
|
||||
if json.Unmarshal(b, &res) == nil {
|
||||
for _, h := range res.Homes {
|
||||
if len(homes) > 0 && h.Stacks == 0 {
|
||||
continue // the node's empty ~/stacks is only worth offering when there is nothing else
|
||||
}
|
||||
h.Default = false
|
||||
homes = append(homes, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(homes) > 0 {
|
||||
homes[0].Default = true
|
||||
}
|
||||
return homes, nil
|
||||
}
|
||||
|
||||
func (s *Server) stackHomes(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second)
|
||||
defer cancel()
|
||||
if dockerTool() == "" && r.PathValue("node") == "" {
|
||||
jsonOK(w, 200, map[string]any{"homes": []stackHome{}})
|
||||
return
|
||||
}
|
||||
homes, err := s.homesFor(ctx, r)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
if homes == nil { // a machine with no Docker and nothing held for it: an empty list, never null
|
||||
homes = []stackHome{}
|
||||
}
|
||||
jsonOK(w, 200, map[string]any{"homes": homes})
|
||||
}
|
||||
|
||||
// stackCreate makes a stack: a folder in a home with compose.yaml (and .env) in it, checked by
|
||||
// compose before it is kept. It deploys nothing; Deploy is the next, separate act.
|
||||
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 }
|
||||
if err != nil || json.Unmarshal(raw, &body) != nil {
|
||||
jsonErr(w, 400, "bad request")
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(body.Name)
|
||||
if !stackNameRe.MatchString(name) || name == "homes" {
|
||||
jsonErr(w, 400, "a stack name is lower-case letters, digits, - and _, starting with a letter or digit")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(body.Text) == "" {
|
||||
jsonErr(w, 400, "the compose file can't be empty")
|
||||
return
|
||||
}
|
||||
homes, err := s.homesFor(ctx, r)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
var home *stackHome
|
||||
for i := range homes {
|
||||
if homes[i].Dir == body.Home || (body.Home == "" && homes[i].Default) {
|
||||
home = &homes[i]
|
||||
}
|
||||
}
|
||||
if home == nil {
|
||||
jsonErr(w, 400, "that isn't one of the places this machine keeps stacks")
|
||||
return
|
||||
}
|
||||
if r.PathValue("node") != "" && home.HeldBy == "" { // the node's own folder: the node makes it
|
||||
r.Body, r.ContentLength = io.NopCloser(bytes.NewReader(raw)), int64(len(raw))
|
||||
s.proxyNode(w, r)
|
||||
return
|
||||
}
|
||||
var all []stack
|
||||
if node := r.PathValue("node"); node != "" {
|
||||
all, _ = s.hubStacks(ctx, node)
|
||||
} else {
|
||||
all, _ = s.findStacks(ctx)
|
||||
}
|
||||
for _, st := range all {
|
||||
if st.Name == name {
|
||||
jsonErr(w, 409, "there is already a stack called "+name)
|
||||
return
|
||||
}
|
||||
}
|
||||
dir := filepath.Join(home.Dir, name)
|
||||
if readable(dir) || exec.Command("test", "-e", dir).Run() == nil {
|
||||
jsonErr(w, 409, dir+" already exists")
|
||||
return
|
||||
}
|
||||
sudo := func(args ...string) error {
|
||||
if msg, err := exec.CommandContext(ctx, args[0], args[1:]...).CombinedOutput(); err != nil {
|
||||
full := priv.Wrap(args...)
|
||||
if msg2, err2 := exec.CommandContext(ctx, full[0], full[1:]...).CombinedOutput(); err2 != nil {
|
||||
return errors.New(strings.TrimSpace(string(msg) + " " + string(msg2)))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := sudo("mkdir", "-p", dir); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
undo := func() {
|
||||
_ = sudo("rm", "-f", filepath.Join(dir, "compose.yaml"), filepath.Join(dir, ".env"))
|
||||
_ = sudo("rmdir", dir)
|
||||
}
|
||||
host := filepath.Join(dir, "compose.yaml")
|
||||
if err := s.writeAsOwner(ctx, host, body.Text); err != nil {
|
||||
undo()
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(body.Env) != "" {
|
||||
if err := s.writeAsOwner(ctx, filepath.Join(dir, ".env"), body.Env); err != nil {
|
||||
undo()
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
_ = sudo("chmod", "600", filepath.Join(dir, ".env")) // it is where the secrets go
|
||||
}
|
||||
st := stack{Name: name, Containers: []string{}, Images: []string{}, HostFiles: []string{host}, HostFile: host, Files: []string{filepath.Join(home.Inner, name, "compose.yaml")},
|
||||
Via: home.Via, Remote: home.Remote, HeldBy: home.HeldBy, Editable: true, Note: "not deployed", HasEnv: strings.TrimSpace(body.Env) != ""}
|
||||
st.File = st.Files[0]
|
||||
if msg, err := composeCmd(ctx, st, seenFiles(st, -1, ""), "config", "-q").CombinedOutput(); err != nil {
|
||||
undo()
|
||||
jsonErr(w, 400, "compose rejects it: "+strings.TrimSpace(string(msg)))
|
||||
return
|
||||
}
|
||||
log.Printf("stack %s: created in %s (from %s)", name, home.Dir, clientIP(r))
|
||||
jsonOK(w, 201, map[string]any{"stack": st})
|
||||
}
|
||||
|
||||
// stackDelete takes a stack down and puts its files in <home>/.hamlab-trash/<name>-<time>/ —
|
||||
// moved, not erased, because a compose file is often the only record of how a service was set
|
||||
// up. Only the stack's own files move (compose files, .env, this console's copies); anything
|
||||
// else in the folder, its volumes and its bind-mounted data are left exactly where they are.
|
||||
func (s *Server) stackDelete(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 4*time.Minute)
|
||||
defer cancel()
|
||||
raw, _ := io.ReadAll(io.LimitReader(r.Body, 4096))
|
||||
st, handled, err := s.stackFor(ctx, r)
|
||||
if !handled {
|
||||
r.Body, r.ContentLength = io.NopCloser(bytes.NewReader(raw)), int64(len(raw))
|
||||
s.proxyNode(w, r)
|
||||
return
|
||||
}
|
||||
if err != nil || !st.Editable {
|
||||
jsonErr(w, 404, "that stack's compose file isn't anywhere this console can reach")
|
||||
return
|
||||
}
|
||||
var body struct{ Confirm string }
|
||||
if json.Unmarshal(raw, &body) != nil || body.Confirm != st.Name {
|
||||
jsonErr(w, 400, "type the stack's name to confirm")
|
||||
return
|
||||
}
|
||||
if len(st.Containers) > 0 {
|
||||
if msg, err := composeCmd(ctx, st, seenFiles(st, -1, ""), "down", "--remove-orphans").CombinedOutput(); err != nil {
|
||||
jsonErr(w, 500, "couldn't take it down, so nothing was deleted: "+strings.TrimSpace(string(msg)))
|
||||
return
|
||||
}
|
||||
}
|
||||
dir := filepath.Dir(st.HostFiles[0])
|
||||
trash := filepath.Join(filepath.Dir(dir), ".hamlab-trash", st.Name+"-"+time.Now().Format("20060102-150405"))
|
||||
run := func(args ...string) error {
|
||||
full := priv.Wrap(args...)
|
||||
if msg, err := exec.CommandContext(ctx, full[0], full[1:]...).CombinedOutput(); err != nil {
|
||||
return errors.New(strings.TrimSpace(string(msg)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := run("mkdir", "-p", trash); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
moved := 0
|
||||
for _, f := range append(append([]string{}, st.HostFiles...), st.envPath()) {
|
||||
for _, suffix := range []string{"", ".hamlab-bak", ".hamlab-deployed", ".hamlab-new"} {
|
||||
if filepath.Dir(f) == dir && readable(f+suffix) && run("mv", f+suffix, trash+"/") == nil {
|
||||
moved++
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = run("rmdir", dir) // only if nothing else was in it
|
||||
stackUpdatesForget(r.PathValue("node") + "/" + st.Name)
|
||||
log.Printf("stack %s: taken down and %d file(s) moved to %s (from %s)", st.Name, moved, trash, clientIP(r))
|
||||
jsonOK(w, 200, map[string]any{"trash": trash, "moved": moved})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user