From c5f8f81a07a6c6c8477ece841ed76123cd2628ff Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Wed, 21 Oct 2026 00:51:40 +0000 Subject: [PATCH] Test that every step and the probe run Go's own release and the probe fills the module cache (issue 1560, ADR 0476) --- .../cmd/mesh-build-go/toolchain_env_test.go | 64 +++++++++ .../toolchain_integration_test.go | 52 ++++++++ .../cmd/mesh-build-go/toolchain_probe_test.go | 124 ++++++++++++++++++ 3 files changed, 240 insertions(+) create mode 100644 modules/mesh-build-go/cmd/mesh-build-go/toolchain_env_test.go create mode 100644 modules/mesh-build-go/cmd/mesh-build-go/toolchain_integration_test.go create mode 100644 modules/mesh-build-go/cmd/mesh-build-go/toolchain_probe_test.go 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 new file mode 100644 index 00000000..d1eefa4c --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/toolchain_env_test.go @@ -0,0 +1,64 @@ +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 new file mode 100644 index 00000000..710e95ee --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/toolchain_integration_test.go @@ -0,0 +1,52 @@ +//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 || v != RecipeGo { + t.Fatalf("probe = %q, %v; want %s", v, err, 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 new file mode 100644 index 00000000..ac21510a --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/toolchain_probe_test.go @@ -0,0 +1,124 @@ +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: 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) + } +}