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 /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) } }