Every stack on core and grid is labelled with a path inside Dockhand's filesystem — /app/data/stacks/… — because Dockhand ran compose from inside its own container. Ham Lab resolves those by walking the bind mounts of running containers, which works right up until the container doing the mounting is removed. Retiring Dockhand would, in one step, make seventeen stacks on core and five on grid unfindable. So the mounts are written down as they are seen, and a path no live container explains is looked for in what was seen before. A remembered mount resolves with no container to run in, which is the right answer: compose runs on the host, against the host's own copy of the file. The same problem bites a scheduled update, which captures how to invoke compose when it is made so the cron job never has to rediscover the world at 3am. repoint() checks the named container is still running and falls back the same way if it is not — and pointedly leaves the spec alone if nothing explains the path, so it fails loudly rather than deploying a guess. Out in cron the record is read and never added to: the daemon owns state.json and would clobber anything written from there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
122 lines
4.2 KiB
Go
122 lines
4.2 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"hamlab/internal/compose"
|
|
"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)
|
|
}
|
|
}
|