Build Go with the installed go and name no Go version (issue 1572, ADR 0477)

This commit is contained in:
2026-10-21 08:02:54 +00:00
parent 7fcb9a743b
commit 049202d373
7 changed files with 51 additions and 70 deletions
@@ -145,8 +145,8 @@ 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 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
// buildEnv is the environment of the compile: the build agent's (CGO_ENABLED=0, GOFLAGS) with the installed go
// alone (GOTOOLCHAIN=local), 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 {
mod := "-mod=mod"
@@ -156,7 +156,7 @@ func (m *Module) buildEnv(dir string) []string {
return []string{
"CGO_ENABLED=0",
"GOFLAGS=" + recipeFlags + " " + mod,
"GOTOOLCHAIN=" + RecipeGo,
"GOTOOLCHAIN=local",
"GOMODCACHE=" + filepath.Join(m.Cache, "gomod"),
"GOCACHE=" + filepath.Join(m.Cache, "gobuild"),
"GOPROXY=off",
@@ -20,7 +20,7 @@ import (
)
// DigestVersion is the form of the digest's text: changed whenever what the digest hashes changes in kind.
const DigestVersion = "1"
const DigestVersion = "2" // 2: no Go version in the text; the toolchain enters a fingerprint as the agreed version (ADR 0477 §3)
// goPackage is the part of `go list -json` a digest reads.
type goPackage struct {
@@ -106,7 +106,7 @@ func (m *Module) digest(ctx context.Context, args map[string]any) (any, error) {
return nil, fmt.Errorf("no workspace %s: it was never fetched here, or it is older than a day and was removed", workspace)
}
var tags []string
header := []string{"digest " + DigestVersion, "level " + level, "go " + m.Recipe.Go}
header := []string{"digest " + DigestVersion, "level " + level}
switch level {
case LevelUnit:
case LevelIntegration:
@@ -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 recipe's Go release (ADR 0476: the probe put it in the module's cache; a step never fetches it),
// goEnv is what a go command of a step is given beyond the base environment: the installed go alone (GOTOOLCHAIN=local, ADR 0477 §1: no toolchain is ever downloaded),
// 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=" + RecipeGo,
"GOTOOLCHAIN=local",
"GOFLAGS=-mod=mod",
"GOMODCACHE=" + filepath.Join(m.Cache, "gomod"),
"GOCACHE=" + filepath.Join(m.Cache, "gobuild"),
@@ -165,8 +165,7 @@ 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)").
// downloading ...` comes first when Go fetches a module, and the cause of a failure on a later line.
func causeLine(s string) string {
for _, l := range strings.Split(s, "\n") {
if l = strings.TrimSpace(l); l != "" && !strings.HasPrefix(l, "go: downloading ") {
@@ -1,8 +1,8 @@
// mesh-build-go: the Go build module (novox/hq ADR 0405, ADR 0466 §1, ADR 0469, ADR 0470; design 47 R4c-3, issue
// 1483). A Go tools bundle the node's runtime launches as the module's own account (runs-as). It names the one Go
// toolchain version it builds with, writes the version it finds to <node>.go of its `toolchains` state at start and
// before every step, and refuses every step on a node that has another: its health is then unhealthy, so the
// controller raises the node's condition. What it says, it says on stderr; stdout is the MCP channel.
// 1483). A Go tools bundle the node's runtime launches as the module's own account (runs-as). It builds with the
// go its node has installed (GOTOOLCHAIN=local, ADR 0477 §1; issue 1572), names no Go version, writes the version it
// finds to <node>.go of its `toolchains` state at start and before every step, and refuses a step only when go
// cannot be run: its health is then unhealthy, so the controller raises the node's condition. What it says, it says on stderr; stdout is the MCP channel.
package main
import (
@@ -45,14 +45,21 @@ type Module struct {
StartBus func(ctx context.Context, dir string) (url string, stop func(), err error)
}
func (m *Module) guard() buildstep.Guard {
return buildstep.Guard{Language: "go", Want: m.Recipe.Go, Recipe: m.Recipe.Version(), Probe: m.Probe, Node: m.Node, Put: m.reportingPut()}
// guard is the SDK guard for the version the probe just read: the module names no Go version (ADR 0477 §2), so the
// version wanted is the one installed and the guard refuses only when go cannot be run at all, writing the installed
// version to <node>.go of the toolchains state every time.
func (m *Module) guard(installed string, probeErr error) buildstep.Guard {
return buildstep.Guard{Language: "go", Want: installed, Recipe: m.Recipe.Version(), Node: m.Node, Put: m.reportingPut(),
Probe: func(context.Context) (string, error) { return installed, probeErr }}
}
// Check reads the installed toolchain, writes <node>.go of the toolchains state and answers nil, or the refusal
// (buildstep.Guard.Check). It runs at start, before every step, at every health look and every CheckRound.
// Check reads the installed toolchain, writes <node>.go of the toolchains state and answers nil, or the refusal when
// go cannot be run (buildstep.Guard.Check). A version other than another node's is not a refusal: the indexer waits
// while build nodes disagree (ADR 0477 §2). It runs at start, before every step, at every health look and every
// CheckRound.
func (m *Module) Check(ctx context.Context) error {
return m.guard().Check(ctx)
installed, err := m.Probe(ctx)
return m.guard(installed, err).Check(ctx)
}
// Start checks once, as the module starts, and then every CheckRound until ctx ends.
@@ -70,22 +77,20 @@ func (m *Module) Start(ctx context.Context) {
}
}
// Health is the module's health (the runtime-health tool, ADR 0366): unhealthy while the toolchain installed is not
// the recipe's, saying both versions, so the controller raises the node's condition. It looks again each time it is
// Health is the module's health (the runtime-health tool, ADR 0366): unhealthy only while go cannot be run, saying why, so the
// controller raises the node's condition; healthy it names the installed version (ADR 0477 §2). It looks again each time it is
// asked.
func (m *Module) Health(ctx context.Context) health.Answer {
err := m.Check(ctx)
switch {
case err == nil:
return health.Healthy(fmt.Sprintf("%s installed, as the recipe %s builds with", m.Recipe.Go, m.Recipe.Version()))
default:
installed, perr := m.Probe(ctx)
if err := m.guard(installed, perr).Check(ctx); err != nil {
return health.Unhealthy(err.Error())
}
return health.Healthy(fmt.Sprintf("%s installed, the version the module builds with (recipe %s)", installed, m.Recipe.Version()))
}
// Long is a tool that runs a long step (ADR 0469 §2): it answers at once with the step's id, and the step ends on
// its key in the steps state. The guard runs inside the step, before run, so a node that refuses ends every step
// refused with both versions named.
// its key in the steps state. The guard runs inside the step, before run, so a node whose go cannot be run ends every step
// refused with the cause named.
func (m *Module) Long(tool string, run func(ctx context.Context, args map[string]any) (any, error)) func(map[string]any) (any, error) {
return func(args map[string]any) (any, error) {
id, err := buildstep.Start(m.Ctx, m.Steps, m.Node, tool, func(ctx context.Context) (any, error) {
@@ -118,7 +123,7 @@ func (m *Module) Status(ctx context.Context) map[string]any {
err := m.Check(ctx)
installed, _ := m.Probe(ctx)
out := map[string]any{
"node": m.Node, "recipe": m.Recipe.Version(), "go": m.Recipe.Go, "installed": installed,
"node": m.Node, "recipe": m.Recipe.Version(), "installed": installed,
"postgres-major": m.Recipe.PostgresMajor, "nats-server": m.Recipe.NatsServer, "refusing": err != nil,
}
if err != nil {
@@ -10,52 +10,34 @@ import (
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// 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
)
// ProbeTimeout bounds `go version`: it reads the installed go and fetches nothing (ADR 0477 §1).
const ProbeTimeout = 15 * time.Second
// 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.
// ProbeGo is the probe of the guard (novox/hq ADR 0477 §1, §2; issue 1572): it runs `go version` with
// GOTOOLCHAIN=local, so it reads the version of the go the node's package manager installed, which is what the
// module builds with, and the third word of its answer is that version. It downloads nothing and needs no network.
// 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.
// probeEnv is what the probe gives `go version` beyond the base environment: the installed go alone, and the
// module's caches, so nothing is written to the account's home.
func probeEnv(cache string) []string {
return []string{
"GOTOOLCHAIN=" + RecipeGo,
"GOTOOLCHAIN=local",
"GOMODCACHE=" + filepath.Join(cache, "gomod"),
"GOCACHE=" + filepath.Join(cache, "gobuild"),
}
}
// 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)
ctx, cancel := context.WithTimeout(ctx, ProbeTimeout)
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 "", fmt.Errorf("`go version` with GOTOOLCHAIN=local failed: %s", causeLine(r.Err+"\n"+r.Stderr))
}
return parseGoVersion(r.Stdout)
}
@@ -1,9 +1,9 @@
package main
// The recipe of the Go build module (novox/hq ADR 0405 §1 and §7, ADR 0469 §1, ADR 0470 §4; issue 1483): the one
// Go toolchain version it builds with, the one postgres major version its integration level tests with, and the
// recipe's own version. A node whose installed Go differs from the recipe's refuses every step (guard.go), so all
// builders run this version or none builds.
// postgres major version its integration level tests with, and the recipe's own version. The module names no Go
// version (ADR 0477 §1, §2; issue 1572): it builds with the go its node has installed (GOTOOLCHAIN=local), reports
// that version to the toolchains state, and the indexer takes the version the build nodes agree on.
import (
"crypto/sha256"
@@ -15,15 +15,11 @@ import (
"github.com/nats-io/nats-server/v2/server"
)
// RecipeGo is the Go version the recipe builds with: the text `go version` prints for it, which is what the guard
// compares and what the indexer reads from <node>.go of the toolchains state.
const RecipeGo = "go1.27.1"
// PostgresMajor is the postgres major version the integration level tests with (ADR 0470 §4).
const PostgresMajor = 18
// RecipeSteps is the recipe's own version: changed whenever the steps of fetch, test or build change in a way
// that can change what they produce (ADR 0405 §7). It is one of the four values the recipe version hashes.
// that can change what they produce (ADR 0405 §7). It is one of the three values the recipe version hashes.
const RecipeSteps = "1"
// natsModule is the library the integration level's bus is started from (ADR 0470 §2).
@@ -33,8 +29,6 @@ const natsModule = "github.com/nats-io/nats-server/v2"
type Recipe struct {
// Steps is RecipeSteps.
Steps string
// Go is the toolchain's version as `go version` prints it.
Go string
// PostgresMajor is the integration level's postgres major version.
PostgresMajor int
// NatsServer is the nats-server library's version, from the program's build information.
@@ -43,16 +37,17 @@ type Recipe struct {
// Current is the recipe of this build of the module.
func Current() Recipe {
return Recipe{Steps: RecipeSteps, Go: RecipeGo, PostgresMajor: PostgresMajor, NatsServer: natsServerVersion()}
return Recipe{Steps: RecipeSteps, PostgresMajor: PostgresMajor, NatsServer: natsServerVersion()}
}
// Version is the recipe version: a hash of the recipe's own version, the Go version, the postgres major version and
// the nats-server version, so a change of any of the four moves it and with it every fingerprint that holds it
// Version is the recipe version: a hash of the recipe's own version, the postgres major version and the
// nats-server version, never a Go version (ADR 0477 §3): the toolchain enters each fingerprint once, as the version
// the build nodes agree on (ADR 0465). A change of any of the three moves it and with it every fingerprint that holds it
// (ADR 0405 §7, ADR 0465 §2). The fields are named in the hashed text, so no two recipes share a text.
func (r Recipe) Version() string {
h := sha256.New()
for _, f := range [][2]string{
{"steps", r.Steps}, {"go", r.Go}, {"postgres", strconv.Itoa(r.PostgresMajor)}, {"nats-server", r.NatsServer},
{"steps", r.Steps}, {"postgres", strconv.Itoa(r.PostgresMajor)}, {"nats-server", r.NatsServer},
} {
h.Write([]byte(f[0] + "=" + f[1] + "\n"))
}