From ad6e813fb0ad4a72abe38f929b28dd453ffe1b8e Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 10 Nov 2026 13:04:39 +0100 Subject: [PATCH 1/3] S2: mesh-build-image and mesh-build-node name nodebuildgo, which buildstep imports (issue 1966) Since buildstep's drop verb imports the node-build-go seat package, both modules' shut-off vet, test and build looked it up on the network: their go.mod did not name it, so the fetch took no version for it. go mod tidy adds it as an indirect requirement. Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ --- modules/mesh-build-image/go.mod | 1 + modules/mesh-build-node/go.mod | 1 + 2 files changed, 2 insertions(+) diff --git a/modules/mesh-build-image/go.mod b/modules/mesh-build-image/go.mod index 06309053b..bdd7b9dea 100644 --- a/modules/mesh-build-image/go.mod +++ b/modules/mesh-build-image/go.mod @@ -41,6 +41,7 @@ require ( git.novox.be/novox/mesh-delivery/contracts/go/clients/ociartifactregistryclient v0.0.0-00010101000000-000000000000 // indirect git.novox.be/novox/mesh-delivery/contracts/go/seats/gopackageregistry v0.0.0-00010101000000-000000000000 // indirect git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-delivery/contracts/go/seats/nodebuildgo v0.0.0-00010101000000-000000000000 // indirect git.novox.be/novox/mesh-delivery/contracts/go/seats/npmpackageregistry v0.0.0-00010101000000-000000000000 // indirect git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry v0.0.0-00010101000000-000000000000 // indirect ) diff --git a/modules/mesh-build-node/go.mod b/modules/mesh-build-node/go.mod index dc59b88f2..af385b3dd 100644 --- a/modules/mesh-build-node/go.mod +++ b/modules/mesh-build-node/go.mod @@ -101,6 +101,7 @@ require ( git.novox.be/novox/mesh-delivery/contracts/go/clients/ociartifactregistryclient v0.0.0-00010101000000-000000000000 // indirect git.novox.be/novox/mesh-delivery/contracts/go/seats/gopackageregistry v0.0.0-00010101000000-000000000000 // indirect git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-delivery/contracts/go/seats/nodebuildgo v0.0.0-00010101000000-000000000000 // indirect git.novox.be/novox/mesh-delivery/contracts/go/seats/npmpackageregistry v0.0.0-00010101000000-000000000000 // indirect git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry v0.0.0-00010101000000-000000000000 // indirect ) From ce3284fc8f148e26bad542cffacb150f7dbb4f23 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 10 Nov 2026 13:08:00 +0100 Subject: [PATCH 2/3] S3: the fetch takes a module's zero-version requirements from its module graph (issue 1966) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0573 §2 builds every module against the latest published version of each mesh package it uses, whatever its go.mod names. The fetch took versions only for packages the directory's own go.mod names, so a package only a module it replaces by a directory requires (mesh-build-image replacing buildstep, which imports the node-build-go seat package) was never taken: go mod download passed and every shut-off step looked it up on the network. Now the requirements of each module the directory replaces by a directory count too, taken the same way (the ask, its repository's asked head, else the latest) and said as named by that directory. 1966/AC178-1: rows of TestARequirementAtTheZeroVersionIsTakenFromTheAskOrTheTrunk. Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ --- .../mesh-build-go/cmd/mesh-build-go/fetch.go | 5 + .../mesh-build-go/cmd/mesh-build-go/pins.go | 132 ++++++++++++++++-- .../cmd/mesh-build-go/pins_test.go | 30 +++- .../cmd/mesh-build-go/versions.go | 10 ++ 4 files changed, 163 insertions(+), 14 deletions(-) diff --git a/modules/mesh-build-go/cmd/mesh-build-go/fetch.go b/modules/mesh-build-go/cmd/mesh-build-go/fetch.go index 1a17af061..8eb13073a 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/fetch.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/fetch.go @@ -179,6 +179,11 @@ func (m *Module) fetchInto(ctx context.Context, req sdkstep.FetchRequest, id str return } var more []TakenVersion + // Then what only a module the directory replaces names, which the download would not miss (pins.go). + more, errs[i] = m.takeReplacedZeroPins(ctx, root, repository, filepath.Join(tree, d), d, req.Packages, taken[i], &pins) + if taken[i] = append(taken[i], more...); errs[i] != nil { + return + } more, errs[i] = m.downloadModules(ctx, root, repository, filepath.Join(tree, d), d, &pins, req.Packages) taken[i] = append(taken[i], more...) }() diff --git a/modules/mesh-build-go/cmd/mesh-build-go/pins.go b/modules/mesh-build-go/cmd/mesh-build-go/pins.go index 8563ba96a..19c1ec08a 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/pins.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/pins.go @@ -259,16 +259,117 @@ func absoluteReplaces(body []byte, dir string) []byte { return []byte(strings.Join(lines, "\n")) } -// takeZeroPins takes, for each requirement of dir's go.mod at the zero version that a go failure names, the version it is -// built against (ADR 0573 §2, §4: whatever go.mod names): the commit the ask names for the package; else the head of -// the package's repository among the asked versions (the check's or try's members: another package of the same -// repository names it); else the latest published version, the trunk's head. It never looks for the commit -// 000000000000 among the pull request heads. It answers what it took; none when no zero requirement is named. -func (m *Module) takeZeroPins(ctx context.Context, root, repository, dir, d, failure string, asked sdkstep.PackageVersions, pins *sync.Mutex) ([]TakenVersion, error) { +// zeroRequirement is a requirement at the zero version in the module graph of a directory: by is "" when the directory's +// own go.mod names it, else the directory (relative to dir) of a module the directory replaces by a directory of the +// tree, whose go.mod names it. +type zeroRequirement struct { + pin + by string +} + +// zeroRequirementsOf are the requirements at the zero version of dir's module graph as Go builds it (ADR 0573 §2: +// whatever go.mod names): those of dir's go.mod, then those of each module dir's go.mod replaces by a directory +// (./x, ../x), each module once, own first. A module dir replaces is left out, since its replace decides it; a replaced +// module's own replaces are not followed, since Go reads replaces only of the directory it builds (issue 1966: buildstep, +// replaced by ../mesh-build-go/buildstep, imports a seat package mesh-build-image's go.mod did not name). +func zeroRequirementsOf(dir string) []zeroRequirement { raw, err := os.ReadFile(filepath.Join(dir, "go.mod")) - if err != nil || !strings.Contains(failure, zeroShort) { + if err != nil { + return nil + } + _, replaced := goModRequirements(string(raw)) + seen := map[string]bool{} + var out []zeroRequirement + add := func(gomod, by string) { + for _, p := range forgePins(gomod) { + if p.short != zeroShort || seen[p.module] || (by != "" && replaced[p.module]) { + continue + } + seen[p.module] = true + out = append(out, zeroRequirement{pin: p, by: by}) + } + } + add(string(raw), "") + local := localReplaces(string(raw)) + for _, module := range slices.Sorted(maps.Keys(local)) { + by := local[module] + b, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(by), "go.mod")) + if err != nil { + continue // a replace to a directory with no go.mod: Go says so in its own words + } + add(string(b), by) + } + return out +} + +// localReplaces are the replaces of a go.mod to a directory (., .., ./x, ../x), by module: the directory as written. +func localReplaces(gomod string) map[string]string { + out := map[string]string{} + inBlock := false + for _, line := range strings.Split(gomod, "\n") { + if i := strings.Index(line, "//"); i >= 0 { + line = line[:i] + } + f := strings.Fields(line) + switch { + case len(f) == 0: + continue + case inBlock && f[0] == ")": + inBlock = false + continue + case !inBlock && f[0] == "replace" && len(f) == 2 && f[1] == "(": + inBlock = true + continue + case !inBlock && f[0] != "replace": + continue + case !inBlock: + f = f[1:] + } + at := slices.Index(f, "=>") + if at < 1 || at+1 >= len(f) { + continue + } + target := strings.Trim(f[at+1], `"`) + if target == "." || target == ".." || strings.HasPrefix(target, "./") || strings.HasPrefix(target, "../") { + out[strings.Trim(f[0], `"`)] = filepath.ToSlash(filepath.Clean(target)) + } + } + return out +} + +// takeZeroPins takes, for each requirement at the zero version of dir's module graph (zeroRequirementsOf) when a go +// failure names the zero version, the version it is built against (takeZeroRequirements). It answers what it took; none +// when no zero requirement is named. +func (m *Module) takeZeroPins(ctx context.Context, root, repository, dir, d, failure string, asked sdkstep.PackageVersions, pins *sync.Mutex) ([]TakenVersion, error) { + if !strings.Contains(failure, zeroShort) { return nil, nil } + return m.takeZeroRequirements(ctx, root, repository, dir, d, zeroRequirementsOf(dir), asked, pins) +} + +// takeReplacedZeroPins takes, before anything is downloaded, the requirements at the zero version that only a module +// dir replaces by a directory names (zeroRequirementsOf): go mod download loads only what dir's own go.mod names, so it +// passes without them, and the shut-off steps would look them up on the network (issue 1966). One the asked versions +// took (before, takeVersions) is left as it is. +func (m *Module) takeReplacedZeroPins(ctx context.Context, root, repository, dir, d string, asked sdkstep.PackageVersions, before []TakenVersion, pins *sync.Mutex) ([]TakenVersion, error) { + var replaced []zeroRequirement + for _, r := range zeroRequirementsOf(dir) { + if r.by != "" && !slices.ContainsFunc(before, func(tv TakenVersion) bool { return tv.Package == r.module }) { + replaced = append(replaced, r) + } + } + if len(replaced) == 0 { + return nil, nil + } + return m.takeZeroRequirements(ctx, root, repository, dir, d, replaced, asked, pins) +} + +// takeZeroRequirements takes, for each requirement at the zero version, the version it is built against (ADR 0573 §2, +// §4: whatever go.mod names): the commit the ask names for the package; else the head of the package's repository among +// the asked versions (the check's or try's members: another package of the same repository names it); else the latest +// published version, the trunk's head. It never looks for the commit 000000000000 among the pull request heads. One +// taken before (dir's modfile requires or replaces it) is left as it is. +func (m *Module) takeZeroRequirements(ctx context.Context, root, repository, dir, d string, reqs []zeroRequirement, asked sdkstep.PackageVersions, pins *sync.Mutex) ([]TakenVersion, error) { resolved := map[string]string{} if modfile, ok := m.pinModfile(dir); ok { if b, err := os.ReadFile(modfile); err == nil { @@ -281,14 +382,16 @@ func (m *Module) takeZeroPins(ctx context.Context, root, repository, dir, d, fai } } var taken []TakenVersion - for _, p := range forgePins(string(raw)) { - if p.short != zeroShort { - continue - } + for _, r := range reqs { + p := r.pin if v := resolved[p.module]; v != "" && !strings.HasSuffix(v, zeroShort) { continue // taken before } named := "v0.0.0-00010101000000-" + zeroShort + namedBy := "" + if r.by != "" { + namedBy = "; " + namedBySaid(r.by) + } if "novox/"+p.repo == repository { // A module of the checked repository itself: it is in the fetched tree and nowhere else, since nothing of the // repository is published before its merge. Every module of the tree goes in, as one may require another. @@ -299,7 +402,7 @@ func (m *Module) takeZeroPins(ctx context.Context, root, repository, dir, d, fai if !slices.Contains(own, p.module) { return taken, fmt.Errorf("the requirement of %s at the zero version: no module of that path is in the fetched tree of %s, and nothing of a repository is published before its merge", p.module, repository) } - tv := TakenVersion{Dir: d, Package: p.module, Named: named, Built: "the fetched tree", Said: "a module of the checked repository: built from its tree"} + tv := TakenVersion{Dir: d, Package: p.module, Named: named, Built: "the fetched tree", Said: "a module of the checked repository: built from its tree" + namedBy} if m.Say != nil { m.Say("%s: %s names no commit; built from the fetched tree of %s", d, p.module, repository) } @@ -316,7 +419,7 @@ func (m *Module) takeZeroPins(ctx context.Context, root, repository, dir, d, fai if err := m.takeVersion(ctx, root, dir, &tv, pins); err != nil { return taken, fmt.Errorf("the requirement of %s at the zero version: %w", p.module, err) } - tv.Said = strings.TrimPrefix(tv.Said+"; "+why, "; ") + tv.Said = strings.TrimPrefix(tv.Said+"; "+why+namedBy, "; ") if m.Say != nil { m.Say("%s: %s names no commit; built at %s (%s)", d, p.module, tv.Built, why) } @@ -325,6 +428,9 @@ func (m *Module) takeZeroPins(ctx context.Context, root, repository, dir, d, fai return taken, nil } +// namedBySaid says that a module dir replaces by the directory by names a package, and dir's go.mod does not. +func namedBySaid(by string) string { return "named by " + by + ", not by this go.mod" } + // askedFor is the commit the ask names for the package p: its own, else that of the first package of the same repository // in order (the members' heads are by repository), else none. func askedFor(asked sdkstep.PackageVersions, p pin) string { diff --git a/modules/mesh-build-go/cmd/mesh-build-go/pins_test.go b/modules/mesh-build-go/cmd/mesh-build-go/pins_test.go index 62bec6fe7..9ce00ffea 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/pins_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/pins_test.go @@ -305,6 +305,9 @@ func TestARequirementAtTheZeroVersionIsTakenFromTheAskOrTheTrunk(t *testing.T) { download string // what go mod download -json answers want string // the version asked of go head bool // the asked commit is on no trunk: only a pull request head carries it + // replaced: the directory's go.mod names no mesh package; a module it replaces by a directory of the tree requires + // it at the zero version, so go mod download passes and only a shut-off package load would look it up (1966/AC178-1) + replaced bool }{ {name: "the ask names the package", packages: map[string]any{sdk: trunk}, want: sdk + "@" + trunk, download: `{"Version":"v0.1.15-0.20261101000000-aaaaaaaaaaaa","Sum":"h1:zip="}`}, @@ -316,13 +319,31 @@ func TestARequirementAtTheZeroVersionIsTakenFromTheAskOrTheTrunk(t *testing.T) { {name: "the ask names nothing of its repository", packages: map[string]any{"git.novox.be/novox/mesh-tools/go": trunk}, want: sdk + "@latest", download: `{"Version":"v0.1.20-0.20261104000000-bbbbbbbbbbbb","Sum":"h1:zip="}`}, {name: "no ask", want: sdk + "@latest", download: `{"Version":"v0.1.20-0.20261104000000-bbbbbbbbbbbb","Sum":"h1:zip="}`}, + {name: "named only by a module the directory replaces, the ask names it", packages: map[string]any{sdk: trunk}, want: sdk + "@" + trunk, replaced: true, + download: `{"Version":"v0.1.15-0.20261101000000-aaaaaaaaaaaa","Sum":"h1:zip="}`}, + {name: "named only by a module the directory replaces, no ask", want: sdk + "@latest", replaced: true, + download: `{"Version":"v0.1.20-0.20261104000000-bbbbbbbbbbbb","Sum":"h1:zip="}`}, } { t.Run(c.name, func(t *testing.T) { - h, states := newPinHarness(t, zeroMod, false, pullList) + gomod := zeroMod + if c.replaced { + gomod = "module fixture/member\n\ngo 1.26\n\nrequire fixture/sib v0.0.0-00010101000000-" + zeroShort + "\n\nreplace fixture/sib => ./sib\n" + } + h, states := newPinHarness(t, gomod, false, pullList) base := h.m.Run h.m.Run = func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran { line := name + " " + strings.Join(args, " ") switch { + case c.replaced && name == "git" && strings.Contains(line, " checkout "): + r := base(ctx, env, name, args...) + trees, _ := filepath.Glob(filepath.Join(h.m.Work, "*", "tree")) + for _, tree := range trees { + os.MkdirAll(filepath.Join(tree, "sib"), 0o755) + os.WriteFile(filepath.Join(tree, "sib", "go.mod"), []byte(strings.Replace(zeroMod, "fixture/member", "fixture/sib", 1)), 0o644) + } + return r + case c.replaced && name == "go" && strings.Contains(line, " mod download") && !strings.Contains(line, "-json"): + return forgekey.Ran{} // go.mod names nothing missing: the download passes case name == "go" && strings.Contains(line, " mod download -json "): h.mu.Lock() h.cmds = append(h.cmds, cmdRecord{env: env, line: line}) @@ -357,6 +378,13 @@ func TestARequirementAtTheZeroVersionIsTakenFromTheAskOrTheTrunk(t *testing.T) { if h.ran("pinned/mesh-sdk@" + zeroShort) { t.Error("a repository was placed at the commit of zeros") } + if c.replaced { + var res FetchResult + json.Unmarshal(st.Result, &res) + if len(res.Packages) != 1 || res.Packages[0].Package != sdk || !strings.Contains(res.Packages[0].Said, "named by sib, not by this go.mod") { + t.Errorf("the taken versions = %+v, want %s said as named by sib", res.Packages, sdk) + } + } }) } } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/versions.go b/modules/mesh-build-go/cmd/mesh-build-go/versions.go index a34b31747..8dbd79c6d 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/versions.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/versions.go @@ -66,6 +66,13 @@ func (m *Module) takeVersions(ctx context.Context, root, dir, d string, asked sd return nil, nil } required, replaced := goModRequirements(string(raw)) + // A package only a module dir replaces by a directory names is in dir's module graph too (zeroRequirementsOf). + namedBy := map[string]string{} + for _, r := range zeroRequirementsOf(dir) { + if _, ok := required[r.module]; !ok && r.by != "" { + required[r.module], namedBy[r.module] = zeroVersion, r.by + } + } var taken []TakenVersion for _, pkg := range slices.Sorted(maps.Keys(asked)) { named, ok := required[pkg] @@ -81,6 +88,9 @@ func (m *Module) takeVersions(ctx context.Context, root, dir, d string, asked sd return taken, err } } + if by := namedBy[pkg]; by != "" { + tv.Said = strings.TrimPrefix(tv.Said+"; "+namedBySaid(by), "; ") + } if m.Say != nil { m.Say("%s: %s is built at %s (asked %.12s, go.mod names %s)%s", d, pkg, tv.Built, tv.Asked, tv.Named, saidSuffix(tv.Said)) } From 4a3914ee1ce7233f4f91b769b6f88c1a0a512ba6 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 10 Nov 2026 13:10:44 +0100 Subject: [PATCH 3/3] S4: the fetch proves with a shut-off package load that it downloaded all a step loads (issue 1966) go mod download loads only the module graph a go.mod names; a vet, a test or a build then loads packages shut off from the network and failed far from the fetch, in fewer words (the builder's "pull_drop.go:11:2: module lookup disabled by GOPROXY=off"). After each directory's download the fetch loads its packages with their tests shut off and fails naming each package whose load would reach the network. The graph of a sibling laid beside is loaded once with the network first, so a go.mod the composer's build reads and the graph's download skipped is fetched, then proven the same way. 1966/AC178-2: a row of TestAVerifyThatFailsEndsTheStepNamingTheDirectoryAndTheLine; the beside case in TestTheFetchDownloadsTheGraphOfASiblingModuleLaidWithTheTreesItRequires. Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ --- .../mesh-build-go/cmd/mesh-build-go/fetch.go | 26 +++++++- .../cmd/mesh-build-go/fetch_test.go | 65 ++++++++++++++----- .../cmd/mesh-build-go/workspace.go | 8 +++ .../cmd/mesh-build-go/workspace_test.go | 19 +++++- 4 files changed, 98 insertions(+), 20 deletions(-) diff --git a/modules/mesh-build-go/cmd/mesh-build-go/fetch.go b/modules/mesh-build-go/cmd/mesh-build-go/fetch.go index 8eb13073a..aa9024e38 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/fetch.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/fetch.go @@ -13,6 +13,7 @@ import ( "os" "path/filepath" "regexp" + "slices" "sort" "strings" "sync" @@ -212,7 +213,9 @@ func (m *Module) downloadModules(ctx context.Context, root, repository, dir, d s for { failure := m.downloadAndVerify(ctx, dir, d) if failure == nil { - return taken, nil + // The proof that what was downloaded is all the shut-off steps load: else the fetch fails here, naming the + // package, and not a vet, a test or a build later with less to say (issue 1966). + return taken, m.shutOffLoad(ctx, m.goEnvFor(dir), dir, d) } // A requirement at the zero version names no commit: it is taken from the asked versions or the trunk's latest. took, err := m.takeZeroPins(ctx, root, repository, dir, d, failure.Error(), asked, pins) @@ -232,6 +235,27 @@ func (m *Module) downloadModules(ctx context.Context, root, repository, dir, d s } } +// shutOffLoadFormat is what go list -e says of each package it loads: nothing, or the package and Go's words of its error. +const shutOffLoadFormat = `{{with .Error}}{{$.ImportPath}}: {{.Err}}{{"\n"}}{{end}}` + +// shutOffLoad loads the packages of dir, with their tests and every package they import, as a shut-off step does +// (GOPROXY=off, the step's own environment env), and fails naming each package whose load would reach the network, in +// Go's words. A package that fails to load for another cause (a compile error, an import of a package of the module +// that does not exist) is the shut-off steps' to say, with their own verdict lines, and is not the fetch's. +func (m *Module) shutOffLoad(ctx context.Context, env []string, dir, d string) error { + r := m.Run(ctx, append(env, "GOPROXY=off"), "go", "-C", dir, "list", "-e", "-deps", "-test", "-f", shutOffLoadFormat, "./...") + var lost []string + for _, l := range strings.Split(r.Stdout+"\n"+r.Stderr, "\n") { + if l = strings.TrimSpace(l); strings.Contains(l, "GOPROXY=off") && !slices.Contains(lost, l) { + lost = append(lost, l) + } + } + if len(lost) > 0 { + return fmt.Errorf("the packages of %s load shut off only with what the fetch did not download: %s", d, strings.Join(lost, "; ")) + } + return nil +} + // verifyLocks hold one mutex per digest of a go.mod and go.sum, so two directories with the same pair verify once. var verifyLocks sync.Map diff --git a/modules/mesh-build-go/cmd/mesh-build-go/fetch_test.go b/modules/mesh-build-go/cmd/mesh-build-go/fetch_test.go index d76472a6f..7b91bd94a 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/fetch_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/fetch_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "os" "path/filepath" + "slices" "strings" "sync" "testing" @@ -211,22 +212,54 @@ func TestNoCommandOfTheStepSeesAPasswordOrAToken(t *testing.T) { // 1485/AC3: go mod verify failing ends the step failed naming the directory and the first line of its output. func TestAVerifyThatFailsEndsTheStepNamingTheDirectoryAndTheLine(t *testing.T) { - f := &fakeStep{gomods: "go.mod\ntools/go.mod\n", goRule: func(line string) forgekey.Ran { - if strings.HasSuffix(line, "tools mod verify") || strings.Contains(line, "/tools mod verify") { - return forgekey.Ran{Status: 1, Stdout: "\nexample.org/x v1.0.0: dir has been modified\nsecond line\n"} - } - return forgekey.Ran{} - }} - m, states := fetchModule(t, f) - if _, err := fetchTool(t, m)(fetchArgs); err != nil { - t.Fatal(err) - } - st := states.waitEnd(t) - if st.State != sdkstep.StepFailed { - t.Fatalf("the step ended %s, want failed", st.State) - } - if !strings.Contains(st.Reason, "tools") || !strings.Contains(st.Reason, "example.org/x v1.0.0: dir has been modified") || strings.Contains(st.Reason, "second line") { - t.Errorf("reason = %q", st.Reason) + const seatPkg = "git.novox.be/novox/mesh-delivery/contracts/go/seats/nodebuildgo" + for _, c := range []struct { + name, ran, out string + want []string + notWant string + }{ + {name: "go mod verify fails", ran: "tools mod verify", out: "\nexample.org/x v1.0.0: dir has been modified\nsecond line\n", + want: []string{"tools", "example.org/x v1.0.0: dir has been modified"}, notWant: "second line"}, + // 1966/AC178-2: what the fetch downloaded misses a package the directory's code imports: the shut-off package load + // fails the fetch naming the package in Go's words, not a shut-off step later. + {name: "the shut-off package load would reach the network", ran: "tools list -e -deps -test", + out: "fixture/tools/cmd: \n" + seatPkg + ": module lookup disabled by GOPROXY=off\n", + want: []string{"tools", "shut off", seatPkg + ": module lookup disabled by GOPROXY=off"}, notWant: "fixture/tools/cmd"}, + } { + t.Run(c.name, func(t *testing.T) { + f := &fakeStep{gomods: "go.mod\ntools/go.mod\n", goRule: func(line string) forgekey.Ran { + if strings.Contains(line, "/"+c.ran) { + if strings.Contains(line, " list ") { + return forgekey.Ran{Stdout: c.out} // go list -e says a package's error and exits 0 + } + return forgekey.Ran{Status: 1, Stdout: c.out} + } + return forgekey.Ran{} + }} + m, states := fetchModule(t, f) + if _, err := fetchTool(t, m)(fetchArgs); err != nil { + t.Fatal(err) + } + st := states.waitEnd(t) + if st.State != sdkstep.StepFailed { + t.Fatalf("the step ended %s, want failed", st.State) + } + for _, w := range c.want { + if !strings.Contains(st.Reason, w) { + t.Errorf("reason = %q, want it to hold %q", st.Reason, w) + } + } + if strings.Contains(st.Reason, c.notWant) { + t.Errorf("reason = %q holds %q", st.Reason, c.notWant) + } + if c.ran == "tools list -e -deps -test" { + for _, call := range f.calls { + if strings.Contains(call.line, " list -e -deps") && !slices.Contains(call.env, "GOPROXY=off") { + t.Errorf("the package load ran with the network: %s", call.env) + } + } + } + }) } } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/workspace.go b/modules/mesh-build-go/cmd/mesh-build-go/workspace.go index 2fc825525..b9184fdf8 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/workspace.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/workspace.go @@ -171,6 +171,14 @@ func (m *Module) downloadBeside(ctx context.Context, root, tree string, dirs []s if res := m.Run(ctx, env, "go", "-C", r, "mod", "download"); res.Status != 0 || res.Err != "" { return fmt.Errorf("go mod download of the graph of %s beside: %s", filepath.Base(r), causeLine(res.Err+"\n"+res.Stdout+"\n"+res.Stderr)) } + // What the packages' load needs beyond the module graph's download (the go.mod of a version a dependency requires, + // which a build of the composer reads: issue 1966), fetched with the network, then proven shut off. + if res := m.Run(ctx, env, "go", "-C", r, "list", "-e", "-deps", "-test", "-f", shutOffLoadFormat, "./..."); res.Status != 0 || res.Err != "" { + return fmt.Errorf("go list of the packages of %s beside: %s", filepath.Base(r), causeLine(res.Err+"\n"+res.Stdout+"\n"+res.Stderr)) + } + if err := m.shutOffLoad(ctx, env, r, "beside "+filepath.Base(r)); err != nil { + return err + } } return nil } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/workspace_test.go b/modules/mesh-build-go/cmd/mesh-build-go/workspace_test.go index 49990aeb5..1ad94163e 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/workspace_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/workspace_test.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "path/filepath" + "slices" "strings" "testing" @@ -72,7 +73,7 @@ func TestTheFetchDownloadsTheGraphOfASiblingModuleLaidWithTheTreesItRequires(t * put(filepath.Join(root, "beside", "mesh-sdk", "go"), "module git.novox.be/novox/mesh-sdk/go\n\ngo 1.26.0\n") var said []string var lines, envs []string - fail := false + fail, lost := false, "" m := &Module{Say: func(f string, a ...any) { said = append(said, fmt.Sprintf(f, a...)) }} m.Run = func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran { lines = append(lines, name+" "+strings.Join(args, " ")) @@ -80,14 +81,26 @@ func TestTheFetchDownloadsTheGraphOfASiblingModuleLaidWithTheTreesItRequires(t * if fail { return forgekey.Ran{Status: 1, Stderr: "dial tcp: network is unreachable"} } + if slices.Contains(env, "GOPROXY=off") { + return forgekey.Ran{Stdout: lost} + } return forgekey.Ran{} } if err := m.downloadBeside(context.Background(), root, tree, []string{"modules/x"}); err != nil { t.Fatal(err) } - if len(lines) != 1 || lines[0] != "go -C "+host+" mod download" || !strings.Contains(envs[0], "GOWORK="+filepath.Join(root, "beside-0.work")) { - t.Fatalf("commands %q env %q, want one go mod download of the node-engine's module over its workspace", lines, envs) + // The graph's download, the packages' load with the network, then the same load shut off (issue 1966). + load := "go -C " + host + " list -e -deps -test -f " + shutOffLoadFormat + " ./..." + if len(lines) != 3 || lines[0] != "go -C "+host+" mod download" || lines[1] != load || lines[2] != load || + !strings.Contains(envs[0], "GOWORK="+filepath.Join(root, "beside-0.work")) || strings.HasSuffix(envs[1], "GOPROXY=off") || !strings.HasSuffix(envs[2], "GOPROXY=off") { + t.Fatalf("commands %q env %q, want go mod download of the node-engine's module over its workspace, then its packages loaded with the network and shut off", lines, envs) } + // 1966/AC178-3 (its unit half): the shut-off load misses a go.mod the composer's build reads: the fetch fails naming it. + lines, lost = nil, "github.com/nats-io/nkeys: github.com/nats-io/nkeys@v0.4.16 requires golang.org/x/crypto@v0.52.0: module lookup disabled by GOPROXY=off\n" + if err := m.downloadBeside(context.Background(), root, tree, []string{"modules/x"}); err == nil || !strings.Contains(err.Error(), "beside mesh-host") || !strings.Contains(err.Error(), "golang.org/x/crypto@v0.52.0: module lookup disabled") { + t.Errorf("a shut-off load that would reach the network = %v, want the fetch failed naming it", err) + } + lost = "" work, _ := os.ReadFile(filepath.Join(root, "beside-0.work")) for _, want := range []string{filepath.Join(tree, "modules", "x"), filepath.Join(root, "beside", "mesh-sdk", "go"), host} { if !strings.Contains(string(work), want) {