carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
4 "errors"
5 "fmt"
6 "os"
7 "os/exec"
8 "path/filepath"
9 "runtime"
10 "strings"
11 "testing"
12)
13
14// TestMain keeps the operator's own state out of the root package's tests.
15//
16// Three exported variables are dropped before any test runs. No shared
17// helper pins all three, and each steers a test that calls run() or a verb
18// directly, and every child that inherits os.Environ(). A test that wants
19// one sets it with t.Setenv, which still works after an Unsetenv.
20//
21// - CARLOS_SERVICE_TOKEN: resolveMode reads it, and a CI shell running
22// `go test` may well export one.
23// - CARLOS_CONFIG: it names the one config file read and switches
24// discovery off, so with it exported every test that relies on its
25// scratch directory holding no config reads the developer's file
26// instead, and ships to the console it names.
27// - CARLOS_DESTINATION: it selects a block in whatever config a test
28// wrote, and a config that does not define it is an error.
29//
30// CARLOS_CONSOLE and CARLOS_CREDENTIALS stay: the shared helpers pin both.
31// HOME stays too, because the tests' own `go build` needs the real one.
32//
33// The operator's .carlos/config: see isolateTempRoot. The binary refuses to
34// run rather than work under one.
35func TestMain(m *testing.M) {
36 for _, name := range []string{"CARLOS_SERVICE_TOKEN", "CARLOS_CONFIG", "CARLOS_DESTINATION"} {
37 os.Unsetenv(name)
38 }
39 // /var/tmp alone. /tmp is not offered: the tests build binaries into
40 // the temp root, and on a machine where /tmp is shared memory the move
41 // would put them there without anyone having asked.
42 moved, cleanup, err := isolateTempRoot([]string{"/var/tmp"})
43 if err != nil {
44 fmt.Fprintln(os.Stderr, err)
45 os.Exit(1)
46 }
47 if moved != "" {
48 fmt.Fprintln(os.Stderr, moved)
49 }
50 // The shared carlos binary (sharedCarlos) is built lazily and its
51 // directory made by the build. What is fixed here, after isolateTempRoot
52 // and before any test can change directory or HOME: the module to build,
53 // the environment to build in, and where the directory goes. GOTMPDIR
54 // first, because that is where t.TempDir() puts things.
55 moduleRoot, _ = os.Getwd()
56 carlosBuildEnv = os.Environ()
57 if carlosTmpParent = os.Getenv("GOTMPDIR"); carlosTmpParent == "" {
58 carlosTmpParent = os.TempDir()
59 }
60 code := m.Run()
61 if carlosDir != "" {
62 os.RemoveAll(carlosDir)
63 }
64 cleanup()
65 os.Exit(code)
66}
67
68// tempRootPrefix names the directory isolateTempRoot makes. Its cleanup runs
69// only when the tests return: a log.Fatal, a panic or a timeout leaves that
70// one directory behind, and the line TestMain prints says where it is.
71const tempRootPrefix = "carlos-test-"
72
73// isolateTempRoot makes sure no .carlos/config sits above the directories
74// the tests' scratch space comes from, before any test asks for one.
75//
76// The tests pin HOME, but the CLI's config discovery (config.go
77// walkConfigFiles) walks up from the working directory, and that directory
78// is a t.TempDir() for every test that uses useTempCredentials or runs the
79// binary from a scratch cmd.Dir. Where the temp root is inside a logged-in
80// operator's home (TMPDIR=~/.cache/tmp/..., the default in a sandboxed agent
81// session) the walk reached the real ~/.carlos/config: two dozen tests
82// failed with "not logged in" to the operator's console, and one offered a
83// re-login to it and waited ten minutes (#226, #258). Pinning CARLOS_CONFIG
84// in the shared helpers is not the fix, because it switches discovery off
85// for the tests that write a .carlos/config of their own.
86//
87// There are two roots. os.TempDir() is TMPDIR. t.TempDir() is GOTMPDIR
88// when that is set and os.TempDir() only when it is not (testing.go
89// makeTempDir: os.MkdirTemp(os.Getenv("GOTMPDIR"), pattern)), so a clear
90// TMPDIR says nothing about a GOTMPDIR under a home.
91//
92// Roots with nothing above them are left alone, which is every CI run.
93// Otherwise the tests get one fresh directory under the first of bases that
94// is clear, and each variable whose root was not clear is pointed at it, so
95// child processes follow. An explicit GOTMPDIR is pointed there rather than
96// cleared: cleared, t.TempDir() would fall back to TMPDIR, which may be the
97// very place the developer set GOTMPDIR to keep the tests' binaries off.
98// moved says where, and the returned cleanup removes it. When no directory
99// can be made the variables are put back, the error names the ones to set,
100// and TestMain stops.
101func isolateTempRoot(bases []string) (moved string, cleanup func(), err error) {
102 var why, names []string
103 if p := configAbove(os.TempDir()); p != "" {
104 why = append(why, fmt.Sprintf("%s is above the temp directory %s", p, os.TempDir()))
105 names = append(names, "TMPDIR")
106 }
107 if root := os.Getenv("GOTMPDIR"); root != "" {
108 if p := configAbove(root); p != "" {
109 why = append(why, fmt.Sprintf("%s is above GOTMPDIR %s", p, root))
110 names = append(names, "GOTMPDIR")
111 }
112 }
113 if len(names) == 0 {
114 return "", func() {}, nil
115 }
116 type was struct {
117 value string
118 set bool
119 }
120 before := map[string]was{}
121 for _, name := range names {
122 v, ok := os.LookupEnv(name)
123 before[name] = was{v, ok}
124 }
125 for _, base := range bases {
126 dir, err := os.MkdirTemp(base, tempRootPrefix)
127 if err != nil {
128 continue
129 }
130 for _, name := range names {
131 os.Setenv(name, dir)
132 }
133 // Asked of both roots again rather than assumed: the base may sit
134 // under a config too, and not every platform reads TMPDIR.
135 if configAbove(os.TempDir()) == "" && configAbove(os.Getenv("GOTMPDIR")) == "" {
136 moved = fmt.Sprintf("carlos tests: %s, so the tests work in %s instead (an aborted run leaves it behind)", strings.Join(why, " and "), dir)
137 return moved, func() { os.RemoveAll(dir) }, nil
138 }
139 os.Remove(dir)
140 for name, w := range before {
141 if w.set {
142 os.Setenv(name, w.value)
143 } else {
144 os.Unsetenv(name)
145 }
146 }
147 }
148 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",
149 strings.Join(why, " and "), strings.Join(bases, " or "), strings.Join(names, " and "))
150}
151
152// configAbove is the first .carlos/config the CLI's discovery would read
153// walking up from dir, "" when there is none or dir is "". The walk is over
154// the path as written, which is what discovery follows after a t.Chdir, and
155// over the path with its symlinks resolved, which is what it follows when
156// PWD is unset. Anything but "does not exist" counts as there: discovery
157// fails on a config it cannot read.
158func configAbove(dir string) string {
159 if dir == "" {
160 return ""
161 }
162 if abs, err := filepath.Abs(dir); err == nil {
163 dir = abs
164 }
165 starts := []string{dir}
166 if resolved, err := filepath.EvalSymlinks(dir); err == nil && resolved != dir {
167 starts = append(starts, resolved)
168 }
169 for _, d := range starts {
170 for {
171 path := filepath.Join(d, ".carlos", "config")
172 if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
173 return path
174 }
175 parent := filepath.Dir(d)
176 if parent == d {
177 break
178 }
179 d = parent
180 }
181 }
182 return ""
183}
184
185// TestMain's own promises, read back from inside a test: neither root has a
186// config above it, and none of the variables it drops came through.
187func TestMainLeavesNoOperatorState(t *testing.T) {
188 for _, root := range []string{os.TempDir(), os.Getenv("GOTMPDIR"), t.TempDir()} {
189 if p := configAbove(root); p != "" {
190 t.Errorf("TestMain let the tests run with %s above %s", p, root)
191 }
192 }
193 for _, name := range []string{"CARLOS_SERVICE_TOKEN", "CARLOS_CONFIG", "CARLOS_DESTINATION"} {
194 if v, ok := os.LookupEnv(name); ok {
195 t.Errorf("%s=%q reached the tests", name, v)
196 }
197 }
198}
199
200// The guard, against a tree of its own. A root under a config moves to the
201// first base that is clear: TMPDIR, GOTMPDIR or both, whichever was under
202// it, and only those. The cleanup removes the directory and nothing else.
203// With no clear base the binary is told to stop and what to set.
204func TestIsolateTempRoot(t *testing.T) {
205 if runtime.GOOS == "windows" {
206 t.Skip("os.TempDir does not read TMPDIR on windows, so there is no moving it")
207 }
208 home := t.TempDir()
209 planted := writeConfig(t, home, `{"console": "https://operator.example"}`)
210 under := filepath.Join(home, "cache", "tmp")
211 alsoUnder := filepath.Join(home, "var")
212 clear, otherClear := t.TempDir(), t.TempDir()
213 for _, d := range []string{under, alsoUnder} {
214 if err := os.MkdirAll(d, 0o755); err != nil {
215 t.Fatal(err)
216 }
217 }
218 if got := configAbove(under); got != planted {
219 t.Fatalf("configAbove(%s) = %q, want %s", under, got, planted)
220 }
221 bases := []string{filepath.Join(home, "missing"), alsoUnder, clear}
222
223 for _, c := range []struct {
224 name string
225 tmpdir, gotmp string // as the run starts; "" is unset
226 moveTmp bool
227 moveGo bool
228 }{
229 {"TMPDIR under a config, no GOTMPDIR", under, "", true, false},
230 {"TMPDIR under a config, GOTMPDIR clear", under, otherClear, true, false},
231 {"GOTMPDIR under a config, TMPDIR clear", otherClear, under, false, true},
232 {"both under a config", under, alsoUnder, true, true},
233 } {
234 t.Run(c.name, func(t *testing.T) {
235 t.Setenv("TMPDIR", c.tmpdir)
236 t.Setenv("GOTMPDIR", c.gotmp)
237 said, cleanup, err := isolateTempRoot(bases)
238 if err != nil {
239 t.Fatal(err)
240 }
241 made, _ := os.ReadDir(clear)
242 if len(made) != 1 || !strings.HasPrefix(made[0].Name(), tempRootPrefix) {
243 t.Fatalf("the clear base holds %v, want one %s directory", made, tempRootPrefix)
244 }
245 moved := filepath.Join(clear, made[0].Name())
246 wantTmp, wantGo := c.tmpdir, c.gotmp
247 if c.moveTmp {
248 wantTmp = moved
249 }
250 if c.moveGo {
251 wantGo = moved
252 }
253 if os.TempDir() != wantTmp || os.Getenv("GOTMPDIR") != wantGo {
254 t.Fatalf("TMPDIR=%s GOTMPDIR=%s, want %s and %s", os.TempDir(), os.Getenv("GOTMPDIR"), wantTmp, wantGo)
255 }
256 if !strings.Contains(said, planted) || !strings.Contains(said, moved) ||
257 strings.Contains(said, "above GOTMPDIR") != c.moveGo || strings.Contains(said, "above the temp directory") != c.moveTmp {
258 t.Fatalf("said %q, want %s, %s and the roots that moved", said, planted, moved)
259 }
260 // What the guard is for: a test's own temp directory is clear now.
261 t.Run("a test's temp directory", func(t *testing.T) {
262 if p := configAbove(t.TempDir()); p != "" {
263 t.Fatalf("t.TempDir() is still under %s", p)
264 }
265 })
266 if left, _ := os.ReadDir(alsoUnder); len(left) != 0 {
267 t.Fatalf("the refused base kept %v", left)
268 }
269 cleanup()
270 if _, err := os.Stat(moved); !errors.Is(err, os.ErrNotExist) {
271 t.Fatalf("the cleanup left %s (%v)", moved, err)
272 }
273 if _, err := os.Stat(clear); err != nil {
274 t.Fatalf("the cleanup took the base with it: %v", err)
275 }
276 })
277 }
278
279 // No clear base: an error naming the config and each variable to set,
280 // with both variables as they were.
281 t.Setenv("TMPDIR", otherClear)
282 t.Setenv("GOTMPDIR", under)
283 _, _, err := isolateTempRoot([]string{alsoUnder})
284 if err == nil || !strings.Contains(err.Error(), planted) || !strings.Contains(err.Error(), "set GOTMPDIR to") {
285 t.Fatalf("no clear base = %v, want an error naming %s and GOTMPDIR", err, planted)
286 }
287 if os.TempDir() != otherClear || os.Getenv("GOTMPDIR") != under {
288 t.Fatalf("a refusal left TMPDIR=%s GOTMPDIR=%s", os.TempDir(), os.Getenv("GOTMPDIR"))
289 }
290 t.Setenv("TMPDIR", under)
291 if _, _, err := isolateTempRoot([]string{alsoUnder}); err == nil || !strings.Contains(err.Error(), "set TMPDIR and GOTMPDIR to") {
292 t.Fatalf("no clear base, both roots = %v, want both variables named", err)
293 }
294 if left, _ := os.ReadDir(alsoUnder); len(left) != 0 {
295 t.Fatalf("the refused base kept %v", left)
296 }
297
298 // Clear roots are not moved at all.
299 t.Setenv("TMPDIR", clear)
300 t.Setenv("GOTMPDIR", otherClear)
301 if said, _, err := isolateTempRoot([]string{filepath.Join(home, "missing")}); err != nil || said != "" || os.TempDir() != clear || os.Getenv("GOTMPDIR") != otherClear {
302 t.Fatalf("clear roots = %s, %s, %q, %v; want them left alone", os.TempDir(), os.Getenv("GOTMPDIR"), said, err)
303 }
304}
305
306// The shared binary is one file, found at one path, that is executable and
307// that nothing can write to, and it lives inside the directory the build
308// made under the temp root. Asked for from a test that has left the module,
309// it still builds, and it is built once for the whole process, whichever
310// package asked (TestSharedCarlosFromTheExternalPackage asks from the other).
311func TestSharedCarlos(t *testing.T) {
312 if testing.Short() {
313 t.Skip("builds a binary")
314 }
315 t.Chdir(t.TempDir())
316 first, second := sharedCarlos(t), sharedCarlos(t)
317 if first != second {
318 t.Fatalf("two calls gave %s and %s, want one path", first, second)
319 }
320 if n := carlosBuilds.Load(); n != 1 {
321 t.Errorf("%d builds in this process, want 1", n)
322 }
323 info, err := os.Stat(first)
324 if err != nil {
325 t.Fatal(err)
326 }
327 if runtime.GOOS != "windows" && info.Mode().Perm() != 0o555 {
328 t.Errorf("mode %v, want r-xr-xr-x: executable and not writable", info.Mode().Perm())
329 }
330 if first != filepath.Join(carlosDir, "carlos") {
331 t.Errorf("%s is not in the directory the build made, %s", first, carlosDir)
332 }
333 if rel, err := filepath.Rel(carlosTmpParent, first); err != nil || strings.HasPrefix(rel, "..") {
334 t.Errorf("%s is not under %s", first, carlosTmpParent)
335 }
336 if os.Geteuid() != 0 {
337 if f, err := os.OpenFile(first, os.O_WRONLY, 0); err == nil {
338 f.Close()
339 t.Error("the shared binary opened for writing")
340 }
341 }
342}
343
344// A failed build is the answer for every caller, not just the first: the
345// build runs once and its error is kept.
346func TestSharedCarlosKeepsABuildFailure(t *testing.T) {
347 var s carlosShared
348 builds := 0
349 failing := func() (string, error) {
350 builds++
351 return "", errors.New("compile error in the module")
352 }
353 for i := 0; i < 3; i++ {
354 if path, err := s.get(failing); path != "" || err == nil || !strings.Contains(err.Error(), "compile error") {
355 t.Fatalf("call %d = %q, %v; want the build's error", i, path, err)
356 }
357 }
358 if builds != 1 {
359 t.Fatalf("the failing build ran %d times, want 1", builds)
360 }
361}
362
363// Where the directory goes, and when it is made, read from a re-executed test
364// binary with scratch roots of its own. GOTMPDIR wins over TMPDIR, as it does
365// for t.TempDir(), so the binary is not put on a shared RAM disk that TMPDIR
366// names. A process that never asks for the binary makes nothing at all.
367// Either way the roots are empty again once the process has exited.
368func TestSharedCarlosLocation(t *testing.T) {
369 if os.Getenv("CARLOS_SHARED_CHILD") != "" {
370 fmt.Println("SHARED_CARLOS=" + sharedCarlos(t))
371 return
372 }
373 if testing.Short() {
374 t.Skip("builds a binary")
375 }
376 // The child skips without a toolchain and prints no path; say so here
377 // rather than fail on the missing line.
378 if _, err := exec.LookPath("go"); err != nil {
379 t.Skip("go toolchain not on PATH")
380 }
381 run := func(args ...string) (tmp, gotmp, out string) {
382 t.Helper()
383 tmp, gotmp = t.TempDir(), t.TempDir()
384 cmd := exec.Command(os.Args[0], args...)
385 cmd.Dir = moduleRoot
386 cmd.Env = append(os.Environ(), "TMPDIR="+tmp, "GOTMPDIR="+gotmp, "CARLOS_SHARED_CHILD=1")
387 b, err := cmd.CombinedOutput()
388 if err != nil {
389 t.Fatalf("child %v: %v\n%s", args, err, b)
390 }
391 for _, root := range []string{tmp, gotmp} {
392 if left, _ := os.ReadDir(root); len(left) != 0 {
393 t.Errorf("the child left %v in %s", left, root)
394 }
395 }
396 return tmp, gotmp, string(b)
397 }
398
399 _, _, out := run("-test.run=^$")
400 if strings.Contains(out, "SHARED_CARLOS=") {
401 t.Fatalf("a child that ran no test built the binary:\n%s", out)
402 }
403
404 tmp, gotmp, out := run("-test.run=^TestSharedCarlosLocation$")
405 var path string
406 for _, line := range strings.Split(out, "\n") {
407 if p, ok := strings.CutPrefix(line, "SHARED_CARLOS="); ok {
408 path = p
409 }
410 }
411 if path == "" || !strings.HasPrefix(path, gotmp+string(filepath.Separator)) {
412 t.Errorf("the binary is at %q, want it under GOTMPDIR %s (TMPDIR was %s)", path, gotmp, tmp)
413 }
414}
415