Files
Mitchell-JohnsonandClaude Opus 5.5 c27d9f0ada 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>
2026-10-10 14:53:10 -07:00

157 lines
6.0 KiB
Go

package api
import (
"context"
"os"
"path/filepath"
"testing"
"hamlab/internal/compose"
"hamlab/internal/config"
"hamlab/internal/store"
)
// A stack deployed by an orchestrator running in a container records the path its compose
// file had inside that container. Resolving it has to work both while the orchestrator is
// running — compose is then run inside it — and after it has been removed, where the same
// mount is only remembered and compose has to run on the host instead.
func TestResolveThroughMounts(t *testing.T) {
root := t.TempDir()
dir := filepath.Join(root, "orch", "stacks", "app")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
file := filepath.Join(dir, "compose.yaml")
if err := os.WriteFile(file, []byte("services: {}\n"), 0o644); err != nil {
t.Fatal(err)
}
inner := "/app/data/stacks/app/compose.yaml"
for _, tc := range []struct {
name, container, wantVia string
}{
{"orchestrator running", "orch", "orch"},
{"orchestrator removed", "", ""},
} {
t.Run(tc.name, func(t *testing.T) {
st := stack{Name: "app", File: inner, Files: []string{inner}}
st.resolve([]mountMap{{tc.container, "/app/data", filepath.Join(root, "orch")}})
if !st.Editable {
t.Fatalf("not editable: %s", st.Note)
}
if st.HostFile != file {
t.Errorf("host file = %q, want %q", st.HostFile, file)
}
if st.Via != tc.wantVia {
t.Errorf("via = %q, want %q", st.Via, tc.wantVia)
}
})
}
// Nothing explains the path: the stack is listed, but read-only and says why.
st := stack{Name: "app", File: inner, Files: []string{inner}}
st.resolve(nil)
if st.Editable || st.Note == "" {
t.Errorf("unresolvable stack should be read-only with a note, got editable=%v note=%q", st.Editable, st.Note)
}
}
// Every file of a multi-file stack must resolve through the same place. Redeploying from
// the first file alone would silently drop whatever the override set.
func TestResolveRefusesSplitStack(t *testing.T) {
root := t.TempDir()
dir := filepath.Join(root, "orch", "stacks", "app")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "compose.yaml"), []byte("services: {}\n"), 0o644); err != nil {
t.Fatal(err)
}
st := stack{Name: "app", File: "/app/data/stacks/app/compose.yaml", Files: []string{
"/app/data/stacks/app/compose.yaml",
"/app/data/stacks/app/override.yaml", // never written: not on disk
}}
st.resolve([]mountMap{{"orch", "/app/data", filepath.Join(root, "orch")}})
if st.Editable {
t.Error("a stack with a file that cannot be found should not be editable")
}
}
// A schedule made while an orchestrator was running says "run compose inside that
// container". When the container is gone the schedule must still work, against the same
// files on the host — otherwise retiring the orchestrator silently breaks every scheduled
// update made before it went.
func TestRepointSurvivesAMissingContainer(t *testing.T) {
root := t.TempDir()
dir := filepath.Join(root, "orch", "stacks", "app")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
host := filepath.Join(dir, "compose.yaml")
if err := os.WriteFile(host, []byte("services: {}\n"), 0o644); err != nil {
t.Fatal(err)
}
db, err := store.Open(filepath.Join(root, "state.json"))
if err != nil {
t.Fatal(err)
}
if err := db.Update(func(st *store.State) {
st.Mounts = []store.RememberedMount{{Container: "gone-orchestrator", Dest: "/app/data", Src: filepath.Join(root, "orch")}}
}); err != nil {
t.Fatal(err)
}
in := compose.Spec{Project: "app", Files: []string{"/app/data/stacks/app/compose.yaml"}, Via: "gone-orchestrator", Tool: "docker"}
got := repoint(context.Background(), db, in)
if got.Via != "" {
t.Errorf("via = %q, want it dropped", got.Via)
}
if len(got.Files) != 1 || got.Files[0] != host {
t.Errorf("files = %v, want [%s]", got.Files, host)
}
// Nothing remembered: the schedule is left exactly as it was rather than pointed at a
// guess. It will fail loudly, which is better than deploying the wrong file.
empty, err := store.Open(filepath.Join(root, "empty.json"))
if err != nil {
t.Fatal(err)
}
if got := repoint(context.Background(), empty, in); got.Via != in.Via || len(got.Files) != 1 || got.Files[0] != in.Files[0] {
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)
}
}