Stacks: one machine, one set of files; default home is <data dir>/stacks
The hub no longer probes a node's Docker on tcp :2375, holds compose files
for a node, or runs compose against another machine's Docker. hubStacks,
nodeDocker, taskOnHub and the hub-side /api/nodes/{node}/docker/... routes
are gone; the generic node proxy hands those requests to the node's own
Ham Lab. Stack.remote / held_by and compose.Spec.Remote are removed.
New stacks default to <data dir>/stacks on every machine. Folders with two
or more deployed stacks are still offered, after it, never as default.
The ~/stacks fallback is gone. A missing home is skipped without a sudo find.
A schedule saved earlier with a tcp target is refused with a message
instead of being run against this machine's Docker.
PORTABILITY.md 3.1, 3.2, 3.3 and 9.2 done; 3.5 checked, no change needed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
a0ce3e5eb9
commit
c27d9f0ada
+77
-276
@@ -18,11 +18,10 @@ package api
|
||||
// machine would become unfindable at once. A remembered mount resolves with no container to
|
||||
// run in, which is right: compose runs on the host against the host's copy of the file.
|
||||
//
|
||||
// A stack can also be deployed *from another machine*: Dockhand on the hub drives a node's
|
||||
// Docker over tcp, so the node runs the containers and the hub holds the files. The node
|
||||
// alone can only list such a stack read-only. The hub, asked about one of its nodes, fills
|
||||
// in what the node couldn't (hubStacks): it resolves the files on itself and runs compose
|
||||
// here with DOCKER_HOST pointed at the node.
|
||||
// A stack is one machine's affair: its files and the Docker that runs it are on the same
|
||||
// box, and that box's Ham Lab is the only thing that touches either. Asked about a node's
|
||||
// stacks, a hub passes the request to the node's own Ham Lab and relays the answer; it holds
|
||||
// no files for anyone else and never points compose at another machine's Docker.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -35,7 +34,6 @@ import (
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -60,8 +58,6 @@ type stack struct {
|
||||
Files []string `json:"files"` // every file it was deployed from, in order: -f a -f b
|
||||
HostFiles []string `json:"host_files"` // the same, on the machine that holds them
|
||||
Via string `json:"via"` // container compose runs inside; "" = on the host
|
||||
Remote string `json:"remote"` // DOCKER_HOST compose is pointed at; "" = this machine's Docker
|
||||
HeldBy string `json:"held_by"` // the machine holding the files, when it isn't the one running them
|
||||
Editable bool `json:"editable"`
|
||||
Note string `json:"note,omitempty"`
|
||||
HasEnv bool `json:"has_env"`
|
||||
@@ -282,7 +278,7 @@ 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)...)
|
||||
stacks = append(stacks, scanHomes(ctx, s.homesOf(stacks), stacks)...)
|
||||
sort.Slice(stacks, func(i, j int) bool { return stacks[i].Name < stacks[j].Name })
|
||||
return stacks, nil
|
||||
}
|
||||
@@ -290,19 +286,23 @@ func (s *Server) findStacks(ctx context.Context) ([]stack, error) {
|
||||
// ---- 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.
|
||||
// that aren't deployed (never were, or were taken down) are still found. The default is
|
||||
// Ham Lab's own: <data dir>/stacks, which is where everything this console manages lives.
|
||||
// Any folder this machine already keeps stacks in — two or more deployed from it — is
|
||||
// offered as well, so what was deployed before Ham Lab arrived stays editable and gains
|
||||
// neighbours where it is, but it is never the default.
|
||||
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
|
||||
Dir string `json:"dir"` // on this machine
|
||||
Inner string `json:"inner"` // the same directory as compose sees it (inside Via); Dir otherwise
|
||||
Via string `json:"via"` // container compose runs inside
|
||||
Stacks int `json:"stacks"`
|
||||
Default bool `json:"default"`
|
||||
}
|
||||
|
||||
func (s *Server) homesOf(stacks []stack, withFallback bool) []stackHome {
|
||||
// stacksDir is the home Ham Lab owns.
|
||||
func (s *Server) stacksDir() string { return filepath.Join(s.cfg.DataDir(), "stacks") }
|
||||
|
||||
func (s *Server) homesOf(stacks []stack) []stackHome {
|
||||
type tally struct {
|
||||
home stackHome
|
||||
deployed int
|
||||
@@ -318,11 +318,10 @@ func (s *Server) homesOf(stacks []stack, withFallback bool) []stackHome {
|
||||
if strings.Count(dir, "/") < 2 { // "/" or "/tmp": not somewhere to make folders in
|
||||
continue
|
||||
}
|
||||
key := dir + "|" + st.Remote
|
||||
t := by[key]
|
||||
t := by[dir]
|
||||
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 = &tally{home: stackHome{Dir: dir}, byVia: map[string]int{}, inner: map[string]string{}}
|
||||
by[dir] = t
|
||||
}
|
||||
t.home.Stacks++
|
||||
if len(st.Containers) > 0 {
|
||||
@@ -335,11 +334,13 @@ func (s *Server) homesOf(stacks []stack, withFallback bool) []stackHome {
|
||||
}
|
||||
}
|
||||
// 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 {
|
||||
// One stack on its own (an orchestrator's own compose file in its app folder) says nothing
|
||||
// about where the next should go, and would have every compose file near it listed as a
|
||||
// stack — unless it is already in Ham Lab's folder. New stacks are deployed the way most
|
||||
// of their neighbours were.
|
||||
own := s.stacksDir()
|
||||
var homes []stackHome
|
||||
for dir, t := range by {
|
||||
best, n := "", -1
|
||||
for via, c := range t.byVia {
|
||||
if c > n || (c == n && via > best) {
|
||||
@@ -355,29 +356,23 @@ func (s *Server) homesOf(stacks []stack, withFallback bool) []stackHome {
|
||||
if t.home.Inner == "" {
|
||||
t.home.Inner = t.home.Dir
|
||||
}
|
||||
if t.deployed >= 2 {
|
||||
if t.deployed >= 2 || dir == own {
|
||||
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].Dir == own) != (homes[j].Dir == own) {
|
||||
return homes[i].Dir == own
|
||||
}
|
||||
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
|
||||
if len(homes) == 0 || homes[0].Dir != own {
|
||||
homes = append([]stackHome{{Dir: own, Inner: own}}, homes...)
|
||||
}
|
||||
homes[0].Default = true
|
||||
return homes
|
||||
}
|
||||
|
||||
@@ -394,6 +389,9 @@ func scanHomes(ctx context.Context, homes []stackHome, known []stack) []stack {
|
||||
}
|
||||
var out []stack
|
||||
for _, h := range homes {
|
||||
if _, err := os.Stat(h.Dir); errors.Is(err, os.ErrNotExist) {
|
||||
continue // Ham Lab's own home before its first stack: nothing to find, and no sudo to spend finding it
|
||||
}
|
||||
args := []string{h.Dir, "-mindepth", "2", "-maxdepth", "2", "-type", "f", "("}
|
||||
for i, n := range composeNames {
|
||||
if i > 0 {
|
||||
@@ -417,7 +415,7 @@ func scanHomes(ctx context.Context, homes []stackHome, known []stack) []stack {
|
||||
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 := stack{Name: name, Containers: []string{}, Images: []string{}, HostFiles: []string{f}, HostFile: f, Via: h.Via, 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())
|
||||
@@ -436,114 +434,23 @@ func contains(list []string, s string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// hubStacks is a node's stack list with the hub's help: what the node could not find is
|
||||
// looked for here, and if it is here — and the node's Docker answers over tcp, which is how
|
||||
// it was deployed in the first place — the stack becomes editable from this hub.
|
||||
func (s *Server) hubStacks(ctx context.Context, node string) ([]stack, error) {
|
||||
code, b, err := s.nodes.Do(ctx, node, "GET", "/api/docker/stacks", nil, nil)
|
||||
// stackFor finds the stack a request means.
|
||||
func (s *Server) stackFor(ctx context.Context, r *http.Request) (stack, error) {
|
||||
all, err := s.findStacks(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var res struct{ Stacks []stack }
|
||||
if code != 200 || json.Unmarshal(b, &res) != nil {
|
||||
return nil, fmt.Errorf("%s answered %d", node, code)
|
||||
}
|
||||
remote, maps := "", []mountMap(nil)
|
||||
for i := range res.Stacks {
|
||||
st := &res.Stacks[i]
|
||||
if st.Editable || len(st.Files) == 0 {
|
||||
continue
|
||||
}
|
||||
if remote == "" {
|
||||
remote, maps = s.nodeDocker(ctx, node), mountsFor(ctx, s.store)
|
||||
}
|
||||
if remote == "-" {
|
||||
continue
|
||||
}
|
||||
note := st.Note
|
||||
if st.resolve(maps); !st.Editable {
|
||||
st.Note = note
|
||||
continue
|
||||
}
|
||||
st.Remote, st.HeldBy = remote, s.hostName()
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
var (
|
||||
nodeDockerMu sync.Mutex
|
||||
nodeDockerOK = map[string]string{} // node → "tcp://host:2375", or "-" when it doesn't answer
|
||||
)
|
||||
|
||||
// nodeDocker is the node's Docker endpoint as Dockhand-style deployments use it, if it answers.
|
||||
func (s *Server) nodeDocker(ctx context.Context, node string) string {
|
||||
nodeDockerMu.Lock()
|
||||
defer nodeDockerMu.Unlock()
|
||||
if v, ok := nodeDockerOK[node]; ok && v != "-" {
|
||||
return v
|
||||
}
|
||||
ep := "-"
|
||||
for _, n := range s.nodes.List() {
|
||||
if n.Name != node {
|
||||
continue
|
||||
}
|
||||
if u, err := url.Parse(n.URL); err == nil && u.Hostname() != "" {
|
||||
cand := "tcp://" + u.Hostname() + ":2375"
|
||||
c, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
if exec.CommandContext(c, dockerTool(), "-H", cand, "version", "--format", "{{.Server.Version}}").Run() == nil {
|
||||
ep = cand
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
nodeDockerOK[node] = ep
|
||||
return ep
|
||||
}
|
||||
|
||||
// stackFor finds the stack a request means: on this machine, or (on a hub route) one of a
|
||||
// node's stacks that this hub holds the files for. handled is false when the request should
|
||||
// simply be passed on to the node.
|
||||
func (s *Server) stackFor(ctx context.Context, r *http.Request) (st stack, handled bool, err error) {
|
||||
var all []stack
|
||||
if node := r.PathValue("node"); node != "" {
|
||||
all, err = s.hubStacks(ctx, node)
|
||||
} else {
|
||||
all, err = s.findStacks(ctx)
|
||||
}
|
||||
if err != nil {
|
||||
return stack{}, true, err
|
||||
return stack{}, err
|
||||
}
|
||||
for _, x := range all {
|
||||
if x.Name == r.PathValue("name") {
|
||||
if r.PathValue("node") != "" && x.Remote == "" {
|
||||
return x, false, nil // the node can serve this one itself
|
||||
}
|
||||
return x, true, nil
|
||||
return x, nil
|
||||
}
|
||||
}
|
||||
return stack{}, true, errors.New("no such stack")
|
||||
return stack{}, errors.New("no such stack")
|
||||
}
|
||||
|
||||
func (s *Server) stacksList(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second)
|
||||
defer cancel()
|
||||
if node := r.PathValue("node"); node != "" {
|
||||
stacks, err := s.hubStacks(ctx, node)
|
||||
if err != nil {
|
||||
s.proxyNode(w, r) // let the node's own answer (or failure) through
|
||||
return
|
||||
}
|
||||
jsonOK(w, 200, map[string]any{"stacks": stacks})
|
||||
return
|
||||
}
|
||||
if dockerTool() == "" { // no Docker is an empty answer, not a failure
|
||||
jsonOK(w, 200, map[string]any{"stacks": []stack{}})
|
||||
return
|
||||
@@ -587,11 +494,7 @@ func target(r *http.Request, st stack) (host string, index int, ok bool) {
|
||||
func (s *Server) stackFile(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second)
|
||||
defer cancel()
|
||||
st, handled, err := s.stackFor(ctx, r)
|
||||
if !handled {
|
||||
s.proxyNode(w, r)
|
||||
return
|
||||
}
|
||||
st, err := s.stackFor(ctx, r)
|
||||
if err != nil || !st.Editable {
|
||||
jsonErr(w, 404, "that stack's compose file isn't anywhere this console can reach")
|
||||
return
|
||||
@@ -610,8 +513,8 @@ func (s *Server) stackFile(w http.ResponseWriter, r *http.Request) {
|
||||
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 files make sense and
|
||||
// pointed at the Docker that runs its containers. files are given as compose will see them.
|
||||
// composeCmd is `docker compose …` for this stack, run where its files make sense. files are
|
||||
// given as compose will see them.
|
||||
func composeCmd(ctx context.Context, st stack, files []string, args ...string) *exec.Cmd {
|
||||
return compose.Cmd(ctx, specOf(st, files), args...)
|
||||
}
|
||||
@@ -619,7 +522,7 @@ func composeCmd(ctx context.Context, st stack, files []string, args ...string) *
|
||||
// specOf is a stack as the compose runner needs it — and as a scheduled update stores it,
|
||||
// so the cron job and the console invoke compose identically.
|
||||
func specOf(st stack, files []string) compose.Spec {
|
||||
return compose.Spec{Project: st.Name, Files: files, Via: st.Via, Remote: st.Remote, Tool: dockerTool()}
|
||||
return compose.Spec{Project: st.Name, Files: files, Via: st.Via, Tool: dockerTool()}
|
||||
}
|
||||
|
||||
// seenFiles is the stack's file list as compose runs it, with file i replaced by a candidate.
|
||||
@@ -647,11 +550,7 @@ func (s *Server) writeAsOwner(ctx context.Context, path, text string) error {
|
||||
func (s *Server) stackSave(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||
defer cancel()
|
||||
st, handled, err := s.stackFor(ctx, r)
|
||||
if !handled {
|
||||
s.proxyNode(w, r)
|
||||
return
|
||||
}
|
||||
st, err := s.stackFor(ctx, r)
|
||||
if err != nil || !st.Editable {
|
||||
jsonErr(w, 404, "that stack's compose file isn't anywhere this console can reach")
|
||||
return
|
||||
@@ -723,11 +622,7 @@ func (s *Server) stackSave(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) stackDiff(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second)
|
||||
defer cancel()
|
||||
st, handled, err := s.stackFor(ctx, r)
|
||||
if !handled {
|
||||
s.proxyNode(w, r)
|
||||
return
|
||||
}
|
||||
st, err := s.stackFor(ctx, r)
|
||||
if err != nil || !st.Editable {
|
||||
jsonErr(w, 404, "that stack's compose file isn't anywhere this console can reach")
|
||||
return
|
||||
@@ -777,11 +672,7 @@ func maskEnv(text string) string {
|
||||
func (s *Server) stackAction(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second)
|
||||
defer cancel()
|
||||
st, handled, err := s.stackFor(ctx, r)
|
||||
if !handled {
|
||||
s.proxyNode(w, r)
|
||||
return
|
||||
}
|
||||
st, err := s.stackFor(ctx, r)
|
||||
if err != nil || !st.Editable {
|
||||
jsonErr(w, 404, "that stack's compose file isn't anywhere this console can reach")
|
||||
return
|
||||
@@ -794,17 +685,10 @@ func (s *Server) stackAction(w http.ResponseWriter, r *http.Request) {
|
||||
flags := priv.Quote(append([]string{"-p", st.Name}, fl...))
|
||||
base := func(args string) string {
|
||||
if st.Via != "" {
|
||||
env := ""
|
||||
if st.Remote != "" {
|
||||
env = "-e " + priv.Quote([]string{"DOCKER_HOST=" + st.Remote}) + " "
|
||||
}
|
||||
return fmt.Sprintf("%s exec %s%s docker compose %s %s", dockerTool(), env, priv.Quote([]string{st.Via}), flags, args)
|
||||
// The CLI inside the orchestrator's container is its own `docker`, whatever this host runs.
|
||||
return fmt.Sprintf("%s exec %s docker compose %s %s", dockerTool(), priv.Quote([]string{st.Via}), flags, args)
|
||||
}
|
||||
env := ""
|
||||
if st.Remote != "" { // through env(1): a quoted NAME=value in front of a command is a command name to sh, not an assignment
|
||||
env = "env " + priv.Quote([]string{"DOCKER_HOST=" + st.Remote}) + " "
|
||||
}
|
||||
return fmt.Sprintf("cd %s && %s%s compose %s %s", priv.Quote([]string{filepath.Dir(files[0])}), env, dockerTool(), flags, args)
|
||||
return fmt.Sprintf("cd %s && %s compose %s %s", priv.Quote([]string{filepath.Dir(files[0])}), dockerTool(), flags, args)
|
||||
}
|
||||
// After a deploy that worked, keep a copy of exactly what was deployed: the next diff is against it.
|
||||
remember := ""
|
||||
@@ -860,7 +744,7 @@ func (s *Server) stackAction(w http.ResponseWriter, r *http.Request) {
|
||||
_ = exec.CommandContext(ctx, dockerTool(), "exec", via, "pkill", "-TERM", "-f", pattern).Run()
|
||||
})
|
||||
}
|
||||
stackUpdatesForget(r.PathValue("node") + "/" + st.Name)
|
||||
stackUpdatesForget(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))
|
||||
}
|
||||
@@ -885,20 +769,13 @@ var (
|
||||
|
||||
func stackUpdatesForget(key string) { updMu.Lock(); delete(updCache, key); updMu.Unlock() }
|
||||
|
||||
// checkImages asks the registry whether each image is still the one we hold. remote is a
|
||||
// DOCKER_HOST for a machine whose images live elsewhere, "" for this one. Each image is one
|
||||
// registry request, which Docker Hub counts — so this is only ever called when asked for.
|
||||
func checkImages(ctx context.Context, images []string, remote string) []imageCheck {
|
||||
local := func(args ...string) ([]byte, error) { // the Docker that holds the images
|
||||
if remote != "" {
|
||||
args = append([]string{"-H", remote}, args...)
|
||||
}
|
||||
return dockerRun(ctx, args...)
|
||||
}
|
||||
// checkImages asks the registry whether each image is still the one we hold. Each image is
|
||||
// one registry request, which Docker Hub counts — so this is only ever called when asked for.
|
||||
func checkImages(ctx context.Context, images []string) []imageCheck {
|
||||
res := []imageCheck{}
|
||||
for _, img := range images {
|
||||
ic := imageCheck{Image: img, State: "unknown"}
|
||||
out, err := local("image", "inspect", "-f", "{{json .RepoDigests}}", img)
|
||||
out, err := dockerRun(ctx, "image", "inspect", "-f", "{{json .RepoDigests}}", img)
|
||||
var have []string
|
||||
if err != nil || json.Unmarshal(bytes.TrimSpace(out), &have) != nil || len(have) == 0 {
|
||||
ic.Detail = "built or loaded here, not pulled from a registry"
|
||||
@@ -930,31 +807,24 @@ func checkImages(ctx context.Context, images []string, remote string) []imageChe
|
||||
}
|
||||
|
||||
// machineUpdates checks every image on this machine in one pass, so the container list can
|
||||
// be coloured and the fleet can be asked "what is behind?" without walking stack by stack.
|
||||
// be coloured and "what is behind?" answered without walking stack by stack.
|
||||
func (s *Server) machineUpdates(w http.ResponseWriter, r *http.Request) {
|
||||
node := r.PathValue("node")
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Minute)
|
||||
defer cancel()
|
||||
stacks, err := s.findStacks(ctx)
|
||||
if node != "" {
|
||||
stacks, err = s.hubStacks(ctx, node)
|
||||
}
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
// one entry per image, however many stacks use it
|
||||
type want struct{ remote string }
|
||||
imgs := map[string]want{}
|
||||
byImage := map[string][]string{} // image -> stacks
|
||||
for _, st := range stacks {
|
||||
for _, img := range st.Images {
|
||||
imgs[img] = want{remote: st.Remote}
|
||||
byImage[img] = append(byImage[img], st.Name)
|
||||
}
|
||||
}
|
||||
refresh := r.URL.Query().Get("refresh") == "1"
|
||||
key := "machine:" + node
|
||||
key := "machine"
|
||||
updMu.Lock()
|
||||
c, ok := updCache[key]
|
||||
updMu.Unlock()
|
||||
@@ -967,15 +837,11 @@ func (s *Server) machineUpdates(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
var list []string
|
||||
remote := ""
|
||||
for img, wnt := range imgs {
|
||||
for img := range byImage {
|
||||
list = append(list, img)
|
||||
if wnt.remote != "" {
|
||||
remote = wnt.remote
|
||||
}
|
||||
}
|
||||
sort.Strings(list)
|
||||
res := checkImages(ctx, list, remote)
|
||||
res := checkImages(ctx, list)
|
||||
updMu.Lock()
|
||||
updCache[key] = updAnswer{time.Now(), res}
|
||||
updMu.Unlock()
|
||||
@@ -988,16 +854,12 @@ func (s *Server) machineUpdates(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) stackUpdates(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second)
|
||||
defer cancel()
|
||||
st, handled, err := s.stackFor(ctx, r)
|
||||
if !handled {
|
||||
s.proxyNode(w, r)
|
||||
return
|
||||
}
|
||||
st, err := s.stackFor(ctx, r)
|
||||
if err != nil {
|
||||
jsonErr(w, 404, err.Error())
|
||||
return
|
||||
}
|
||||
key := r.PathValue("node") + "/" + st.Name
|
||||
key := st.Name
|
||||
updMu.Lock()
|
||||
c, ok := updCache[key]
|
||||
updMu.Unlock()
|
||||
@@ -1010,84 +872,38 @@ func (s *Server) stackUpdates(w http.ResponseWriter, r *http.Request) {
|
||||
jsonOK(w, 200, map[string]any{"images": []imageCheck{}, "checked": 0})
|
||||
return
|
||||
}
|
||||
res := checkImages(ctx, st.Images, st.Remote)
|
||||
res := checkImages(ctx, st.Images)
|
||||
updMu.Lock()
|
||||
updCache[key] = updAnswer{time.Now(), res}
|
||||
updMu.Unlock()
|
||||
jsonOK(w, 200, map[string]any{"images": res, "checked": time.Now().UnixMilli()})
|
||||
}
|
||||
|
||||
// taskOnHub serves a task this hub is running for one of its nodes' stacks; anything else
|
||||
// under /api/nodes/<node>/tasks belongs to the node.
|
||||
func (s *Server) taskOnHub(local http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.tasks.Get(r.PathValue("id")); ok {
|
||||
local(w, r)
|
||||
return
|
||||
}
|
||||
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)
|
||||
// homesFor is where a new stack could go on this machine.
|
||||
func (s *Server) homesFor(ctx context.Context) ([]stackHome, error) {
|
||||
stacks, err := s.findStacks(ctx)
|
||||
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
|
||||
return s.homesOf(stacks), 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") == "" {
|
||||
if dockerTool() == "" {
|
||||
jsonOK(w, 200, map[string]any{"homes": []stackHome{}})
|
||||
return
|
||||
}
|
||||
homes, err := s.homesFor(ctx, r)
|
||||
homes, err := s.homesFor(ctx)
|
||||
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})
|
||||
}
|
||||
|
||||
@@ -1114,7 +930,7 @@ func (s *Server) stackCreate(w http.ResponseWriter, r *http.Request) {
|
||||
jsonErr(w, 400, "the compose file can't be empty")
|
||||
return
|
||||
}
|
||||
homes, err := s.homesFor(ctx, r)
|
||||
homes, err := s.homesFor(ctx)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
@@ -1129,17 +945,7 @@ func (s *Server) stackCreate(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
}
|
||||
all, _ := s.findStacks(ctx)
|
||||
for _, st := range all {
|
||||
if st.Name == name {
|
||||
jsonErr(w, 409, "there is already a stack called "+name)
|
||||
@@ -1201,7 +1007,7 @@ func (s *Server) stackCreate(w http.ResponseWriter, r *http.Request) {
|
||||
_ = 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) != ""}
|
||||
Via: home.Via, 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()
|
||||
@@ -1220,12 +1026,7 @@ 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
|
||||
}
|
||||
st, err := s.stackFor(ctx, r)
|
||||
if err != nil || !st.Editable {
|
||||
jsonErr(w, 404, "that stack's compose file isn't anywhere this console can reach")
|
||||
return
|
||||
@@ -1263,7 +1064,7 @@ func (s *Server) stackDelete(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
_ = run("rmdir", dir) // only if nothing else was in it
|
||||
stackUpdatesForget(r.PathValue("node") + "/" + st.Name)
|
||||
stackUpdatesForget(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