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:
Mitchell-Johnson
2026-10-10 14:53:10 -07:00
co-authored by Claude Opus 5.5
parent a0ce3e5eb9
commit c27d9f0ada
10 changed files with 160 additions and 340 deletions
+9 -3
View File
@@ -55,11 +55,14 @@ type composeSpec struct {
Project string `json:"project"`
Files []string `json:"files"`
Via string `json:"via,omitempty"`
Remote string `json:"remote,omitempty"`
// Remote is read but never written: schedules made before v0.2 could point compose at
// another machine's Docker over tcp. Such a job is refused rather than run against this
// machine's Docker, which is where it would land without the variable.
Remote string `json:"remote,omitempty"`
}
func (c composeSpec) toSpec(tool string) compose.Spec {
return compose.Spec{Project: c.Project, Files: c.Files, Via: c.Via, Remote: c.Remote, Tool: tool}
return compose.Spec{Project: c.Project, Files: c.Files, Via: c.Via, Tool: tool}
}
const dockerCronFile = "/etc/cron.d/hamlab-docker"
@@ -169,6 +172,9 @@ func RunUpdate(ctx context.Context, db *store.Store, j UpdateJob, tool string) (
if len(j.Spec.Files) == 0 {
return "", errors.New("this schedule has no compose file recorded — open it in the Docker tab and save it again")
}
if j.Spec.Remote != "" {
return "", errors.New("this schedule was made to deploy to another machine over tcp, which this console no longer does — make it again on that machine")
}
spec := repoint(ctx, db, j.Spec.toSpec(tool))
var log strings.Builder
run := func(args ...string) error {
@@ -238,7 +244,7 @@ func (s *Server) dsPut(w http.ResponseWriter, r *http.Request) {
// seenFiles is the list as compose itself sees it: host paths normally, the
// orchestrator container's own paths when compose runs inside one. Getting this
// backwards gives a cron line that works from the console and fails at 3am.
j.Spec = composeSpec{Project: st.Name, Files: seenFiles(st, -1, ""), Via: st.Via, Remote: st.Remote}
j.Spec = composeSpec{Project: st.Name, Files: seenFiles(st, -1, ""), Via: st.Via}
if len(j.Spec.Files) == 0 {
jsonErr(w, 409, "that stack's compose file isn't anywhere this console can reach")
return
+3 -19
View File
@@ -220,20 +220,8 @@ func (s *Server) routes() *http.ServeMux {
m.HandleFunc("PUT /api/docker/schedules", s.auth(s.dsPut))
m.HandleFunc("DELETE /api/docker/schedules/{id}", s.auth(s.dsDelete))
m.HandleFunc("POST /api/docker/schedules/{id}/run", s.auth(s.dsRun))
m.HandleFunc("GET /api/nodes/{node}/docker/updates", s.auth(s.machineUpdates))
// 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))
m.HandleFunc("GET /api/nodes/{node}/docker/stacks/{name}/updates", s.auth(s.stackUpdates))
m.HandleFunc("POST /api/nodes/{node}/docker/stacks/{name}/{action}", s.auth(s.stackAction))
m.HandleFunc("GET /api/nodes/{node}/tasks/{id}", s.auth(s.taskOnHub(s.getTask)))
m.HandleFunc("GET /api/nodes/{node}/tasks/{id}/stream", s.auth(s.taskOnHub(s.streamTask)))
// Asked of a node through this hub, every one of these is the node's to answer: the
// generic /api/nodes/{name}/{rest...} proxy above carries them there unchanged.
m.HandleFunc("POST /api/docker/containers/{id}/{action}", s.auth(s.dockerAction))
m.HandleFunc("GET /api/docker/containers/{id}/logs", s.auth(s.dockerLogs))
m.HandleFunc("GET /api/docker/containers/{id}/logs/tail", s.auth(s.dockerTail))
@@ -960,11 +948,7 @@ func (s *Server) updateAllMachines(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) proxyNode(w http.ResponseWriter, r *http.Request) {
node := r.PathValue("node") // routes that also carry a {name} of their own call the machine {node}
if node == "" {
node = r.PathValue("name")
}
p, ok := s.nodes.Proxy(node)
p, ok := s.nodes.Proxy(r.PathValue("name"))
if !ok {
jsonErr(w, 404, "no such machine")
return
+77 -276
View File
@@ -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})
}
+35
View File
@@ -7,6 +7,7 @@ import (
"testing"
"hamlab/internal/compose"
"hamlab/internal/config"
"hamlab/internal/store"
)
@@ -119,3 +120,37 @@ func TestRepointSurvivesAMissingContainer(t *testing.T) {
t.Errorf("unresolvable spec was changed: %+v", got)
}
}
// Where new stacks go: Ham Lab's own <data dir>/stacks is always offered, first and as the
// default, whether or not anything is in it yet. A folder this machine already keeps stacks
// in — two or more deployed from it — is offered too, but never ahead of Ham Lab's own.
func TestHomesDefaultToDataDir(t *testing.T) {
root := t.TempDir()
cfg := config.Default(filepath.Join(root, "data", "config.json"))
s := &Server{cfg: cfg}
own := filepath.Join(root, "data", "stacks")
legacy := filepath.Join(root, "home", "stacks", "box")
deployed := func(dir, name string) stack {
f := filepath.Join(dir, name, "compose.yaml")
return stack{Name: name, Containers: []string{name + "-1"}, Files: []string{f}, File: f, HostFiles: []string{f}, HostFile: f, Editable: true}
}
// Nothing deployed anywhere: one home, Ham Lab's own.
homes := s.homesOf(nil)
if len(homes) != 1 || homes[0].Dir != own || !homes[0].Default {
t.Fatalf("empty machine: homes = %+v, want just %s as default", homes, own)
}
// Two stacks deployed from a pre-existing folder: it is offered, after Ham Lab's own.
homes = s.homesOf([]stack{deployed(legacy, "a"), deployed(legacy, "b")})
if len(homes) != 2 || homes[0].Dir != own || !homes[0].Default || homes[1].Dir != legacy || homes[1].Default || homes[1].Stacks != 2 {
t.Fatalf("legacy folder: homes = %+v, want [%s (default), %s]", homes, own, legacy)
}
// One lone stack elsewhere says nothing about where the next should go; one in Ham Lab's
// own folder is already home.
homes = s.homesOf([]stack{deployed(filepath.Join(root, "home", "apps"), "orchestrator"), deployed(own, "c")})
if len(homes) != 1 || homes[0].Dir != own || homes[0].Stacks != 1 || !homes[0].Default {
t.Fatalf("lone stacks: homes = %+v, want just %s", homes, own)
}
}
+6 -14
View File
@@ -1,14 +1,13 @@
// Package compose runs docker compose the way a stack was deployed — which is not always
// simply "on this host". A stack deployed by an orchestrator running inside its own
// container records paths in that container's filesystem, so compose has to run in there;
// a stack deployed to another machine over tcp needs DOCKER_HOST pointed at it. Both the
// console and the scheduled updates it writes must invoke it identically, or a redeploy
// from one would not match the other's labels.
// container records paths in that container's filesystem, so compose has to run in there.
// Both the console and the scheduled updates it writes must invoke it identically, or a
// redeploy from one would not match the other's labels. Compose always drives this
// machine's own Docker: a stack's files and its containers are on the same box.
package compose
import (
"context"
"os"
"os/exec"
"path/filepath"
)
@@ -18,7 +17,6 @@ type Spec struct {
Project string // -p
Files []string // -f, in order
Via string // a container to run compose inside; "" is this host
Remote string // a DOCKER_HOST; "" is this machine's Docker
Tool string // "docker" or "podman"
}
@@ -33,18 +31,12 @@ func Cmd(ctx context.Context, s Spec, args ...string) *exec.Cmd {
base = append(base, "-f", f)
}
if s.Via != "" {
pre := []string{"exec"}
if s.Remote != "" {
pre = append(pre, "-e", "DOCKER_HOST="+s.Remote)
}
return exec.CommandContext(ctx, tool, append(append(append(pre, s.Via, "docker"), base...), args...)...)
// The CLI inside the orchestrator's container is its own `docker`, whatever this host runs.
return exec.CommandContext(ctx, tool, append(append([]string{"exec", s.Via, "docker"}, base...), args...)...)
}
cmd := exec.CommandContext(ctx, tool, append(base, args...)...)
if len(s.Files) > 0 {
cmd.Dir = filepath.Dir(s.Files[0])
}
if s.Remote != "" {
cmd.Env = append(os.Environ(), "DOCKER_HOST="+s.Remote)
}
return cmd
}