package main import ( "errors" "fmt" "os" "os/exec" "path/filepath" "runtime" "strings" "testing" ) // TestMain keeps the operator's own state out of the root package's tests. // // Three exported variables are dropped before any test runs. No shared // helper pins all three, and each steers a test that calls run() or a verb // directly, and every child that inherits os.Environ(). A test that wants // one sets it with t.Setenv, which still works after an Unsetenv. // // - CARLOS_SERVICE_TOKEN: resolveMode reads it, and a CI shell running // `go test` may well export one. // - CARLOS_CONFIG: it names the one config file read and switches // discovery off, so with it exported every test that relies on its // scratch directory holding no config reads the developer's file // instead, and ships to the console it names. // - CARLOS_DESTINATION: it selects a block in whatever config a test // wrote, and a config that does not define it is an error. // // CARLOS_CONSOLE and CARLOS_CREDENTIALS stay: the shared helpers pin both. // HOME stays too, because the tests' own `go build` needs the real one. // // The operator's .carlos/config: see isolateTempRoot. The binary refuses to // run rather than work under one. func TestMain(m *testing.M) { for _, name := range []string{"CARLOS_SERVICE_TOKEN", "CARLOS_CONFIG", "CARLOS_DESTINATION"} { os.Unsetenv(name) } // /var/tmp alone. /tmp is not offered: the tests build binaries into // the temp root, and on a machine where /tmp is shared memory the move // would put them there without anyone having asked. moved, cleanup, err := isolateTempRoot([]string{"/var/tmp"}) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } if moved != "" { fmt.Fprintln(os.Stderr, moved) } // The shared carlos binary (sharedCarlos) is built lazily and its // directory made by the build. What is fixed here, after isolateTempRoot // and before any test can change directory or HOME: the module to build, // the environment to build in, and where the directory goes. GOTMPDIR // first, because that is where t.TempDir() puts things. moduleRoot, _ = os.Getwd() carlosBuildEnv = os.Environ() if carlosTmpParent = os.Getenv("GOTMPDIR"); carlosTmpParent == "" { carlosTmpParent = os.TempDir() } code := m.Run() if carlosDir != "" { os.RemoveAll(carlosDir) } cleanup() os.Exit(code) } // tempRootPrefix names the directory isolateTempRoot makes. Its cleanup runs // only when the tests return: a log.Fatal, a panic or a timeout leaves that // one directory behind, and the line TestMain prints says where it is. const tempRootPrefix = "carlos-test-" // isolateTempRoot makes sure no .carlos/config sits above the directories // the tests' scratch space comes from, before any test asks for one. // // The tests pin HOME, but the CLI's config discovery (config.go // walkConfigFiles) walks up from the working directory, and that directory // is a t.TempDir() for every test that uses useTempCredentials or runs the // binary from a scratch cmd.Dir. Where the temp root is inside a logged-in // operator's home (TMPDIR=~/.cache/tmp/..., the default in a sandboxed agent // session) the walk reached the real ~/.carlos/config: two dozen tests // failed with "not logged in" to the operator's console, and one offered a // re-login to it and waited ten minutes (#226, #258). Pinning CARLOS_CONFIG // in the shared helpers is not the fix, because it switches discovery off // for the tests that write a .carlos/config of their own. // // There are two roots. os.TempDir() is TMPDIR. t.TempDir() is GOTMPDIR // when that is set and os.TempDir() only when it is not (testing.go // makeTempDir: os.MkdirTemp(os.Getenv("GOTMPDIR"), pattern)), so a clear // TMPDIR says nothing about a GOTMPDIR under a home. // // Roots with nothing above them are left alone, which is every CI run. // Otherwise the tests get one fresh directory under the first of bases that // is clear, and each variable whose root was not clear is pointed at it, so // child processes follow. An explicit GOTMPDIR is pointed there rather than // cleared: cleared, t.TempDir() would fall back to TMPDIR, which may be the // very place the developer set GOTMPDIR to keep the tests' binaries off. // moved says where, and the returned cleanup removes it. When no directory // can be made the variables are put back, the error names the ones to set, // and TestMain stops. func isolateTempRoot(bases []string) (moved string, cleanup func(), err error) { var why, names []string if p := configAbove(os.TempDir()); p != "" { why = append(why, fmt.Sprintf("%s is above the temp directory %s", p, os.TempDir())) names = append(names, "TMPDIR") } if root := os.Getenv("GOTMPDIR"); root != "" { if p := configAbove(root); p != "" { why = append(why, fmt.Sprintf("%s is above GOTMPDIR %s", p, root)) names = append(names, "GOTMPDIR") } } if len(names) == 0 { return "", func() {}, nil } type was struct { value string set bool } before := map[string]was{} for _, name := range names { v, ok := os.LookupEnv(name) before[name] = was{v, ok} } for _, base := range bases { dir, err := os.MkdirTemp(base, tempRootPrefix) if err != nil { continue } for _, name := range names { os.Setenv(name, dir) } // Asked of both roots again rather than assumed: the base may sit // under a config too, and not every platform reads TMPDIR. if configAbove(os.TempDir()) == "" && configAbove(os.Getenv("GOTMPDIR")) == "" { moved = fmt.Sprintf("carlos tests: %s, so the tests work in %s instead (an aborted run leaves it behind)", strings.Join(why, " and "), dir) return moved, func() { os.RemoveAll(dir) }, nil } os.Remove(dir) for name, w := range before { if w.set { os.Setenv(name, w.value) } else { os.Unsetenv(name) } } } return "", nil, fmt.Errorf("carlos tests: %s, and the CLI's config walk would read it. No directory could be made under %s to work in instead: set %s to a directory with no .carlos/config above it", strings.Join(why, " and "), strings.Join(bases, " or "), strings.Join(names, " and ")) } // configAbove is the first .carlos/config the CLI's discovery would read // walking up from dir, "" when there is none or dir is "". The walk is over // the path as written, which is what discovery follows after a t.Chdir, and // over the path with its symlinks resolved, which is what it follows when // PWD is unset. Anything but "does not exist" counts as there: discovery // fails on a config it cannot read. func configAbove(dir string) string { if dir == "" { return "" } if abs, err := filepath.Abs(dir); err == nil { dir = abs } starts := []string{dir} if resolved, err := filepath.EvalSymlinks(dir); err == nil && resolved != dir { starts = append(starts, resolved) } for _, d := range starts { for { path := filepath.Join(d, ".carlos", "config") if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { return path } parent := filepath.Dir(d) if parent == d { break } d = parent } } return "" } // TestMain's own promises, read back from inside a test: neither root has a // config above it, and none of the variables it drops came through. func TestMainLeavesNoOperatorState(t *testing.T) { for _, root := range []string{os.TempDir(), os.Getenv("GOTMPDIR"), t.TempDir()} { if p := configAbove(root); p != "" { t.Errorf("TestMain let the tests run with %s above %s", p, root) } } for _, name := range []string{"CARLOS_SERVICE_TOKEN", "CARLOS_CONFIG", "CARLOS_DESTINATION"} { if v, ok := os.LookupEnv(name); ok { t.Errorf("%s=%q reached the tests", name, v) } } } // The guard, against a tree of its own. A root under a config moves to the // first base that is clear: TMPDIR, GOTMPDIR or both, whichever was under // it, and only those. The cleanup removes the directory and nothing else. // With no clear base the binary is told to stop and what to set. func TestIsolateTempRoot(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("os.TempDir does not read TMPDIR on windows, so there is no moving it") } home := t.TempDir() planted := writeConfig(t, home, `{"console": "https://operator.example"}`) under := filepath.Join(home, "cache", "tmp") alsoUnder := filepath.Join(home, "var") clear, otherClear := t.TempDir(), t.TempDir() for _, d := range []string{under, alsoUnder} { if err := os.MkdirAll(d, 0o755); err != nil { t.Fatal(err) } } if got := configAbove(under); got != planted { t.Fatalf("configAbove(%s) = %q, want %s", under, got, planted) } bases := []string{filepath.Join(home, "missing"), alsoUnder, clear} for _, c := range []struct { name string tmpdir, gotmp string // as the run starts; "" is unset moveTmp bool moveGo bool }{ {"TMPDIR under a config, no GOTMPDIR", under, "", true, false}, {"TMPDIR under a config, GOTMPDIR clear", under, otherClear, true, false}, {"GOTMPDIR under a config, TMPDIR clear", otherClear, under, false, true}, {"both under a config", under, alsoUnder, true, true}, } { t.Run(c.name, func(t *testing.T) { t.Setenv("TMPDIR", c.tmpdir) t.Setenv("GOTMPDIR", c.gotmp) said, cleanup, err := isolateTempRoot(bases) if err != nil { t.Fatal(err) } made, _ := os.ReadDir(clear) if len(made) != 1 || !strings.HasPrefix(made[0].Name(), tempRootPrefix) { t.Fatalf("the clear base holds %v, want one %s directory", made, tempRootPrefix) } moved := filepath.Join(clear, made[0].Name()) wantTmp, wantGo := c.tmpdir, c.gotmp if c.moveTmp { wantTmp = moved } if c.moveGo { wantGo = moved } if os.TempDir() != wantTmp || os.Getenv("GOTMPDIR") != wantGo { t.Fatalf("TMPDIR=%s GOTMPDIR=%s, want %s and %s", os.TempDir(), os.Getenv("GOTMPDIR"), wantTmp, wantGo) } if !strings.Contains(said, planted) || !strings.Contains(said, moved) || strings.Contains(said, "above GOTMPDIR") != c.moveGo || strings.Contains(said, "above the temp directory") != c.moveTmp { t.Fatalf("said %q, want %s, %s and the roots that moved", said, planted, moved) } // What the guard is for: a test's own temp directory is clear now. t.Run("a test's temp directory", func(t *testing.T) { if p := configAbove(t.TempDir()); p != "" { t.Fatalf("t.TempDir() is still under %s", p) } }) if left, _ := os.ReadDir(alsoUnder); len(left) != 0 { t.Fatalf("the refused base kept %v", left) } cleanup() if _, err := os.Stat(moved); !errors.Is(err, os.ErrNotExist) { t.Fatalf("the cleanup left %s (%v)", moved, err) } if _, err := os.Stat(clear); err != nil { t.Fatalf("the cleanup took the base with it: %v", err) } }) } // No clear base: an error naming the config and each variable to set, // with both variables as they were. t.Setenv("TMPDIR", otherClear) t.Setenv("GOTMPDIR", under) _, _, err := isolateTempRoot([]string{alsoUnder}) if err == nil || !strings.Contains(err.Error(), planted) || !strings.Contains(err.Error(), "set GOTMPDIR to") { t.Fatalf("no clear base = %v, want an error naming %s and GOTMPDIR", err, planted) } if os.TempDir() != otherClear || os.Getenv("GOTMPDIR") != under { t.Fatalf("a refusal left TMPDIR=%s GOTMPDIR=%s", os.TempDir(), os.Getenv("GOTMPDIR")) } t.Setenv("TMPDIR", under) if _, _, err := isolateTempRoot([]string{alsoUnder}); err == nil || !strings.Contains(err.Error(), "set TMPDIR and GOTMPDIR to") { t.Fatalf("no clear base, both roots = %v, want both variables named", err) } if left, _ := os.ReadDir(alsoUnder); len(left) != 0 { t.Fatalf("the refused base kept %v", left) } // Clear roots are not moved at all. t.Setenv("TMPDIR", clear) t.Setenv("GOTMPDIR", otherClear) if said, _, err := isolateTempRoot([]string{filepath.Join(home, "missing")}); err != nil || said != "" || os.TempDir() != clear || os.Getenv("GOTMPDIR") != otherClear { t.Fatalf("clear roots = %s, %s, %q, %v; want them left alone", os.TempDir(), os.Getenv("GOTMPDIR"), said, err) } } // The shared binary is one file, found at one path, that is executable and // that nothing can write to, and it lives inside the directory the build // made under the temp root. Asked for from a test that has left the module, // it still builds, and it is built once for the whole process, whichever // package asked (TestSharedCarlosFromTheExternalPackage asks from the other). func TestSharedCarlos(t *testing.T) { if testing.Short() { t.Skip("builds a binary") } t.Chdir(t.TempDir()) first, second := sharedCarlos(t), sharedCarlos(t) if first != second { t.Fatalf("two calls gave %s and %s, want one path", first, second) } if n := carlosBuilds.Load(); n != 1 { t.Errorf("%d builds in this process, want 1", n) } info, err := os.Stat(first) if err != nil { t.Fatal(err) } if runtime.GOOS != "windows" && info.Mode().Perm() != 0o555 { t.Errorf("mode %v, want r-xr-xr-x: executable and not writable", info.Mode().Perm()) } if first != filepath.Join(carlosDir, "carlos") { t.Errorf("%s is not in the directory the build made, %s", first, carlosDir) } if rel, err := filepath.Rel(carlosTmpParent, first); err != nil || strings.HasPrefix(rel, "..") { t.Errorf("%s is not under %s", first, carlosTmpParent) } if os.Geteuid() != 0 { if f, err := os.OpenFile(first, os.O_WRONLY, 0); err == nil { f.Close() t.Error("the shared binary opened for writing") } } } // A failed build is the answer for every caller, not just the first: the // build runs once and its error is kept. func TestSharedCarlosKeepsABuildFailure(t *testing.T) { var s carlosShared builds := 0 failing := func() (string, error) { builds++ return "", errors.New("compile error in the module") } for i := 0; i < 3; i++ { if path, err := s.get(failing); path != "" || err == nil || !strings.Contains(err.Error(), "compile error") { t.Fatalf("call %d = %q, %v; want the build's error", i, path, err) } } if builds != 1 { t.Fatalf("the failing build ran %d times, want 1", builds) } } // Where the directory goes, and when it is made, read from a re-executed test // binary with scratch roots of its own. GOTMPDIR wins over TMPDIR, as it does // for t.TempDir(), so the binary is not put on a shared RAM disk that TMPDIR // names. A process that never asks for the binary makes nothing at all. // Either way the roots are empty again once the process has exited. func TestSharedCarlosLocation(t *testing.T) { if os.Getenv("CARLOS_SHARED_CHILD") != "" { fmt.Println("SHARED_CARLOS=" + sharedCarlos(t)) return } if testing.Short() { t.Skip("builds a binary") } // The child skips without a toolchain and prints no path; say so here // rather than fail on the missing line. if _, err := exec.LookPath("go"); err != nil { t.Skip("go toolchain not on PATH") } run := func(args ...string) (tmp, gotmp, out string) { t.Helper() tmp, gotmp = t.TempDir(), t.TempDir() cmd := exec.Command(os.Args[0], args...) cmd.Dir = moduleRoot cmd.Env = append(os.Environ(), "TMPDIR="+tmp, "GOTMPDIR="+gotmp, "CARLOS_SHARED_CHILD=1") b, err := cmd.CombinedOutput() if err != nil { t.Fatalf("child %v: %v\n%s", args, err, b) } for _, root := range []string{tmp, gotmp} { if left, _ := os.ReadDir(root); len(left) != 0 { t.Errorf("the child left %v in %s", left, root) } } return tmp, gotmp, string(b) } _, _, out := run("-test.run=^$") if strings.Contains(out, "SHARED_CARLOS=") { t.Fatalf("a child that ran no test built the binary:\n%s", out) } tmp, gotmp, out := run("-test.run=^TestSharedCarlosLocation$") var path string for _, line := range strings.Split(out, "\n") { if p, ok := strings.CutPrefix(line, "SHARED_CARLOS="); ok { path = p } } if path == "" || !strings.HasPrefix(path, gotmp+string(filepath.Separator)) { t.Errorf("the binary is at %q, want it under GOTMPDIR %s (TMPDIR was %s)", path, gotmp, tmp) } }