diff --git a/modules/mesh-build-go/cmd/mesh-build-go/installed_toolchain_test.go b/modules/mesh-build-go/cmd/mesh-build-go/installed_toolchain_test.go new file mode 100644 index 00000000..22467181 --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/installed_toolchain_test.go @@ -0,0 +1,170 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/forgekey" + "git.novox.be/novox/mesh-sdk/go/health" +) + +// toolchainValues are the GOTOOLCHAIN settings of an environment. +func toolchainValues(env []string) []string { + var out []string + for _, e := range env { + if strings.HasPrefix(e, "GOTOOLCHAIN=") { + out = append(out, e) + } + } + return out +} + +// 1572/AC1 (ADR 0477 §1): every environment a step gives its go commands says GOTOOLCHAIN=local, once, and names no +// release. +func TestEveryStepEnvironmentSaysGotoolchainLocal(t *testing.T) { + m := &Module{Cache: t.TempDir()} + vend := t.TempDir() + os.MkdirAll(filepath.Join(vend, "vendor"), 0o755) + os.WriteFile(filepath.Join(vend, "vendor", "modules.txt"), nil, 0o644) + plain := t.TempDir() + envs := map[string][]string{ + "goEnv": m.goEnv(), "goEnvFor(plain)": m.goEnvFor(plain), "goEnvFor(vendor)": m.goEnvFor(vend), + "buildEnv(plain)": m.buildEnv(plain), "buildEnv(vendor)": m.buildEnv(vend), "probeEnv": probeEnv(t.TempDir()), + } + for name, env := range envs { + if got := toolchainValues(env); len(got) != 1 || got[0] != "GOTOOLCHAIN=local" { + t.Errorf("%s: GOTOOLCHAIN settings %v, want exactly GOTOOLCHAIN=local", name, got) + } + } +} + +// 1572/AC1: the fetch step runs no `go version` and downloads no toolchain, and each go command says GOTOOLCHAIN=local. +func TestTheFetchStepsGoCommandsSayGotoolchainLocalAndFetchNoToolchain(t *testing.T) { + f := &fakeStep{gomods: "go.mod\n"} + m, states := fetchModule(t, f) + if _, err := fetchTool(t, m)(fetchArgs); err != nil { + t.Fatal(err) + } + if st := states.waitEnd(t); st.State != delivery.StepPassed { + t.Fatalf("the step ended %s: %s", st.State, st.Reason) + } + goCalls := 0 + for _, c := range f.calls { + if strings.HasSuffix(c.line, " version") || strings.Contains(c.line, " get toolchain") || strings.Contains(c.line, " toolchain") { + t.Errorf("the fetch step ran %q", c.line) + } + if strings.HasPrefix(c.line, "go ") { + goCalls++ + if got := toolchainValues(c.env); len(got) != 1 || got[0] != "GOTOOLCHAIN=local" { + t.Errorf("%s: GOTOOLCHAIN settings %v", c.line, got) + } + } + } + if goCalls == 0 { + t.Error("no go command ran") + } +} + +type probeSpy struct { + env []string + line string + ran forgekey.Ran + left int64 +} + +func (p *probeSpy) run(ctx context.Context, env []string, name string, args ...string) forgekey.Ran { + p.env, p.line = env, name+" "+strings.Join(args, " ") + if d, ok := ctx.Deadline(); ok { + p.left = int64(d.Sub(time.Now())) + } + return p.ran +} + +// 1572/AC1 (ADR 0477 §1): the probe runs `go version` with GOTOOLCHAIN=local and a short bound, with an empty cache: +// it fetches nothing, so there is no long bound for a download. +func TestTheProbeRunsGoVersionLocalWithOneShortBound(t *testing.T) { + p := &probeSpy{ran: forgekey.Ran{Stdout: "go version go1.27.1-X:nodwarf5 linux/amd64\n"}} + v, err := probeGo(context.Background(), p.run, "/home/x", t.TempDir()) + if err != nil || v != "go1.27.1-X:nodwarf5" { + t.Fatalf("= %q, %v", v, err) + } + if got := toolchainValues(p.env); len(got) != 1 || got[0] != "GOTOOLCHAIN=local" || p.line != "go version" { + t.Errorf("command %q, GOTOOLCHAIN settings %v", p.line, got) + } + if p.left > int64(ProbeTimeout) || p.left <= 0 { + t.Errorf("the probe's bound is %v, want at most %v", p.left, int64(ProbeTimeout)) + } +} + +// 1572/AC2 (ADR 0477 §2): neither the recipe nor the module names a Go version: the recipe has no field for one. +func TestTheRecipeHasNoFieldForAToolchainVersion(t *testing.T) { + typ := reflect.TypeOf(Recipe{}) + for i := 0; i < typ.NumField(); i++ { + if n := typ.Field(i).Name; n == "Go" || strings.Contains(n, "Toolchain") { + t.Errorf("Recipe has the field %s", n) + } + } +} + +// 1572/AC2 and AC4: whatever version the node has installed, the module stays healthy, names that version in its health, +// and writes it to .go of the toolchains state. +func TestAnyInstalledGoIsHealthyNamedAndReported(t *testing.T) { + for _, installed := range []string{"go1.27.1", "go1.27.2", "go1.28.0", "go1.27.1-X:nodwarf5"} { + t.Run(installed, func(t *testing.T) { + states := newStates() + m := moduleWith(states, installed) + if err := m.Check(context.Background()); err != nil { + t.Fatalf("Check refused: %v", err) + } + a := m.Health(context.Background()) + if !a.Healthy || a.State != health.StateHealthy || !strings.Contains(a.Why, installed) { + t.Errorf("health = %+v, want healthy naming %s", a, installed) + } + tc, ok := states.toolchain("ace.go") + if !ok || tc.Version != installed || tc.Recipe != m.Recipe.Version() { + t.Errorf("ace.go = %+v, %v; want version %s and recipe %s", tc, ok, installed, m.Recipe.Version()) + } + ran := false + run := func(context.Context, map[string]any) (any, error) { ran = true; return "done", nil } + if _, err := m.Short(run)(nil); err != nil || !ran { + t.Errorf("a step on go %s: ran %v, err %v", installed, ran, err) + } + }) + } +} + +// 1572/AC3 (ADR 0477 §3): the recipe version is made without a toolchain version: the module's recipe is the same +// whatever go is installed, and a toolchain version set on the recipe by any name moves nothing; it moves with the +// steps' version. +func TestTheRecipeVersionIgnoresTheToolchainVersion(t *testing.T) { + base := Current() + v := base.Version() + for _, installed := range []string{"go1.27.1", "go1.28.0"} { + m := moduleWith(newStates(), installed) + m.Recipe = Current() + if err := m.Check(context.Background()); err != nil { + t.Fatal(err) + } + if got := m.Recipe.Version(); got != v { + t.Errorf("with go %s installed the recipe version is %s, want %s", installed, got, v) + } + } + r := base + if f := reflect.ValueOf(&r).Elem().FieldByName("Go"); f.IsValid() { + f.SetString("go9.9.9") + if r.Version() != v { + t.Errorf("a toolchain version on the recipe moved its version") + } + } + r = base + r.Steps += "x" + if r.Version() == v { + t.Errorf("the steps' version did not move the recipe version") + } +} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/toolchain_env_test.go b/modules/mesh-build-go/cmd/mesh-build-go/toolchain_env_test.go deleted file mode 100644 index d1eefa4c..00000000 --- a/modules/mesh-build-go/cmd/mesh-build-go/toolchain_env_test.go +++ /dev/null @@ -1,64 +0,0 @@ -package main - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -// 1560 (ADR 0476 §1): every environment a step gives its go commands names the recipe's release as the toolchain -// and never GOTOOLCHAIN=local, which would run the distribution's build. -func TestEveryStepEnvironmentCarriesTheRecipesRelease(t *testing.T) { - m := &Module{Cache: t.TempDir()} - vend := t.TempDir() - os.MkdirAll(filepath.Join(vend, "vendor"), 0o755) - os.WriteFile(filepath.Join(vend, "vendor", "modules.txt"), nil, 0o644) - plain := t.TempDir() - envs := map[string][]string{ - "goEnv": m.goEnv(), "goEnvFor(plain)": m.goEnvFor(plain), "goEnvFor(vendor)": m.goEnvFor(vend), - "buildEnv(plain)": m.buildEnv(plain), "buildEnv(vendor)": m.buildEnv(vend), - } - for name, env := range envs { - joined := "\n" + strings.Join(env, "\n") + "\n" - if !strings.Contains(joined, "\nGOTOOLCHAIN="+RecipeGo+"\n") { - t.Errorf("%s lacks GOTOOLCHAIN=%s:\n%s", name, RecipeGo, joined) - } - if strings.Contains(joined, "GOTOOLCHAIN=local") { - t.Errorf("%s still says GOTOOLCHAIN=local:\n%s", name, joined) - } - if !strings.Contains(joined, "\nGOMODCACHE="+filepath.Join(m.Cache, "gomod")+"\n") { - t.Errorf("%s lacks the module cache:\n%s", name, joined) - } - } -} - -// 1560 (ADR 0476 §3): the fetch step downloads the change's locked dependencies and nothing else: no `go version` -// and no toolchain download among its commands, and each go command carries the recipe's release. -func TestTheFetchStepsCommandsFetchNoToolchain(t *testing.T) { - f := &fakeStep{gomods: "go.mod\n"} - m, states := fetchModule(t, f) - if _, err := fetchTool(t, m)(fetchArgs); err != nil { - t.Fatal(err) - } - if st := states.waitEnd(t); st.State != delivery.StepPassed { - t.Fatalf("the step ended %s: %s", st.State, st.Reason) - } - goCalls := 0 - for _, c := range f.calls { - if strings.Contains(c.line, "version") || strings.Contains(c.line, "toolchain") { - t.Errorf("the fetch step ran %q", c.line) - } - if strings.HasPrefix(c.line, "go ") { - goCalls++ - if env := strings.Join(c.env, " "); !strings.Contains(env, "GOTOOLCHAIN="+RecipeGo) || strings.Contains(env, "GOTOOLCHAIN=local") { - t.Errorf("%s: environment %s", c.line, env) - } - } - } - if goCalls == 0 { - t.Error("no go command ran") - } -} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/toolchain_integration_test.go b/modules/mesh-build-go/cmd/mesh-build-go/toolchain_integration_test.go deleted file mode 100644 index 0dee6370..00000000 --- a/modules/mesh-build-go/cmd/mesh-build-go/toolchain_integration_test.go +++ /dev/null @@ -1,55 +0,0 @@ -//go:build integration - -package main - -import ( - "context" - "io/fs" - "os" - "path/filepath" - "strings" - "testing" -) - -// 1560 (ADR 0476 §3): the probe, with a fresh module cache and network, lets the installed Go put -// golang.org/toolchain@v0.0.1-.- into the cache and reads the release's version; then a step -// with the cache read-only and no network (GOPROXY=off) reports the recipe's version from it. -func TestTheProbePutsTheReleaseIntoTheCacheAndAStepReadsItOffline(t *testing.T) { - home, cache := t.TempDir(), t.TempDir() - t.Cleanup(func() { // the Go module cache is read-only by Go's own doing - filepath.WalkDir(cache, func(p string, d fs.DirEntry, err error) error { - if err == nil && d.IsDir() { - os.Chmod(p, 0o755) - } - return nil - }) - }) - v, err := ProbeGo(home, cache)(context.Background()) - if err != nil { - t.Skipf("NOT RUN: ADR 0476: the probe could not put %s into the module cache where this check runs (%v); proven live on a build node", RecipeGo, err) - } - if v != RecipeGo { - t.Fatalf("probe = %q; want %s", v, RecipeGo) - } - dirs, _ := filepath.Glob(filepath.Join(cache, "gomod", "golang.org", "toolchain@v0.0.1-"+RecipeGo+".*")) - if len(dirs) != 1 { - t.Fatalf("the cache holds %v, want golang.org/toolchain@v0.0.1-%s.*", dirs, RecipeGo) - } - filepath.WalkDir(cache, func(p string, d fs.DirEntry, err error) error { - if err == nil { - if info, e := d.Info(); e == nil { - os.Chmod(p, info.Mode()&^0o222) - } - } - return nil - }) - r := StepRunner(home)(context.Background(), []string{ - "GOTOOLCHAIN=" + RecipeGo, "GOMODCACHE=" + filepath.Join(cache, "gomod"), "GOCACHE=" + t.TempDir(), "GOPROXY=off", "GOFLAGS=-mod=mod", - }, "go", "version") - if r.Status != 0 || r.Err != "" { - t.Fatalf("go version offline: %s %s", r.Err, r.Stderr) - } - if got, err := parseGoVersion(r.Stdout); err != nil || got != RecipeGo || !strings.Contains(r.Stdout, RecipeGo) { - t.Errorf("the step reports %q, %v; want %s", r.Stdout, err, RecipeGo) - } -} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/toolchain_probe_test.go b/modules/mesh-build-go/cmd/mesh-build-go/toolchain_probe_test.go deleted file mode 100644 index f2b0a4d5..00000000 --- a/modules/mesh-build-go/cmd/mesh-build-go/toolchain_probe_test.go +++ /dev/null @@ -1,135 +0,0 @@ -package main - -import ( - "context" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "git.novox.be/novox/mesh-sdk/go/forgekey" - "git.novox.be/novox/mesh-sdk/go/health" -) - -// probeFake is a runner that records the command and answers by rule. -type probeFake struct { - env []string - line string - ran forgekey.Ran -} - -func (p *probeFake) run(_ context.Context, env []string, name string, args ...string) forgekey.Ran { - p.env, p.line = env, name+" "+strings.Join(args, " ") - return p.ran -} - -// 1560 (ADR 0476 §2, §3): the probe runs `go version` with the recipe's release as the toolchain and the module's -// cache as GOMODCACHE, in an environment built from scratch (nothing of the process's, no GOTOOLCHAIN=local). -func TestTheProbeRunsGoVersionWithTheRecipesReleaseAndTheModuleCache(t *testing.T) { - p := &probeFake{ran: forgekey.Ran{Stdout: "go version go1.27.1 linux/amd64\n"}} - cache := t.TempDir() - v, err := probeGo(context.Background(), p.run, "/home/x", cache) - if err != nil || v != "go1.27.1" { - t.Fatalf("= %q, %v", v, err) - } - env := "\n" + strings.Join(p.env, "\n") + "\n" - for _, want := range []string{"\nGOTOOLCHAIN=" + RecipeGo + "\n", "\nGOMODCACHE=" + filepath.Join(cache, "gomod") + "\n", "\nHOME=/home/x\n"} { - if !strings.Contains(env, want) { - t.Errorf("the probe's environment lacks %q:\n%s", want, env) - } - } - if strings.Contains(env, "GOTOOLCHAIN=local") || p.line != "go version" { - t.Errorf("command %q, environment:\n%s", p.line, env) - } -} - -// 1560: the release's own output parses to its version; the distribution's build parses to a different one. -func TestParseGoVersionOfTheReleaseAndOfTheDistributionsBuild(t *testing.T) { - if v, err := parseGoVersion("go version go1.27.1 linux/amd64\n"); err != nil || v != RecipeGo { - t.Errorf("release: %q, %v", v, err) - } - if v, _ := parseGoVersion("go version go1.27.1-X:nodwarf5 linux/amd64\n"); v == RecipeGo { - t.Errorf("the distribution's build parsed as the release: %q", v) - } -} - -// 1560 (ADR 0476 §2): the toolchains key carries the version the probe read from the release. -func TestTheToolchainsKeyIsTheReleasesVersion(t *testing.T) { - states := newStates() - m := moduleWith(states, "") - p := &probeFake{ran: forgekey.Ran{Stdout: "go version go1.27.1 linux/amd64\n"}} - m.Probe = func(ctx context.Context) (string, error) { return probeGo(ctx, p.run, "/home/x", t.TempDir()) } - if err := m.Check(context.Background()); err != nil { - t.Fatal(err) - } - tc, ok := states.toolchain("ace.go") - if !ok || tc.Version != RecipeGo { - t.Fatalf("ace.go = %+v, %v; want version %s", tc, ok, RecipeGo) - } -} - -// 1560 (ADR 0476 §2): a probe that fails (the release cannot be had) or reads another version makes the module -// refuse, and its health is unhealthy naming the recipe's version and what the probe got. -func TestAFailedFetchOrAnotherVersionMakesHealthUnhealthyNamingBoth(t *testing.T) { - cases := []struct { - name string - ran forgekey.Ran - want []string - }{ - {"fetch fails", forgekey.Ran{Status: 1, Stderr: "go: downloading go1.27.1 (linux/amd64)\ngo: download go1.27.1 for linux/amd64: dial tcp: no route to host\n"}, - []string{"go1.27.1", "no route to host"}}, - {"other version", forgekey.Ran{Stdout: "go version go1.27.1-X:nodwarf5 linux/amd64\n"}, - []string{"go1.27.1", "go1.27.1-X:nodwarf5"}}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - m := moduleWith(newStates(), "") - p := &probeFake{ran: c.ran} - m.Probe = func(ctx context.Context) (string, error) { return probeGo(ctx, p.run, "/home/x", t.TempDir()) } - a := m.Health(context.Background()) - if a.Healthy || a.State != health.StateUnhealthy { - t.Fatalf("health = %+v, want unhealthy", a) - } - for _, w := range c.want { - if !strings.Contains(a.Why, w) { - t.Errorf("health does not name %q: %q", w, a.Why) - } - } - if !strings.Contains(a.Why, "recipe") && !strings.Contains(a.Why, RecipeGo) { - t.Errorf("health does not name the recipe's version: %q", a.Why) - } - }) - } -} - -// 1560: the probe is bound long enough to download while the release is missing, and short once it is cached. -func TestTheProbeIsBoundLongOnlyWhileTheReleaseIsMissing(t *testing.T) { - var got time.Duration - run := func(ctx context.Context, _ []string, _ string, _ ...string) forgekey.Ran { - d, _ := ctx.Deadline() - got = time.Until(d) - return forgekey.Ran{Stdout: "go version go1.27.1 linux/amd64\n"} - } - cache := t.TempDir() - probeGo(context.Background(), run, "/h", cache) - if got < time.Minute { - t.Errorf("missing release: bound %v", got) - } - os.MkdirAll(filepath.Join(cache, "gomod", "golang.org", "toolchain@v0.0.1-"+RecipeGo+".linux-amd64"), 0o755) - probeGo(context.Background(), run, "/h", cache) - if got > ProbeTimeout { - t.Errorf("cached release: bound %v", got) - } -} - -// 1560 (review): Go's progress line is not the cause: the refusal names the line after it. -func TestCauseLineSkipsGosDownloadingLine(t *testing.T) { - got := causeLine("go: downloading go1.27.1 (linux/amd64)\ngo: download go1.27.1: checksum mismatch\n") - if got != "go: download go1.27.1: checksum mismatch" { - t.Errorf("causeLine = %q", got) - } - if got := causeLine("go: downloading go1.27.1 (linux/amd64)\n"); got != "go: downloading go1.27.1 (linux/amd64)" { - t.Errorf("only a progress line: %q", got) - } -} diff --git a/modules/mesh-build-node/cmd/mesh-build-node/installed_toolchain_test.go b/modules/mesh-build-node/cmd/mesh-build-node/installed_toolchain_test.go new file mode 100644 index 00000000..e4b7841c --- /dev/null +++ b/modules/mesh-build-node/cmd/mesh-build-node/installed_toolchain_test.go @@ -0,0 +1,86 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/health" +) + +// 1572/AC2 (ADR 0477 §2): the recipe has no field for a Node version, and no constant names one. +func TestTheRecipeHasNoFieldForAToolchainVersion(t *testing.T) { + typ := reflect.TypeOf(Recipe{}) + for i := 0; i < typ.NumField(); i++ { + if n := typ.Field(i).Name; n == "Node" || strings.Contains(n, "Toolchain") { + t.Errorf("Recipe has the field %s", n) + } + } +} + +// 1572/AC2 and AC4: whatever Node the machine has installed, the module stays healthy, names that version in its +// health, runs its steps and writes the version to .typescript of the toolchains state. +func TestAnyInstalledNodeIsHealthyNamedAndReported(t *testing.T) { + for _, installed := range []string{"v26.10.0", "v26.11.0", "v27.0.1", "v24.9.0"} { + t.Run(installed, func(t *testing.T) { + states := newStates() + m := moduleWith(states, installed) + if err := m.Check(context.Background()); err != nil { + t.Fatalf("Check refused: %v", err) + } + a := m.Health(context.Background()) + if !a.Healthy || a.State != health.StateHealthy || !strings.Contains(a.Why, installed) { + t.Errorf("health = %+v, want healthy naming %s", a, installed) + } + tc, ok := states.toolchain("ace.typescript") + if !ok || tc.Version != installed || tc.Recipe != m.Recipe.Version() { + t.Errorf("ace.typescript = %+v, %v; want version %s and recipe %s", tc, ok, installed, m.Recipe.Version()) + } + if err := m.Prepare(context.Background()); err != nil { + t.Errorf("no step can run on node %s: %v", installed, err) + } + }) + } +} + +// 1572/AC3 (ADR 0477 §3): the recipe version is made without a Node version: the recipe read from a lock is the same +// whatever node is installed, and a version set on the recipe by name moves nothing; it moves with the steps' version +// and with one byte of the lock. +func TestTheRecipeVersionIgnoresTheToolchainVersionAndMovesWithStepsAndLock(t *testing.T) { + path := filepath.Join(t.TempDir(), "package-lock.json") + os.WriteFile(path, []byte("lock-a"), 0o600) + base, err := ReadRecipe(path) + if err != nil { + t.Fatal(err) + } + v := base.Version() + for _, installed := range []string{"v26.10.0", "v27.0.1"} { + m := moduleWith(newStates(), installed) + m.Reload = func() (Recipe, error) { return ReadRecipe(path) } + if err := m.Check(context.Background()); err != nil { + t.Fatal(err) + } + if got := m.recipe().Version(); got != v { + t.Errorf("with node %s installed the recipe version is %s, want %s", installed, got, v) + } + } + r := base + if f := reflect.ValueOf(&r).Elem().FieldByName("Node"); f.IsValid() { + f.SetString("v99.0.0") + if r.Version() != v { + t.Errorf("a Node version on the recipe moved its version") + } + } + r = base + r.Steps += "x" + if r.Version() == v { + t.Errorf("the steps' version did not move the recipe version") + } + os.WriteFile(path, []byte("lock-b"), 0o600) + if b, _ := ReadRecipe(path); b.Version() == v { + t.Errorf("one byte of the lock did not move the recipe version") + } +}