Merge pull request 'Build Go modules with Go's own release of the recipe's version, so the distribution's build no longer makes the guard refuse (issue 1560, ADR 0476)' (#400) from fix/1560-go-release-toolchain into main
This commit was merged in pull request #400.
This commit is contained in:
@@ -14,9 +14,11 @@ the nats-server library's version, read from the program's build information, so
|
||||
|
||||
## The guard and the health
|
||||
|
||||
At start, every ten minutes, before every step and at every health look, the module reads the installed Go
|
||||
(`go version`) and writes it with the recipe version to `<node>.go` of its `toolchains` state. A machine whose Go
|
||||
is not the recipe's refuses every step, the step ending `refused` on its key in `steps` with both versions named,
|
||||
At start, every ten minutes, before every step and at every health look, the module reads the version of Go's own
|
||||
release of the recipe's version (`go version` run with `GOTOOLCHAIN=<RecipeGo>`, ADR 0476) and writes it with the recipe version to `<node>.go` of its `toolchains` state. The probe runs with network and
|
||||
the module's Go module cache (`GOMODCACHE`): while the release is missing from it, the installed Go downloads it there,
|
||||
verified against Go's checksum database; no step fetches it. A machine where the release cannot be had, or that does not
|
||||
report the recipe's version, refuses every step, the step ending `refused` on its key in `steps` with both versions named,
|
||||
and `build_go_health` (the module's `runtime-health`) answers unhealthy with the same text, so the controller raises
|
||||
the machine's condition. Moving to a new Go is a change of `RecipeGo`.
|
||||
|
||||
@@ -31,7 +33,7 @@ step answers at once with its id and ends on `<node>.<id>` of `steps` (ADR 0469
|
||||
`build_go_fetch {repository, commit, ref?}` fetches the commit over SSH as the module's account (`mesh-forge` is the
|
||||
forge-account provision's ssh name, so the account's own ssh files and git rewrite are used) into a workspace of the
|
||||
work directory, then runs `go mod download` and `go mod verify` in every directory with a `go.mod` that does not vendor,
|
||||
with `GOTOOLCHAIN=local`, `GOFLAGS=-mod=mod` and the module's cache directory. Every command runs with an
|
||||
with `GOTOOLCHAIN=<RecipeGo>`, `GOFLAGS=-mod=mod` and the module's cache directory. Every command runs with an
|
||||
environment built from scratch (`StepRunner`): `PATH`, `HOME` and what the step passes. Workspaces older than a day
|
||||
are removed.
|
||||
|
||||
@@ -86,7 +88,7 @@ is listed and tested from its vendor directory (`-mod=vendor`).
|
||||
agent's recipe (mesh-controller's `toolchain.go`, the Go entry, copied in `testdata/captured/toolchain-go-entry.txt`, and
|
||||
`builder.go`): `CGO_ENABLED=0`, `GOFLAGS=-trimpath -buildvcs=false` (with `-mod=mod`, or `-mod=vendor` for a directory
|
||||
that vendors), `go build -ldflags "-s -w -X main.builtFor=<system>" -o .mesh-build/<artifact>/<binary> ./<from>` in the
|
||||
module's directory, by the installed Go (`GOTOOLCHAIN=local`) from the module cache the fetch filled and no network
|
||||
module's directory, by the recipe's Go release (`GOTOOLCHAIN=<RecipeGo>`, found in the module cache) from the module cache the fetch filled and no network
|
||||
(`GOPROXY=off`). The binary is named as the build agent names it. The output directory is packed with mesh-sdk's `Pack`,
|
||||
so the digest is the build agent's for the same tree. The step ends with the artifact, the binary, the digest, the size and
|
||||
the path of the archive, which stays in the workspace's `artifacts` directory: publishing it is the builder's, in a later
|
||||
|
||||
@@ -128,7 +128,7 @@ func (m *Module) build(ctx context.Context, args map[string]any) (any, error) {
|
||||
}
|
||||
cmd := []string{"-C", dir, "build", "-ldflags", linker, "-o", filepath.Join(out, binary), "./" + strings.Trim(art.From, "./")}
|
||||
if r := m.Run(ctx, m.buildEnv(dir), "go", cmd...); r.Status != 0 || r.Err != "" {
|
||||
return nil, fmt.Errorf("go build of %s: %s", art.Name, firstLine(r.Err+"\n"+r.Stderr+"\n"+r.Stdout))
|
||||
return nil, fmt.Errorf("go build of %s: %s", art.Name, causeLine(r.Err+"\n"+r.Stderr+"\n"+r.Stdout))
|
||||
}
|
||||
archive, digest, err := buildstep.Pack(out)
|
||||
if err != nil {
|
||||
@@ -145,7 +145,7 @@ func (m *Module) build(ctx context.Context, args map[string]any) (any, error) {
|
||||
return BuildResult{Artifact: art.Name, Binary: binary, Digest: digest, Size: len(archive), Path: path}, nil
|
||||
}
|
||||
|
||||
// buildEnv is the environment of the compile: the build agent's (CGO_ENABLED=0, GOFLAGS) with the installed toolchain
|
||||
// buildEnv is the environment of the compile: the build agent's (CGO_ENABLED=0, GOFLAGS) with the recipe's Go release
|
||||
// alone, the modules the fetch put in the module's cache and no way to fetch another (GOPROXY=off), so a build never
|
||||
// reaches the network. A directory that vendors is built from its vendor directory.
|
||||
func (m *Module) buildEnv(dir string) []string {
|
||||
@@ -156,7 +156,7 @@ func (m *Module) buildEnv(dir string) []string {
|
||||
return []string{
|
||||
"CGO_ENABLED=0",
|
||||
"GOFLAGS=" + recipeFlags + " " + mod,
|
||||
"GOTOOLCHAIN=local",
|
||||
"GOTOOLCHAIN=" + RecipeGo,
|
||||
"GOMODCACHE=" + filepath.Join(m.Cache, "gomod"),
|
||||
"GOCACHE=" + filepath.Join(m.Cache, "gobuild"),
|
||||
"GOPROXY=off",
|
||||
|
||||
@@ -120,7 +120,7 @@ func TestTheBuildCommandCarriesTodaysFlagsAndEnvironment(t *testing.T) {
|
||||
}
|
||||
}
|
||||
env := strings.Join(c.env, "\n")
|
||||
for _, want := range []string{compile[1], "GOFLAGS=" + strings.TrimPrefix(compile[2], "GOFLAGS=") + " -mod=mod", "GOTOOLCHAIN=local", "GOPROXY=off",
|
||||
for _, want := range []string{compile[1], "GOFLAGS=" + strings.TrimPrefix(compile[2], "GOFLAGS=") + " -mod=mod", "GOTOOLCHAIN=go1.27.1", "GOPROXY=off",
|
||||
"GOMODCACHE=" + filepath.Join(m.Cache, "gomod"), "GOCACHE=" + filepath.Join(m.Cache, "gobuild")} {
|
||||
if !strings.Contains(env, want) {
|
||||
t.Errorf("the environment lacks %q:\n%s", want, env)
|
||||
|
||||
@@ -148,7 +148,7 @@ func (m *Module) listPackages(ctx context.Context, tree, dir string, tags []stri
|
||||
args = append(args, "./...")
|
||||
r := m.Run(ctx, m.goEnvFor(abs), "go", args...)
|
||||
if r.Status != 0 || r.Err != "" {
|
||||
return nil, fmt.Errorf("go list in %s: %s", dir, firstLine(r.Err+"\n"+r.Stderr+"\n"+r.Stdout))
|
||||
return nil, fmt.Errorf("go list in %s: %s", dir, causeLine(r.Err+"\n"+r.Stderr+"\n"+r.Stdout))
|
||||
}
|
||||
return parsePackages(r.Stdout)
|
||||
}
|
||||
|
||||
@@ -37,7 +37,15 @@ func digestFixture(t *testing.T) (*Module, string) {
|
||||
case "postgres":
|
||||
return forgekey.Ran{Stdout: "postgres (PostgreSQL) " + strconv.Itoa(m.Recipe.PostgresMajor) + ".1\n"}
|
||||
}
|
||||
return real(ctx, env, name, args...)
|
||||
// The unit suite has no network: the release (ADR 0476) is not fetched here, the installed Go runs the fixture.
|
||||
local := []string{}
|
||||
for _, e := range env {
|
||||
if e == "GOTOOLCHAIN="+RecipeGo {
|
||||
e = "GOTOOLCHAIN=local"
|
||||
}
|
||||
local = append(local, e)
|
||||
}
|
||||
return real(ctx, local, name, args...)
|
||||
}
|
||||
return m, tree
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ func (m *Module) fetch(ctx context.Context, args map[string]any) (any, error) {
|
||||
for _, verb := range []string{"download", "verify"} {
|
||||
r := m.Run(ctx, m.goEnv(), "go", "-C", filepath.Join(tree, d), "mod", verb)
|
||||
if r.Status != 0 || r.Err != "" {
|
||||
return nil, fmt.Errorf("go mod %s in %s: %s", verb, d, firstLine(r.Err+"\n"+r.Stdout+"\n"+r.Stderr))
|
||||
return nil, fmt.Errorf("go mod %s in %s: %s", verb, d, causeLine(r.Err+"\n"+r.Stdout+"\n"+r.Stderr))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -112,13 +112,13 @@ func (m *Module) goModDirs(ctx context.Context, tree string) ([]string, error) {
|
||||
return dirs, nil
|
||||
}
|
||||
|
||||
// goEnv is what a go command of a step is given beyond the base environment: the installed toolchain and no other,
|
||||
// goEnv is what a go command of a step is given beyond the base environment: the recipe's Go release (ADR 0476: the probe put it in the module's cache; a step never fetches it),
|
||||
// modules resolved by their go.mod and go.sum, the module's own caches, the mesh's own modules fetched directly (by
|
||||
// git, through the account's rewrite of the forge's address to ssh), and nothing that asks a question. For a directory
|
||||
// that vendors, see goEnvFor.
|
||||
func (m *Module) goEnv() []string {
|
||||
return []string{
|
||||
"GOTOOLCHAIN=local",
|
||||
"GOTOOLCHAIN=" + RecipeGo,
|
||||
"GOFLAGS=-mod=mod",
|
||||
"GOMODCACHE=" + filepath.Join(m.Cache, "gomod"),
|
||||
"GOCACHE=" + filepath.Join(m.Cache, "gobuild"),
|
||||
@@ -164,6 +164,18 @@ func (m *Module) clock() time.Time {
|
||||
}
|
||||
func (m *Module) now() time.Time { return m.clock() }
|
||||
|
||||
// causeLine is the first line of a go command's output that says something other than Go's own progress: `go:
|
||||
// downloading ...` comes first when Go fetches a toolchain or a module, and the cause of a failure on a later line
|
||||
// (issue 1560: a refusal read only "go: downloading go1.27.1 (linux/amd64)").
|
||||
func causeLine(s string) string {
|
||||
for _, l := range strings.Split(s, "\n") {
|
||||
if l = strings.TrimSpace(l); l != "" && !strings.HasPrefix(l, "go: downloading ") {
|
||||
return l
|
||||
}
|
||||
}
|
||||
return firstLine(s)
|
||||
}
|
||||
|
||||
func firstLine(s string) string {
|
||||
for _, l := range strings.Split(s, "\n") {
|
||||
if l = strings.TrimSpace(l); l != "" {
|
||||
|
||||
@@ -93,7 +93,7 @@ func fetchTool(t *testing.T, m *Module) func(map[string]any) (any, error) {
|
||||
var fetchArgs = map[string]any{"repository": "novox/mesh-sdk", "commit": commitSHA}
|
||||
|
||||
// 1485/AC1: git is asked through FetchCommit with the ssh remote, and go mod download and verify run in each go.mod
|
||||
// directory (not in one that vendors) with GOTOOLCHAIN=local, GOFLAGS=-mod=mod and the module's cache paths.
|
||||
// directory (not in one that vendors) with GOTOOLCHAIN=go1.27.1, GOFLAGS=-mod=mod and the module's cache paths.
|
||||
func TestFetchAsksGitForTheSshRemoteAndDownloadsEachModule(t *testing.T) {
|
||||
f := &fakeStep{gomods: "go.mod\nsub/dir/go.mod\nvend/go.mod\n", vendors: []string{"vend"}}
|
||||
m, states := fetchModule(t, f)
|
||||
@@ -128,7 +128,7 @@ func TestFetchAsksGitForTheSshRemoteAndDownloadsEachModule(t *testing.T) {
|
||||
t.Errorf("a module that vendors was downloaded: %s", c.line)
|
||||
}
|
||||
env := strings.Join(c.env, " ")
|
||||
for _, want := range []string{"GOTOOLCHAIN=local", "GOFLAGS=-mod=mod", "GOMODCACHE=" + filepath.Join(m.Cache, "gomod"), "GOCACHE=" + filepath.Join(m.Cache, "gobuild"), "GOPRIVATE=git.novox.be"} {
|
||||
for _, want := range []string{"GOTOOLCHAIN=go1.27.1", "GOFLAGS=-mod=mod", "GOMODCACHE=" + filepath.Join(m.Cache, "gomod"), "GOCACHE=" + filepath.Join(m.Cache, "gobuild"), "GOPRIVATE=git.novox.be"} {
|
||||
if !strings.Contains(env, want) {
|
||||
t.Errorf("%s: environment lacks %s: %s", c.line, want, env)
|
||||
}
|
||||
|
||||
@@ -33,7 +33,6 @@ func main() {
|
||||
m := &Module{
|
||||
Node: strings.TrimSpace(os.Getenv("MESH_NODE")),
|
||||
Recipe: Current(),
|
||||
Probe: ProbeGo,
|
||||
Toolchains: stdio.State(delivery.ToolchainsState).Put,
|
||||
Emit: func(t string, b any) error { return stdio.Emit(t, b) },
|
||||
Steps: stdio.State(delivery.StepsState).Put,
|
||||
@@ -46,6 +45,7 @@ func main() {
|
||||
m.Home = u.HomeDir
|
||||
}
|
||||
m.Run = StepRunner(m.Home)
|
||||
m.Probe = ProbeGo(m.Home, m.Cache)
|
||||
// Outside a launch by the runtime — a build, a check — there is no node and no state to write.
|
||||
if m.Node != "" {
|
||||
go m.Start(ctx)
|
||||
|
||||
@@ -3,28 +3,61 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
||||
)
|
||||
|
||||
// ProbeTimeout bounds `go version`.
|
||||
const ProbeTimeout = 15 * time.Second
|
||||
// ProbeTimeout bounds `go version` when the release is in the cache; FetchTimeout bounds it while the release is
|
||||
// missing and the installed Go downloads it (about seventy megabytes).
|
||||
const (
|
||||
ProbeTimeout = 15 * time.Second
|
||||
FetchTimeout = 5 * time.Minute
|
||||
)
|
||||
|
||||
// ProbeGo reads the installed Go version: the third word `go version` prints ("go version go1.27.1 linux/amd64"),
|
||||
// as the recipe's RecipeGo is written. GOTOOLCHAIN=local keeps an older Go from fetching another toolchain: what is
|
||||
// installed is what is read.
|
||||
func ProbeGo(ctx context.Context) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, ProbeTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "go", "version")
|
||||
cmd.Env = append(os.Environ(), "GOTOOLCHAIN=local")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
// ProbeGo is the probe of the guard (novox/hq ADR 0476 §2, §3; issue 1560): it runs `go version` with
|
||||
// GOTOOLCHAIN=RecipeGo, so it reads the version of Go's own release of the recipe's version, which is what the
|
||||
// module builds with, and the third word of its answer is that version. It runs with network and the module's own
|
||||
// Go module cache (cache/gomod): while the release is missing from it, the installed Go downloads it there, verified
|
||||
// against Go's checksum database. No step fetches it; a step runs behind this probe and refuses when it is missing.
|
||||
// The environment is built from scratch like a step's (StepRunner), so the module's bus credentials are not in it.
|
||||
func ProbeGo(home, cache string) func(ctx context.Context) (string, error) {
|
||||
run := forgekey.ExecRunnerClean(0)
|
||||
return func(ctx context.Context) (string, error) { return probeGo(ctx, run, home, cache) }
|
||||
}
|
||||
|
||||
// probeEnv is what the probe gives `go version` beyond the base environment: the recipe's release as the toolchain,
|
||||
// the module's cache for it to land in (and the build cache the go command wants), and Go's own proxy and checksum
|
||||
// database, which this module's steps never leave at their default except here and in fetch.
|
||||
func probeEnv(cache string) []string {
|
||||
return []string{
|
||||
"GOTOOLCHAIN=" + RecipeGo,
|
||||
"GOMODCACHE=" + filepath.Join(cache, "gomod"),
|
||||
"GOCACHE=" + filepath.Join(cache, "gobuild"),
|
||||
}
|
||||
return parseGoVersion(string(out))
|
||||
}
|
||||
|
||||
// releaseCached says whether the release is in the module cache: golang.org/toolchain@v0.0.1-<version>.<os>-<arch>.
|
||||
func releaseCached(cache string) bool {
|
||||
dirs, _ := filepath.Glob(filepath.Join(cache, "gomod", "golang.org", "toolchain@v0.0.1-"+RecipeGo+".*"))
|
||||
return len(dirs) > 0
|
||||
}
|
||||
|
||||
func probeGo(ctx context.Context, run forgekey.Runner, home, cache string) (string, error) {
|
||||
bound := ProbeTimeout
|
||||
if !releaseCached(cache) {
|
||||
bound = FetchTimeout
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, bound)
|
||||
defer cancel()
|
||||
r := run(ctx, append(baseEnv(home), probeEnv(cache)...), "go", "version")
|
||||
if r.Status != 0 || r.Err != "" {
|
||||
return "", fmt.Errorf("`go version` with GOTOOLCHAIN=%s failed (the recipe builds with %s): %s", RecipeGo, RecipeGo,
|
||||
causeLine(r.Err+"\n"+r.Stderr))
|
||||
}
|
||||
return parseGoVersion(r.Stdout)
|
||||
}
|
||||
|
||||
func parseGoVersion(out string) (string, error) {
|
||||
|
||||
@@ -7,11 +7,14 @@ import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -21,7 +24,52 @@ func TestMain(m *testing.M) {
|
||||
if len(os.Args) > 1 && os.Args[1] == busArg {
|
||||
os.Exit(runBus(os.Args[2:]))
|
||||
}
|
||||
os.Exit(m.Run())
|
||||
code := m.Run()
|
||||
releaseCleanup()
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
// The module cache the real-go tests share, filled once by the probe as the module's start does (ADR 0476 §3): the
|
||||
// steps carry GOTOOLCHAIN=<RecipeGo> and fetch no toolchain, so a test that runs the real go needs the release in its
|
||||
// cache first.
|
||||
var (
|
||||
releaseOnce sync.Once
|
||||
releaseCache string
|
||||
releaseErr error
|
||||
)
|
||||
|
||||
// needRelease answers the cache with the recipe's release in it, filled by ProbeGo, and skips loudly when the probe
|
||||
// cannot have it (no network to Go's download service where the check runs); the build node has it (issues 1486, 1560).
|
||||
func needRelease(t *testing.T, home string) string {
|
||||
t.Helper()
|
||||
releaseOnce.Do(func() {
|
||||
releaseCache, releaseErr = os.MkdirTemp("", "release-cache")
|
||||
if releaseErr != nil {
|
||||
return
|
||||
}
|
||||
var v string
|
||||
if v, releaseErr = ProbeGo(home, releaseCache)(context.Background()); releaseErr == nil && v != RecipeGo {
|
||||
releaseErr = fmt.Errorf("the probe read %s", v)
|
||||
}
|
||||
})
|
||||
if releaseErr != 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, releaseErr)
|
||||
}
|
||||
return releaseCache
|
||||
}
|
||||
|
||||
// releaseCleanup removes the shared cache; Go writes its module cache read-only.
|
||||
func releaseCleanup() {
|
||||
if releaseCache == "" {
|
||||
return
|
||||
}
|
||||
filepath.WalkDir(releaseCache, func(p string, d fs.DirEntry, err error) error {
|
||||
if err == nil && d.IsDir() {
|
||||
os.Chmod(p, 0o755)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
os.RemoveAll(releaseCache)
|
||||
}
|
||||
|
||||
// needTools skips, loudly, a test whose tools are not installed where the check runs (the mesh's Go toolchain image
|
||||
@@ -68,7 +116,7 @@ func TestUnitLevelRunsTheRealGoOnTheFixtureRepositories(t *testing.T) {
|
||||
for name, w := range want {
|
||||
tree := repository(t, name)
|
||||
home := t.TempDir()
|
||||
m := &Module{Home: home, Work: t.TempDir(), Cache: t.TempDir()}
|
||||
m := &Module{Home: home, Work: t.TempDir(), Cache: needRelease(t, home)}
|
||||
m.Run = StepRunner(home)
|
||||
res, err := m.runLevel(context.Background(), tree, levelOpts{level: LevelUnit, bounds: Bounds{Run: UnitBound}})
|
||||
if err != nil {
|
||||
@@ -111,7 +159,7 @@ func TestIntegrationLevelRunsAgainstARealStoreAndBusAndLeavesNothing(t *testing.
|
||||
t.Fatal(err)
|
||||
}
|
||||
home := t.TempDir()
|
||||
m := &Module{Recipe: Current(), Home: home, Work: work, Cache: t.TempDir()}
|
||||
m := &Module{Recipe: Current(), Home: home, Work: work, Cache: needRelease(t, home)}
|
||||
m.Run = StepRunner(home)
|
||||
res, err := m.integration(context.Background(), "ws1", filepath.Join(work, "ws1", "tree"), nil)
|
||||
if err != nil {
|
||||
@@ -149,7 +197,7 @@ func TestBuildTwiceGivesOneDigestAndOneExecutable(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
home := t.TempDir()
|
||||
m := &Module{Home: home, Work: work, Cache: t.TempDir()}
|
||||
m := &Module{Home: home, Work: work, Cache: needRelease(t, home)}
|
||||
m.Run = StepRunner(home)
|
||||
args := map[string]any{"workspace": "ws1", "module": ".", "artifact": "tools"}
|
||||
first, err := m.build(context.Background(), args)
|
||||
|
||||
@@ -175,7 +175,7 @@ func TestUnitLevelRunsGoTestJsonInTheModulesDirectoryWithTheStepsEnvironment(t *
|
||||
if line != want {
|
||||
t.Errorf("command = %q, want %q", line, want)
|
||||
}
|
||||
for _, w := range []string{"GOTOOLCHAIN=local", "GOFLAGS=-mod=mod", "GOMODCACHE=" + filepath.Join(m.Cache, "gomod")} {
|
||||
for _, w := range []string{"GOTOOLCHAIN=go1.27.1", "GOFLAGS=-mod=mod", "GOMODCACHE=" + filepath.Join(m.Cache, "gomod")} {
|
||||
if !strings.Contains(env, w) {
|
||||
t.Errorf("environment %s lacks %s", env, w)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
Each file is the standard output of `GOTOOLCHAIN=local GOFLAGS=-mod=mod go test -count=1 -json ./...` (go1.27.1)
|
||||
Each file is the standard output of `GOTOOLCHAIN=go1.27.1 GOFLAGS=-mod=mod go test -count=1 -json ./...` (go1.27.1)
|
||||
run in the fixture module of the same name under ../repos, captured on 2026-10-20 for issue 1290. The unit
|
||||
tests feed them to the parser through a fake runner, so no test here runs `go test`.
|
||||
|
||||
golist-external.json is `GOTOOLCHAIN=local GOFLAGS=-mod=mod go list -json=ImportPath,Standard,DepOnly,Module
|
||||
golist-external.json is `GOTOOLCHAIN=go1.27.1 GOFLAGS=-mod=mod go list -json=ImportPath,Standard,DepOnly,Module
|
||||
git.novox.be/novox/mesh-sdk/go/buildstep` run in this module (go1.27.1, 2026-10-20 for issue 1291): a package of a module
|
||||
outside the tree as go list describes it.
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//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-<RecipeGo>.<os>-<arch> 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user