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