Compare commits

..
Author SHA1 Message Date
jschoubben 8bc54a3e27 Merge remote-tracking branch 'origin/main' into fix/888-forge-merge-moves-source 2026-10-23 14:17:34 +02:00
jschoubben dfcdf234c5 Never move source.main back for a forge-heard merge, by dating the head it holds (issue 888, review round 1)
A merge the gate handed is known by the bridge's word before the forge's, and finish wrote it already; an older forge-only
merge heard in between overwrote it, since only the forge's word was dated and the write never compared with the head held.
A merge is now dated by either word, and the write leaves a head that is a later merge, or one the gate cannot date while
the merge is older than the records it keeps (a pruned newer merge).
2026-10-23 12:36:58 +02:00
jschoubben 5bc9578634 Move source.main for a merge heard only from the forge, so the checker judges on the trunk that is (issue 888)
The checker reads the trunk only from the gate's source.main, and the gate moved it only in finish, for a merge it handed to
the forge bridge on an Approve. A pull request merged at the forge directly moved every other delivery's trunk (trunkMoved)
and left source.main behind, so the checker kept judging candidates on the trunk before it (catalogue 89e15c7b after
6c0024e4, 2026-10-23).

settleForgeMerges now writes such a merge into source.main by compare-and-set, never a merge replayed as history or older
than one of its repository already heard; an approved delivery merged at the forge before the gate handed it is moved by
finish with its group, once.
2026-10-23 12:23:40 +02:00
30 changed files with 344 additions and 1609 deletions
-10
View File
@@ -105,16 +105,6 @@ fingerprint once, as the version the build nodes agree on (ADR 0477 §3), and th
version's. A package with no tests at the level has no digest and is named in `without_tests`. A directory that vendors
is listed and tested from its vendor directory (`-mod=vendor`).
## A change to this module is judged by its candidate
`build_go_digest` and `build_go_test` take `runner`: `installed` (the default) runs the step as this program;
`workspace` compiles the workspace's own `modules/mesh-build-go` (`go build` with `GOPROXY=off`, `GOTOOLCHAIN=local`, in a
run step shut off) into the workspace's `candidate/mesh-build-go`, once per workspace, and runs the step as that program:
`<candidate> level <spec>` in one bubblewrap, the spec naming the step (`digest`, `unit` or `integration`). This program
keeps the fetch, the forge key and the bus login (ADR 0535 §1). A candidate that does not compile fails the step with the
compiler's first error, and nothing runs (§3). Every digest and level answer says `judged_by`: `installed`, or
`candidate <commit of the tree>`. The checker asks `workspace` when a merge changes `modules/mesh-build-go/` (issue 1898).
## Build
`build_go_build {workspace, module, artifact}` builds one Go bundle artifact of a module of the workspace with the build
@@ -11,72 +11,37 @@ import (
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// writeSiblings records the siblings the fetch placed, as the fetch does.
func writeSiblings(t *testing.T, m *Module, placed []Sibling) {
t.Helper()
raw, _ := json.Marshal(placed)
if err := os.WriteFile(filepath.Join(m.Work, "ws1", besideRecord), raw, 0o600); err != nil {
t.Fatal(err)
}
}
// Issue 1896/AC1 and AC3 (ADR 0536 §1): a sibling's commit changes no digest of a package whose tests reach no file
// naming MESH_CHECK_BESIDE; the answer still echoes every placed sibling, in form 4.
func TestASiblingsCommitLeavesAPackageThatDoesNotReadTheSiblingsAsItWas(t *testing.T) {
// The lead's decision L5 (the checker keeps a verdict of a core repository): the fetch writes the siblings it placed
// beside the workspace, and the digest folds each one's repository and commit into every package's digest and echoes
// them, so a verdict is kept only for the siblings it was judged with.
func TestTheSiblingsCommitsAreInEveryDigestAndEchoed(t *testing.T) {
t.Parallel()
m, _ := digestFixture(t)
alone := digests(t, m, "unit")
if len(alone.Beside) != 0 {
t.Errorf("a workspace without siblings echoes %v", alone.Beside)
}
write := func(placed []Sibling) {
raw, _ := json.Marshal(placed)
if err := os.WriteFile(filepath.Join(m.Work, "ws1", besideRecord), raw, 0o600); err != nil {
t.Fatal(err)
}
}
other := strings.Repeat("b", 40)
writeSiblings(t, m, []Sibling{{Repository: "novox/mesh-sdk", Commit: commitSHA, Dir: "mesh-sdk"}, {Repository: "novox/mesh-catalog", Commit: commitSHA, Dir: "mesh-catalog"}})
write([]Sibling{{Repository: "novox/mesh-sdk", Commit: commitSHA, Dir: "mesh-sdk"}, {Repository: "novox/mesh-catalog", Commit: commitSHA, Dir: "mesh-catalog"}})
with := digests(t, m, "unit")
want := []string{"novox/mesh-catalog " + commitSHA, "novox/mesh-sdk " + commitSHA}
if !slices.Equal(with.Beside, want) {
t.Errorf("echoed %v, want %v", with.Beside, want)
}
writeSiblings(t, m, []Sibling{{Repository: "novox/mesh-sdk", Commit: other, Dir: "mesh-sdk"}, {Repository: "novox/mesh-catalog", Commit: commitSHA, Dir: "mesh-catalog"}})
write([]Sibling{{Repository: "novox/mesh-sdk", Commit: other, Dir: "mesh-sdk"}, {Repository: "novox/mesh-catalog", Commit: commitSHA, Dir: "mesh-catalog"}})
moved := digests(t, m, "unit")
for p, d := range alone.Packages {
if with.Packages[p] != d || moved.Packages[p] != d {
t.Errorf("%s: a sibling left its digest changed though no test of it reads the siblings", p)
if with.Packages[p] == d {
t.Errorf("%s: the siblings left the digest as it was without them", p)
}
}
if !slices.Equal(moved.Beside, []string{"novox/mesh-catalog " + commitSHA, "novox/mesh-sdk " + other}) {
t.Errorf("echoed %v after the move", moved.Beside)
}
}
// Issue 1896/AC2: a package whose test imports an in-tree package naming MESH_CHECK_BESIDE is judged again when a
// sibling moves; a package beside it that does not import it is not.
func TestASiblingsCommitChangesTheDigestOfAPackageWhoseTestsReachAReaderOfTheSiblings(t *testing.T) {
t.Parallel()
m, tree := digestFixture(t)
for name, text := range map[string]string{
"f/f.go": "package f\n\n// Beside is where the siblings lie.\nconst Beside = \"MESH_CHECK_BESIDE\"\n",
"e/e_test.go": "package e\n\nimport (\n\t\"testing\"\n\n\t\"fixture/digest/f\"\n)\n\nfunc TestE(t *testing.T) {\n\tif f.Beside == \"\" {\n\t\tt.Fatal(\"e\")\n\t}\n}\n",
"e/e.go": "package e\n",
"g/g_test.go": "package g\n\nimport \"testing\"\n\nfunc TestG(t *testing.T) {\n\t// MESH_CHECK_BESIDE named in the test's own file\n}\n",
"g/g.go": "package g\n",
} {
if err := os.MkdirAll(filepath.Dir(filepath.Join(tree, name)), 0o755); err != nil {
t.Fatal(err)
}
edit(t, tree, name, text)
}
writeSiblings(t, m, []Sibling{{Repository: "novox/mesh-sdk", Commit: commitSHA, Dir: "mesh-sdk"}})
before := digests(t, m, "unit")
writeSiblings(t, m, []Sibling{{Repository: "novox/mesh-sdk", Commit: strings.Repeat("b", 40), Dir: "mesh-sdk"}})
after := digests(t, m, "unit")
for _, p := range []string{"fixture/digest/e", "fixture/digest/g"} {
if len(before.Packages[p]) != 64 || before.Packages[p] == after.Packages[p] {
t.Errorf("%s reads the siblings and its digest stayed (%q, %q)", p, before.Packages[p], after.Packages[p])
}
}
for _, p := range []string{"fixture/digest/a", "fixture/digest/c"} {
if before.Packages[p] != after.Packages[p] {
t.Errorf("%s does not read the siblings and its digest changed", p)
if moved.Packages[p] == with.Packages[p] {
t.Errorf("%s: a sibling's new commit left the digest as it was", p)
}
}
}
@@ -116,7 +81,7 @@ func TestTheIntegrationLevelDigestsUnderBothTags(t *testing.T) {
t.Errorf("the integration digest listed %q", s.command)
}
}
if DigestVersion != "4" {
if DigestVersion != "3" {
t.Errorf("the digest's form changed in kind and is still version %s", DigestVersion)
}
}
@@ -1,132 +0,0 @@
package main
// A change to this module is judged by its own candidate (novox/hq ADR 0535; issue 1898): asked with the runner
// workspace, the digest and test steps compile the program of mesh-build-go from the workspace's merged tree in a run
// step, shut off, and run the step as that program in one shut-off process (`<candidate> level <spec>`, level.go), where
// this program would run the step's commands. This program keeps the fetch, the forge key and the bus login; the
// candidate's program holds none of them. Each answer says which program ran it (judged_by).
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
)
// The runners of a digest or test step: this installed program, or the candidate's compiled from the workspace.
const (
RunnerInstalled = "installed"
RunnerWorkspace = "workspace"
)
// runnerProperty is the runner argument as the digest and test tools announce it: a checker reads it there, and an
// installed module that does not announce it judges as it always did (ADR 0535 §2).
var runnerProperty = map[string]any{"type": "string", "enum": []string{RunnerInstalled, RunnerWorkspace},
"description": "optional: installed (the default) runs the step as this installed program; workspace compiles the " +
"workspace's own mesh-build-go (modules/mesh-build-go of the merged tree) shut off and runs the step as it, for a " +
"change to this module (ADR 0535); the answer's judged_by names the program that ran"}
// candidateModule is the directory of this module's go.mod in the catalogue's tree, and candidateMain its program's
// package there.
const (
candidateModule = "modules/mesh-build-go"
candidateMain = "./cmd/mesh-build-go"
)
// runnerArg reads the runner argument: "" (the installed program), installed or workspace.
func runnerArg(v any) (string, error) {
if v == nil {
return "", nil
}
s, ok := v.(string)
if !ok || (s != "" && s != RunnerInstalled && s != RunnerWorkspace) {
return "", fmt.Errorf("the runner %v is neither %s nor %s", v, RunnerInstalled, RunnerWorkspace)
}
return s, nil
}
// workspaceThere refuses a workspace id that is not one, or a workspace this node never fetched.
func (m *Module) workspaceThere(workspace string) error {
if !workspaceOK.MatchString(workspace) {
return fmt.Errorf("%q is not a workspace id", workspace)
}
if _, err := os.Stat(filepath.Join(m.Work, workspace, "tree")); err != nil {
return fmt.Errorf("no workspace %s: it was never fetched here, or it is older than a day and was removed", workspace)
}
return nil
}
// candidateLocks hold one mutex per workspace, so its candidate is compiled once.
var candidateLocks sync.Map
// candidate is the program of mesh-build-go compiled from the workspace's tree, in the workspace beside its tree
// (candidate/mesh-build-go), compiled once per workspace, in a run step shut off with the caches read-only and nothing
// fetched (GOPROXY=off: the fetch step downloaded every module the tree's go.mod files name). A candidate that does not
// compile is the step's failure, naming the compiler's first error (ADR 0535 §3).
func (m *Module) candidate(ctx context.Context, workspace string) (string, error) {
exe := filepath.Join(m.Work, workspace, "candidate", "mesh-build-go")
mu, _ := candidateLocks.LoadOrStore(exe, &sync.Mutex{})
mu.(*sync.Mutex).Lock()
defer mu.(*sync.Mutex).Unlock()
if st, err := os.Stat(exe); err == nil && st.Mode().IsRegular() {
return exe, nil
}
dir := filepath.Join(m.Work, workspace, "tree", candidateModule)
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err != nil {
return "", fmt.Errorf("the workspace holds no %s/go.mod, so it has no candidate mesh-build-go to run", candidateModule)
}
if err := os.MkdirAll(filepath.Dir(exe), 0o700); err != nil {
return "", err
}
run, err := m.stepRunner(workspace, false)
if err != nil {
return "", err
}
env := append(m.goEnvFor(dir), "GOPROXY=off", "GOTOOLCHAIN=local")
r := run(ctx, env, "go", "-C", dir, "build", "-o", exe, candidateMain)
if r.Status != 0 || r.Err != "" {
return "", fmt.Errorf("the candidate's mesh-build-go does not compile: %s", compileError(r.Err+"\n"+r.Stderr+"\n"+r.Stdout))
}
return exe, nil
}
// compileError is the first line of the compiler's output that says an error: not the `# package` heading go build
// prints above a package's errors, nor Go's progress.
func compileError(out string) string {
for _, l := range strings.Split(out, "\n") {
if l = strings.TrimSpace(l); l != "" && !strings.HasPrefix(l, "#") && !strings.HasPrefix(l, "go: downloading ") {
return l
}
}
return firstLine(out)
}
// mergeCommit is the commit the workspace's tree holds: the merge the fetch made, or the head it fetched.
func (m *Module) mergeCommit(ctx context.Context, workspace string) (string, error) {
r := m.Run(ctx, nil, "git", "-C", filepath.Join(m.Work, workspace, "tree"), "rev-parse", "HEAD")
commit := strings.TrimSpace(r.Stdout)
if r.Status != 0 || r.Err != "" || commit == "" {
return "", fmt.Errorf("the workspace's commit: git rev-parse: %s", firstLine(r.Err+"\n"+r.Stderr))
}
return commit, nil
}
// asCandidate runs the spec's step as the candidate's program, shut off, and answers what it said and the judged_by of
// the answer: "candidate <merge commit>".
func (m *Module) asCandidate(ctx context.Context, spec levelSpec) (levelAnswer, string, error) {
commit, err := m.mergeCommit(ctx, spec.Workspace)
if err != nil {
return levelAnswer{}, "", err
}
exe, err := m.candidate(ctx, spec.Workspace)
if err != nil {
return levelAnswer{}, "", err
}
a, err := m.shutOff(ctx, exe, spec)
if err != nil {
return levelAnswer{}, "", fmt.Errorf("the candidate's mesh-build-go: %w", err)
}
return a, "candidate " + commit, nil
}
@@ -1,127 +0,0 @@
//go:build integration
package main
import (
"context"
"encoding/json"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// candidateMainSource is a candidate's program that answers a mark: asked `level <spec>`, it tries what a run step must
// not reach (the account's key and login, a name, a socket of the machine) and answers a passing level of 42 tests whose
// why is "mark" and what it reached.
const candidateMainSource = `package main
import (
"encoding/json"
"fmt"
"net"
"os"
"time"
)
func main() {
if len(os.Args) != 3 || os.Args[1] != "level" {
os.Exit(2)
}
seen := map[string]bool{}
_, err := os.ReadFile(%q)
seen["key"] = err == nil
_, err = os.ReadFile(%q)
seen["login"] = err == nil
_, err = net.LookupHost("git.novox.be")
seen["resolved"] = err == nil
c, err := net.DialTimeout("tcp", %q, 2*time.Second)
if err == nil {
c.Close()
}
seen["tcp"] = err == nil
raw, _ := json.Marshal(seen)
var spec struct {
Step string ` + "`json:\"step\"`" + `
}
json.Unmarshal([]byte(os.Args[2]), &spec)
out, _ := json.Marshal(map[string]any{"result": map[string]any{"level": spec.Step, "passed": true, "tests": 42,
"packages": []any{}, "why": "mark " + string(raw)}})
fmt.Println(string(out))
}
`
// 1898/AC4 (ADR 0535 §1): a tree whose mesh-build-go program answers a mark: the unit level with the runner workspace
// compiles that program and runs it shut off, answers its mark judged by the candidate at the tree's commit, and the
// program reached no key, no login, no name and no socket of the machine.
func TestTheUnitLevelUnderTheWorkspaceRunnerRunsTheTreesProgramShutOff(t *testing.T) {
needTools(t, "the candidate's program shut off", "go", "git", "bwrap")
home := t.TempDir()
key, login := filepath.Join(home, ".ssh", "mesh_ed25519"), filepath.Join(home, "broker")
for _, f := range []string{key, login} {
if err := os.MkdirAll(filepath.Dir(f), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(f, []byte("secret"), 0o600); err != nil {
t.Fatal(err)
}
}
tcp, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer tcp.Close()
work, err := os.MkdirTemp("", "w")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.RemoveAll(work) })
tree := filepath.Join(work, "ws1", "tree")
dir := filepath.Join(tree, candidateModule)
if err := os.MkdirAll(filepath.Join(dir, "cmd", "mesh-build-go"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module meshbuildgo\n\ngo 1.25.0\n"), 0o644); err != nil {
t.Fatal(err)
}
src := fmt.Sprintf(candidateMainSource, key, login, tcp.Addr().String())
if err := os.WriteFile(filepath.Join(dir, "cmd", "mesh-build-go", "main.go"), []byte(src), 0o644); err != nil {
t.Fatal(err)
}
for _, c := range [][]string{{"init", "-q"}, {"add", "-A"}, {"-c", "user.email=a@b", "-c", "user.name=x", "commit", "-qm", "x"}} {
if out, err := exec.Command("git", append([]string{"-C", tree}, c...)...).CombinedOutput(); err != nil {
t.Fatalf("git %v: %v %s", c, err, out)
}
}
head, err := exec.Command("git", "-C", tree, "rev-parse", "HEAD").Output()
if err != nil {
t.Fatal(err)
}
m := &Module{Recipe: Current(), Home: home, Work: work, Cache: t.TempDir()}
m.Run = StepRunner(home)
out, err := m.test(context.Background(), map[string]any{"workspace": "ws1", "level": LevelUnit, "runner": RunnerWorkspace})
if err != nil {
t.Fatal(err)
}
res := out.(*TestResult)
if res.Tests != 42 || !strings.HasPrefix(res.Why, "mark ") {
t.Fatalf("result = %+v, want the candidate's mark", res)
}
if want := "candidate " + strings.TrimSpace(string(head)); res.JudgedBy != want {
t.Errorf("judged by %q, want %q", res.JudgedBy, want)
}
var seen map[string]bool
if err := json.Unmarshal([]byte(strings.TrimPrefix(res.Why, "mark ")), &seen); err != nil || len(seen) != 4 {
t.Fatalf("the program said %q: %v", res.Why, err)
}
for what, reached := range seen {
if reached {
t.Errorf("the candidate's program reached %s", what)
}
}
}
@@ -1,262 +0,0 @@
package main
import (
"context"
"encoding/json"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// Issue 1898 K1, K2 (ADR 0535): asked with the runner workspace, the digest and test steps compile the workspace's own
// mesh-build-go shut off and run the step as it, in one shut-off process; the answer says which program ran.
const candidateSHA = "fedcba9876543210fedcba9876543210fedcba98"
// candidateRig is a module over a workspace whose tree holds modules/mesh-build-go, with a runner standing for git, go
// and the candidate's program; what ran shut off is in seen, every command in ran.
type candidateRig struct {
m *Module
states *keptStates
seen []shutOff
ran []string
compile forgekey.Ran // what go build answers; Status 0 writes the program
answer func(spec levelSpec) levelAnswer // what the candidate's program answers
specs []levelSpec
compiled int
}
func newCandidateRig(t *testing.T) *candidateRig {
t.Helper()
cr := &candidateRig{states: newStates()}
cr.m = moduleWith(cr.states, "go1.27.1")
cr.m.Home, cr.m.Work, cr.m.Cache = t.TempDir(), t.TempDir(), t.TempDir()
cr.m.Executable = "/opt/bundle/mesh-build-go"
dir := filepath.Join(cr.m.Work, "ws1", "tree", candidateModule)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module meshbuildgo\n\ngo 1.26.0\n"), 0o644); err != nil {
t.Fatal(err)
}
cr.answer = func(spec levelSpec) levelAnswer {
if spec.Step == stepDigest {
return levelAnswer{Digest: &DigestResult{Level: spec.Level, Packages: map[string]string{"meshbuildgo/cmd/mesh-build-go": "mark"}}}
}
return levelAnswer{Result: &TestResult{Level: spec.Step, Passed: true, Tests: 42, Packages: []PackageResult{}, Why: "mark"}}
}
exe := filepath.Join(cr.m.Work, "ws1", "candidate", "mesh-build-go")
cr.m.Run = throughBubblewrap(t, func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
line := name + " " + strings.Join(args, " ")
cr.ran = append(cr.ran, line)
switch {
case name == "git" && slices.Contains(args, "rev-parse"):
return forgekey.Ran{Stdout: candidateSHA + "\n"}
case name == "git" && slices.Contains(args, "ls-files"):
return forgekey.Ran{Stdout: "go.mod\n"}
case name == "postgres":
return forgekey.Ran{Stdout: "postgres (PostgreSQL) 18.6\n"}
case name == "go" && slices.Contains(args, "build"):
cr.compiled++
for _, want := range []string{"GOPROXY=off", "GOTOOLCHAIN=local", "GOFLAGS=-mod=mod"} {
if !slices.Contains(env, want) {
t.Errorf("the candidate compiled without %s: %v", want, env)
}
}
if cr.compile.Status == 0 && cr.compile.Err == "" {
if err := os.WriteFile(args[slices.Index(args, "-o")+1], []byte("program"), 0o755); err != nil {
t.Fatal(err)
}
}
return cr.compile
case name == exe && len(args) == 2 && args[0] == levelArg:
var spec levelSpec
if err := json.Unmarshal([]byte(args[1]), &spec); err != nil {
t.Errorf("the spec %s: %v", args[1], err)
}
cr.specs = append(cr.specs, spec)
raw, _ := json.Marshal(cr.answer(spec))
return forgekey.Ran{Stdout: string(raw) + "\n"}
}
return forgekey.Ran{Status: 1, Err: "not expected here: " + line}
}, &cr.seen)
return cr
}
func (cr *candidateRig) test(t *testing.T, args map[string]any) (TestResult, delivery.Step) {
t.Helper()
if _, err := stepTool(t, cr.m, "build_go_test")(args); err != nil {
t.Fatal(err)
}
st := cr.states.waitEnd(t)
var res TestResult
if st.State == delivery.StepPassed {
if err := json.Unmarshal(st.Result, &res); err != nil {
t.Fatalf("result %s: %v", st.Result, err)
}
}
return res, st
}
func TestTheDigestAndTestToolsAnnounceTheRunner(t *testing.T) { // 1898 K1, ADR 0535 §2
m := moduleWith(newStates(), "go1.27.1")
for _, s := range m.StepTools() {
if s.Name != "build_go_digest" && s.Name != "build_go_test" {
continue
}
p, _ := s.Input["properties"].(map[string]any)["runner"].(map[string]any)
if p == nil || !slices.Equal(p["enum"].([]string), []string{RunnerInstalled, RunnerWorkspace}) {
t.Errorf("%s announces the runner %v, want installed and workspace", s.Name, p)
}
}
}
func TestTheUnitLevelUnderTheWorkspaceRunnerRunsTheCandidateShutOff(t *testing.T) { // 1898 K1, K2 (AC4's unit part)
cr := newCandidateRig(t)
res, st := cr.test(t, map[string]any{"workspace": "ws1", "level": LevelUnit, "runner": RunnerWorkspace, "packages": []any{"./..."}})
if st.State != delivery.StepPassed {
t.Fatalf("the step ended %s: %s", st.State, st.Reason)
}
if res.Tests != 42 || res.Why != "mark" || res.JudgedBy != "candidate "+candidateSHA {
t.Errorf("result = %+v, want the candidate's mark judged by candidate %s", res, candidateSHA)
}
if len(cr.specs) != 1 || cr.specs[0].Step != stepUnit || cr.specs[0].Workspace != "ws1" || !slices.Equal(cr.specs[0].Packages, []string{"./..."}) || cr.specs[0].Bound != UnitBound {
t.Errorf("the candidate was asked %+v, want the unit level of ws1 with its packages and bound", cr.specs)
}
for _, l := range cr.ran {
if strings.HasPrefix(l, "go ") && strings.Contains(l, " test ") {
t.Errorf("the installed program ran %q: the level is the candidate's to run", l)
}
}
// Two commands shut off: the compile, then the candidate's process; each as a run step is (ADR 0448 §2).
if len(cr.seen) != 2 || !strings.Contains(cr.seen[0].command, " build ") || !strings.Contains(cr.seen[0].command, "-C "+filepath.Join(cr.m.Work, "ws1", "tree", candidateModule)) ||
!strings.HasPrefix(cr.seen[1].command, filepath.Join(cr.m.Work, "ws1", "candidate", "mesh-build-go")+" level ") {
t.Fatalf("shut off %+v, want the compile and the candidate's process", cr.seen)
}
for _, s := range cr.seen {
checkShutOff(t, cr.m, s, false)
}
}
func TestTheIntegrationLevelUnderTheWorkspaceRunnerRunsTheCandidate(t *testing.T) { // 1898 K2
cr := newCandidateRig(t)
res, st := cr.test(t, map[string]any{"workspace": "ws1", "level": LevelIntegration, "runner": RunnerWorkspace})
if st.State != delivery.StepPassed || res.JudgedBy != "candidate "+candidateSHA {
t.Fatalf("the step ended %s (%s): %+v", st.State, st.Reason, res)
}
if len(cr.specs) != 1 || cr.specs[0].Step != stepIntegration || cr.specs[0].Bound != IntegrationRun || cr.specs[0].Setup != IntegrationSetup {
t.Errorf("the candidate was asked %+v, want the integration level with its bounds", cr.specs)
}
}
func TestTheDigestUnderTheWorkspaceRunnerIsTheCandidatesAndTheCandidateIsCompiledOnce(t *testing.T) { // 1898 K1, K2
cr := newCandidateRig(t)
out, err := stepTool(t, cr.m, "build_go_digest")(map[string]any{"workspace": "ws1", "level": LevelUnit, "runner": RunnerWorkspace})
if err != nil {
t.Fatal(err)
}
d := out.(DigestResult)
if d.Packages["meshbuildgo/cmd/mesh-build-go"] != "mark" || d.JudgedBy != "candidate "+candidateSHA {
t.Errorf("digest = %+v, want the candidate's, judged by candidate %s", d, candidateSHA)
}
if len(cr.specs) != 1 || cr.specs[0].Step != stepDigest || cr.specs[0].Level != LevelUnit {
t.Errorf("the candidate was asked %+v", cr.specs)
}
cr.test(t, map[string]any{"workspace": "ws1", "level": LevelUnit, "runner": RunnerWorkspace})
if cr.compiled != 1 {
t.Errorf("compiled the candidate %d times for two steps of one workspace, want once", cr.compiled)
}
}
func TestACandidateThatDoesNotCompileFailsTheStepWithTheCompilersFirstErrorAndRunsNoLevel(t *testing.T) { // 1898/AC5
cr := newCandidateRig(t)
cr.compile = forgekey.Ran{Status: 1, Stderr: "# meshbuildgo/cmd/mesh-build-go\ncmd/mesh-build-go/digest.go:12:2: undefined: x\ncmd/mesh-build-go/digest.go:13:2: undefined: y\n"}
_, st := cr.test(t, map[string]any{"workspace": "ws1", "level": LevelUnit, "runner": RunnerWorkspace})
if st.State == delivery.StepPassed || !strings.Contains(st.Reason, "does not compile: cmd/mesh-build-go/digest.go:12:2: undefined: x") || strings.Contains(st.Reason, "undefined: y") {
t.Fatalf("the step ended %s: %q, want it failed naming the first compile error", st.State, st.Reason)
}
if len(cr.specs) != 0 {
t.Errorf("the candidate ran %+v after it failed to compile", cr.specs)
}
for _, l := range cr.ran {
if strings.Contains(l, " test ") {
t.Errorf("a level ran: %q", l)
}
}
if _, err := stepTool(t, cr.m, "build_go_digest")(map[string]any{"workspace": "ws1", "level": LevelUnit, "runner": RunnerWorkspace}); err == nil ||
!strings.Contains(err.Error(), "undefined: x") {
t.Errorf("the digest answered %v, want the compile error", err)
}
}
func TestTheInstalledProgramSaysItJudged(t *testing.T) { // 1898 K1, ADR 0535 §5
m, states, ws, _ := workspaceFor(t, "slow")
for _, runner := range []any{nil, RunnerInstalled} {
args := map[string]any{"workspace": ws, "level": LevelUnit}
if runner != nil {
args["runner"] = runner
}
res, st := runUnitWith(t, m, states, args)
if st.State != delivery.StepPassed || res.JudgedBy != RunnerInstalled {
t.Errorf("runner %v: the step ended %s, judged by %q, want installed", runner, st.State, res.JudgedBy)
}
}
dm, _ := digestFixture(t)
if d := digests(t, dm, LevelUnit); d.JudgedBy != RunnerInstalled {
t.Errorf("the installed digest is judged by %q, want installed", d.JudgedBy)
}
}
func TestAnUnknownRunnerIsRefused(t *testing.T) { // 1898 K1
cr := newCandidateRig(t)
if _, err := cr.m.digest(context.Background(), map[string]any{"workspace": "ws1", "level": LevelUnit, "runner": "elsewhere"}); err == nil {
t.Error("the digest took the runner elsewhere")
}
if _, err := cr.m.test(context.Background(), map[string]any{"workspace": "ws1", "level": LevelUnit, "runner": "elsewhere"}); err == nil {
t.Error("the test step took the runner elsewhere")
}
}
func TestTheShutOffProcessRunsTheStepItIsAsked(t *testing.T) { // 1898 K2: the candidate's side of the spec
m, _, ws, calls := workspaceFor(t, "slow")
m.Inside = true
a := m.answer(context.Background(), levelSpec{Step: stepUnit, Workspace: ws, Packages: []string{"./..."}, Bound: UnitBound})
if a.Error != "" || a.Result == nil || a.Result.Level != LevelUnit || a.Result.Tests == 0 {
t.Fatalf("the unit step answered %+v", a)
}
ranTest := false
for _, c := range *calls {
ranTest = ranTest || strings.HasPrefix(c.line, "go -C ") && strings.Contains(c.line, " test -json ")
}
if !ranTest {
t.Errorf("the process ran no go test: %+v", *calls)
}
dm, _ := digestFixture(t)
dm.Inside = true
if a := dm.answer(context.Background(), levelSpec{Step: stepDigest, Level: LevelUnit, Workspace: "ws1"}); a.Error != "" || a.Digest == nil || len(a.Digest.Packages) == 0 {
t.Errorf("the digest step answered %+v", a)
}
if a := m.answer(context.Background(), levelSpec{Step: "deploy", Workspace: ws}); a.Error == "" {
t.Error("the process took a step that is not one")
}
}
func runUnitWith(t *testing.T, m *Module, states *keptStates, args map[string]any) (TestResult, delivery.Step) {
t.Helper()
if _, err := stepTool(t, m, "build_go_test")(args); err != nil {
t.Fatal(err)
}
st := states.waitEnd(t)
var res TestResult
if st.State == delivery.StepPassed {
if err := json.Unmarshal(st.Result, &res); err != nil {
t.Fatalf("result %s: %v", st.Result, err)
}
}
return res, st
}
@@ -5,7 +5,6 @@ package main
// digest is unchanged is not run again. The tool answers in its call (it is short, ADR 0469 §2).
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
@@ -23,12 +22,7 @@ import (
// DigestVersion is the form of the digest's text: changed whenever what the digest hashes changes in kind.
// 2: no Go version in the text; the toolchain enters a fingerprint as the agreed version (ADR 0477 §3). 3: the siblings'
// commits (the lead's decision L5) and the integration level's tags integration,isolation (the checker's agreement).
// 4: the siblings' commits enter only the digest of a package whose tests reach a file naming MESH_CHECK_BESIDE (ADR 0536 §1).
const DigestVersion = "4"
// besideVariable is the name a file bears when it reads where the siblings lie; a package whose tests reach such a file
// depends on the siblings' commits.
const besideVariable = "MESH_CHECK_BESIDE"
const DigestVersion = "3"
// integrationTags are the build tags of the integration level, its run and its digest alike: integration, and isolation
// for the bubblewrap proofs (mesh-sdk's and the build modules'), which a build node can run.
@@ -89,9 +83,6 @@ type DigestResult struct {
// only when they are the siblings it gave the fetch.
Beside []string `json:"beside,omitempty"`
WithoutTests []string `json:"without_tests,omitempty"`
// JudgedBy is the program that ran the digest (ADR 0535 §5): "installed", or "candidate <merge commit>" under the
// runner workspace.
JudgedBy string `json:"judged_by,omitempty"`
}
func (m *Module) digestTool() StepTool {
@@ -106,7 +97,6 @@ func (m *Module) digestTool() StepTool {
"properties": map[string]any{
"workspace": map[string]any{"type": "string", "description": "the workspace id build_go_fetch answered"},
"level": map[string]any{"type": "string", "enum": []string{LevelUnit, LevelIntegration}, "description": "the level"},
"runner": runnerProperty,
},
"required": []string{"workspace", "level"},
},
@@ -117,49 +107,12 @@ func (m *Module) digestTool() StepTool {
func (m *Module) digest(ctx context.Context, args map[string]any) (any, error) {
workspace, _ := args["workspace"].(string)
level, _ := args["level"].(string)
runner, err := runnerArg(args["runner"])
if err != nil {
return nil, err
}
if runner == RunnerWorkspace {
if err := m.workspaceThere(workspace); err != nil {
return nil, err
}
switch level {
case LevelUnit:
case LevelIntegration:
if _, err := m.checkPostgres(ctx); err != nil {
return nil, err // a *buildstep.Refusal, as the installed program's digest says it
}
default:
return nil, fmt.Errorf("the level %q is neither unit nor integration", level)
}
a, judged, err := m.asCandidate(ctx, levelSpec{Step: stepDigest, Level: level, Workspace: workspace})
if err != nil {
return nil, err
}
if a.Digest == nil {
return nil, fmt.Errorf("the candidate's mesh-build-go answered no digest")
}
a.Digest.JudgedBy = judged
return *a.Digest, nil
}
res, err := m.digestHere(ctx, workspace, level)
if err != nil {
return nil, err
}
res.JudgedBy = RunnerInstalled
return res, nil
}
// digestHere is the digest step as this program runs it.
func (m *Module) digestHere(ctx context.Context, workspace, level string) (DigestResult, error) {
if !workspaceOK.MatchString(workspace) {
return DigestResult{}, fmt.Errorf("%q is not a workspace id", workspace)
return nil, fmt.Errorf("%q is not a workspace id", workspace)
}
tree := filepath.Join(m.Work, workspace, "tree")
if _, err := os.Stat(tree); err != nil {
return DigestResult{}, fmt.Errorf("no workspace %s: it was never fetched here, or it is older than a day and was removed", workspace)
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}
@@ -167,35 +120,34 @@ func (m *Module) digestHere(ctx context.Context, workspace, level string) (Diges
case LevelUnit:
case LevelIntegration:
if _, err := m.checkPostgres(ctx); err != nil {
return DigestResult{}, err
return nil, err
}
tags = integrationTags
header = append(header, "postgres "+strconv.Itoa(m.Recipe.PostgresMajor), "nats-server "+m.Recipe.NatsServer)
default:
return DigestResult{}, fmt.Errorf("the level %q is neither unit nor integration", level)
return nil, fmt.Errorf("the level %q is neither unit nor integration", level)
}
header = append(header, "tags "+strings.Join(tags, ","))
beside, err := m.besideLines(workspace)
if err != nil {
return DigestResult{}, err
return nil, err
}
besideHeader := make([]string, 0, len(beside))
for _, b := range beside {
besideHeader = append(besideHeader, "beside "+b)
header = append(header, "beside "+b)
}
dirs, err := m.goModDirs(ctx, tree)
if err != nil {
return DigestResult{}, err
return nil, err
}
res := DigestResult{Level: level, Packages: map[string]string{}, Beside: beside}
for _, d := range dirs {
pkgs, err := m.listPackages(ctx, workspace, tree, d, tags)
if err != nil {
return DigestResult{}, err
return nil, err
}
if err := digestModule(&res, pkgs, tree, header, besideHeader); err != nil {
return DigestResult{}, err
if err := digestModule(&res, pkgs, tree, header); err != nil {
return nil, err
}
}
sort.Strings(res.WithoutTests)
@@ -238,7 +190,7 @@ func parsePackages(out string) (map[string]*goPackage, error) {
// digestModule adds to res the digest of each root package of one go.mod directory. A root is a package of the main
// module that is listed, not only a dependency, and not a variant of another.
func digestModule(res *DigestResult, pkgs map[string]*goPackage, tree string, header, beside []string) error {
func digestModule(res *DigestResult, pkgs map[string]*goPackage, tree string, header []string) error {
roots := []string{}
for path, p := range pkgs {
if p.ForTest == "" && !p.DepOnly && !p.Standard && !strings.HasSuffix(path, ".test") && p.Module != nil && p.Module.Main {
@@ -256,7 +208,7 @@ func digestModule(res *DigestResult, pkgs map[string]*goPackage, tree string, he
if testMain == nil {
return fmt.Errorf("go list gave no test binary for %s", path)
}
d, err := packageDigest(root, testMain, pkgs, tree, header, beside)
d, err := packageDigest(root, testMain, pkgs, tree, header)
if err != nil {
return fmt.Errorf("%s: %w", path, err)
}
@@ -267,9 +219,8 @@ func digestModule(res *DigestResult, pkgs map[string]*goPackage, tree string, he
// packageDigest is the digest of one package: a hash over a sorted text of the header, the files of every package its
// tests reach in the module's own tree (by their content, under their path in the tree), the version and checksum of
// every module outside it, and the package's own test data. The beside lines (the siblings' commits) join the header
// only when a file the tests reach names MESH_CHECK_BESIDE. A package of the standard library is the Go version's.
func packageDigest(root, testMain *goPackage, pkgs map[string]*goPackage, tree string, header, beside []string) (string, error) {
// every module outside it, and the package's own test data. A package of the standard library is the Go version's.
func packageDigest(root, testMain *goPackage, pkgs map[string]*goPackage, tree string, header []string) (string, error) {
reached := map[string]*goPackage{root.ImportPath: root}
for _, dep := range testMain.Deps {
if p := pkgs[dep]; p != nil {
@@ -277,7 +228,6 @@ func packageDigest(root, testMain *goPackage, pkgs map[string]*goPackage, tree s
}
}
lines := []string{}
readsBeside := false
for _, p := range reached {
if p.Standard || p.Module == nil {
continue
@@ -296,11 +246,6 @@ func packageDigest(root, testMain *goPackage, pkgs map[string]*goPackage, tree s
return "", err
}
lines = append(lines, l)
if !readsBeside {
if raw, err := os.ReadFile(filepath.Join(p.Dir, f)); err == nil && bytes.Contains(raw, []byte(besideVariable)) {
readsBeside = true
}
}
}
}
data := filepath.Join(root.Dir, "testdata")
@@ -318,9 +263,6 @@ func packageDigest(root, testMain *goPackage, pkgs map[string]*goPackage, tree s
}
}
sort.Strings(lines)
if readsBeside {
header = append(append([]string{}, header...), beside...)
}
h := sha256.New()
for _, l := range append(append([]string{}, header...), lines...) {
h.Write([]byte(l + "\n"))
@@ -173,7 +173,7 @@ func TestAModuleOutsideTheTreeCountsByVersionAndChecksum(t *testing.T) {
testMain := &goPackage{ImportPath: "x/r.test", Deps: []string{ext.ImportPath}}
pkgs[root.ImportPath] = root
digest := func() string {
d, err := packageDigest(root, testMain, pkgs, dir, []string{"digest 1"}, nil)
d, err := packageDigest(root, testMain, pkgs, dir, []string{"digest 1"})
if err != nil {
t.Fatal(err)
}
@@ -6,15 +6,12 @@ package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/buildstep"
@@ -117,7 +114,7 @@ func (m *Module) fetchInto(ctx context.Context, req buildstep.FetchRequest, id s
return nil, err
}
remote := m.remote(repository)
if err := buildstep.FetchCommit(ctx, m.Run, tree, remote, commit, ref, m.fetchOptions(ctx, repository, commit, ref)...); err != nil {
if err := buildstep.FetchCommit(ctx, m.Run, tree, remote, commit, ref, m.FetchOptions...); err != nil {
return nil, err
}
res := FetchResult{Workspace: id, Commit: commit}
@@ -126,9 +123,6 @@ func (m *Module) fetchInto(ctx context.Context, req buildstep.FetchRequest, id s
return nil, err
}
}
if err := m.dissociate(ctx, tree); err != nil {
return nil, err
}
if res.Beside, err = m.placeSiblings(ctx, root, siblings); err != nil {
return nil, err
}
@@ -149,27 +143,11 @@ func (m *Module) fetchInto(ctx context.Context, req buildstep.FetchRequest, id s
if base != "" && res.Merge == "" {
return res, nil // a conflict: the tree is at the trunk, nothing of the head is built or tested
}
// The directories download side by side, up to the bound (ADR 0536 §2); the first failure in directory order is the
// step's. Resolving a pin places a repository beside the workspace, which two directories may share: one at a time.
var pins sync.Mutex
errs := make([]error, len(dirs))
slots := make(chan struct{}, m.parallel())
var wg sync.WaitGroup
for i, d := range dirs {
for _, d := range dirs {
if _, err := os.Stat(filepath.Join(tree, d, "vendor", "modules.txt")); err == nil {
continue // a module that vendors resolves nothing
}
wg.Add(1)
slots <- struct{}{}
go func() {
defer wg.Done()
defer func() { <-slots }()
errs[i] = m.downloadModules(ctx, root, filepath.Join(tree, d), d, &pins)
}()
}
wg.Wait()
for _, err := range errs {
if err != nil {
if err := m.downloadModules(ctx, root, filepath.Join(tree, d), d); err != nil {
return nil, err
}
}
@@ -179,15 +157,13 @@ func (m *Module) fetchInto(ctx context.Context, req buildstep.FetchRequest, id s
// downloadModules downloads and verifies the Go modules of the directory dir (d in the tree). A pin of a forge module
// that Go cannot fetch is looked for among the forge's pull request heads and handed to Go by a modfile (pins.go); a
// failure that names no such pin is the first failure.
func (m *Module) downloadModules(ctx context.Context, root, dir, d string, pins *sync.Mutex) error {
func (m *Module) downloadModules(ctx context.Context, root, dir, d string) error {
for {
failure := m.downloadAndVerify(ctx, dir, d)
if failure == nil {
return nil
}
pins.Lock()
progress, err := m.resolvePins(ctx, root, dir, failure.Error())
pins.Unlock()
if err != nil {
return err
}
@@ -197,41 +173,8 @@ func (m *Module) downloadModules(ctx context.Context, root, dir, d string, pins
}
}
// 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
// verifiedMark is the file naming a pass of go mod verify for a go.mod and go.sum pair, "" when a pass is not kept:
// the module cache is only read by steps that are shut off with it read-only (ADR 0448 §3), so the pass holds only then,
// and a directory with a pin modfile verifies another file.
func (m *Module) verifiedMark(dir string) string {
if m.Cache == "" || m.Bubblewrap == nil {
return ""
}
if _, ok := m.pinModfile(dir); ok {
return ""
}
h := sha256.New()
for _, name := range []string{"go.mod", "go.sum"} {
raw, _ := os.ReadFile(filepath.Join(dir, name))
fmt.Fprintf(h, "%s %d\n", name, len(raw))
h.Write(raw)
}
return filepath.Join(m.Cache, "verified", hex.EncodeToString(h.Sum(nil)))
}
func (m *Module) downloadAndVerify(ctx context.Context, dir, d string) error {
for _, verb := range []string{"download", "verify"} {
mark := ""
if verb == "verify" {
if mark = m.verifiedMark(dir); mark != "" {
mu, _ := verifyLocks.LoadOrStore(mark, &sync.Mutex{})
mu.(*sync.Mutex).Lock()
defer mu.(*sync.Mutex).Unlock()
if _, err := os.Stat(mark); err == nil {
continue // this go.mod and go.sum passed before
}
}
}
cmd, env := []string{"-C", dir, "mod", verb}, m.goEnvFor(dir)
if modfile, ok := m.pinModfile(dir); ok && verb == "verify" {
// go mod verify takes no -modfile: it is run on the modfile itself, which names the placed repositories.
@@ -241,12 +184,6 @@ func (m *Module) downloadAndVerify(ctx context.Context, dir, d string) error {
if r.Status != 0 || r.Err != "" {
return fmt.Errorf("go mod %s in %s: %s", verb, d, causeLine(r.Err+"\n"+r.Stdout+"\n"+r.Stderr))
}
if mark != "" {
// Said after the pass; a mark that cannot be written only means the next fetch verifies again.
if os.MkdirAll(filepath.Dir(mark), 0o700) == nil {
os.WriteFile(mark, nil, 0o600)
}
}
}
return nil
}
@@ -30,7 +30,6 @@ type fakeStep struct {
gomods string
gate chan struct{} // when set, the first git command waits for it
goRule func(line string) forgekey.Ran
files map[string]string // files (relative to the tree) written at checkout: go.mod and go.sum of the fixture
vendors []string // directories (relative to the tree) that get a vendor/modules.txt at checkout
rule func(line string) (forgekey.Ran, bool) // answers a command before the defaults do
}
@@ -56,10 +55,6 @@ func (f *fakeStep) run(ctx context.Context, env []string, name string, args ...s
case name == "git" && strings.Contains(line, " checkout "):
for i, a := range args {
if a == "-C" {
for name, text := range f.files {
os.MkdirAll(filepath.Dir(filepath.Join(args[i+1], name)), 0o755)
os.WriteFile(filepath.Join(args[i+1], name), []byte(text), 0o644)
}
for _, v := range f.vendors {
os.MkdirAll(filepath.Join(args[i+1], v, "vendor"), 0o755)
os.WriteFile(filepath.Join(args[i+1], v, "vendor", "modules.txt"), nil, 0o644)
@@ -75,16 +75,8 @@ func (m *Module) integrationHere(ctx context.Context, workspace, tree string, pa
env := append([]string{"MESH_TEST_POSTGRES=" + postgres, "MESH_TEST_NATS=" + nats}, m.besideEnv(workspace)...)
// Compiling counts as setup: the tests are built once, here, with the addresses a TestMain may read, and the run
// below finds them built. Whether they compile is for the run to say; its failure is not read here.
// Only the directories the selected packages lie in compile (ADR 0536 §3); no selection is every directory.
byDir, err := packagesByDir(tree, dirs, packages)
if err != nil {
return nil, err
}
for _, d := range dirs {
if len(byDir[d]) == 0 {
continue
}
m.Run(ctx, append(m.goEnvFor(filepath.Join(tree, d)), env...), "go", append([]string{"-C", filepath.Join(tree, d), "test", "-tags", strings.Join(integrationTags, ","), "-run", "^$", "-count=1"}, byDir[d]...)...)
m.Run(ctx, append(m.goEnvFor(filepath.Join(tree, d)), env...), "go", "-C", filepath.Join(tree, d), "test", "-tags", strings.Join(integrationTags, ","), "-run", "^$", "-count=1", "./...")
}
setup := m.clock().Sub(began)
@@ -31,10 +31,6 @@ type integFake struct {
busDown bool
runDirs []string // run directories seen when the store was started, to be gone at the end
panicOn string // a phase that panics
// gomods is what git ls-files answers ("go.mod\n" when empty); onTest is called, unlocked, as each go test -json
// command runs, with its line.
gomods string
onTest func(line string)
}
func newIntegFake(t *testing.T, captured string) *integFake {
@@ -67,16 +63,10 @@ func (f *integFake) run(ctx context.Context, env []string, name string, args ...
case name == "postgres":
return forgekey.Ran{Stdout: f.postgres + "\n"}
case name == "git" && len(args) > 2 && args[2] == "ls-files":
if f.gomods != "" {
return forgekey.Ran{Stdout: f.gomods}
}
return forgekey.Ran{Stdout: "go.mod\n"}
case name == "go" && strings.Contains(line, "-run ^$"):
f.tick("compile")
case name == "go":
if f.onTest != nil {
f.onTest(line)
}
f.tick("test")
status := 0
if strings.Contains(f.captured, `"Action":"fail"`) {
@@ -18,22 +18,11 @@ import (
"git.novox.be/novox/mesh-sdk/go/buildstep"
)
// levelArg is the first argument that makes this binary a step's shut-off process: the integration level's, and, when
// the merge changes this module, the candidate's program running a digest or a level (ADR 0535 §1, candidate.go).
// levelArg is the first argument that makes this binary an integration level's process.
const levelArg = "level"
// The steps a shut-off process runs (levelSpec.Step); empty is the integration level, as a spec said before the runner.
const (
stepDigest = "digest"
stepUnit = LevelUnit
stepIntegration = LevelIntegration
)
// levelSpec is what the shut-off process is asked: the step, the workspace, the packages and the -run pattern, the
// level's bounds; for a digest, its level.
// levelSpec is what the shut-off process is asked: the workspace, the packages and the -run pattern, the level's bounds.
type levelSpec struct {
Step string `json:"step,omitempty"`
Level string `json:"level,omitempty"`
Work string `json:"work"`
Cache string `json:"cache"`
Workspace string `json:"workspace"`
@@ -43,11 +32,10 @@ type levelSpec struct {
Bound time.Duration `json:"bound"`
}
// levelAnswer is the one line the process prints: the level's result or the digest, or why it could not run.
// levelAnswer is the one line the process prints: the level's result, or why it could not run.
type levelAnswer struct {
Result *TestResult `json:"result,omitempty"`
Digest *DigestResult `json:"digest,omitempty"`
Error string `json:"error,omitempty"`
Result *TestResult `json:"result,omitempty"`
Error string `json:"error,omitempty"`
}
// executable is this program's path, bound read-only into the step: Executable when a test set it.
@@ -62,63 +50,35 @@ func (m *Module) executable() (string, error) {
return filepath.EvalSymlinks(exe)
}
// integrationShutOff runs the integration level in one shut-off process of this program and reads its answer.
// integrationShutOff runs the integration level in one shut-off process and reads its answer.
func (m *Module) integrationShutOff(ctx context.Context, workspace string, packages []string, pattern string) (*TestResult, error) {
exe, err := m.executable()
if err != nil {
return nil, fmt.Errorf("the integration level's program: %w", err)
}
b := m.integrationBounds()
a, err := m.shutOff(ctx, exe, levelSpec{Step: stepIntegration, Workspace: workspace, Packages: packages, Run: pattern, Setup: b.Setup, Bound: b.Run})
spec, err := json.Marshal(levelSpec{Work: m.Work, Cache: m.Cache, Workspace: workspace, Packages: packages, Run: pattern, Setup: b.Setup, Bound: b.Run})
if err != nil {
return nil, err
}
if a.Result == nil {
return nil, fmt.Errorf("the integration level's shut-off process answered no result")
}
return a.Result, nil
}
// shutOff runs the program exe as one shut-off process of the workspace (`exe level <spec>`) and reads its answer: the
// workspace read-write, the program alone read-only, the shared caches beneath a throwaway layer, the Node build
// module's toolchains read-only for the integration level.
func (m *Module) shutOff(ctx context.Context, exe string, spec levelSpec) (levelAnswer, error) {
spec.Work, spec.Cache = m.Work, m.Cache
raw, err := json.Marshal(spec)
if err != nil {
return levelAnswer{}, err
}
for _, c := range m.caches() {
if err := os.MkdirAll(c, 0o700); err != nil {
return levelAnswer{}, fmt.Errorf("the shared cache %s: %w", c, err)
return nil, fmt.Errorf("the shared cache %s: %w", c, err)
}
}
iso := m.isolation(spec.Workspace, false)
iso := m.isolation(workspace, false)
iso.ReadOnly = append(iso.ReadOnly, exe) // the program alone, not the directory it lies in
if spec.Step == stepIntegration || spec.Step == "" {
iso.ReadOnly = append(iso.ReadOnly, m.nodeToolchains()...)
}
r := buildstep.Isolated(m.Run, iso)(ctx, nil, exe, levelArg, string(raw))
iso.ReadOnly = append(iso.ReadOnly, m.nodeToolchains()...)
r := buildstep.Isolated(m.Run, iso)(ctx, nil, exe, levelArg, string(spec))
var answer levelAnswer
line := lastLine(r.Stdout)
if err := json.Unmarshal([]byte(line), &answer); err != nil || (answer.Result == nil && answer.Digest == nil && answer.Error == "") {
return levelAnswer{}, fmt.Errorf("the %s step's shut-off process answered nothing it says (status %d %s): %s", stepName(spec.Step), r.Status, r.Err, firstLine(r.Stderr+"\n"+r.Stdout))
if err := json.Unmarshal([]byte(line), &answer); err != nil || (answer.Result == nil && answer.Error == "") {
return nil, fmt.Errorf("the integration level's shut-off process answered nothing it says (status %d %s): %s", r.Status, r.Err, firstLine(r.Stderr+"\n"+r.Stdout))
}
if answer.Error != "" {
return levelAnswer{}, fmt.Errorf("%s", answer.Error)
return nil, fmt.Errorf("%s", answer.Error)
}
return answer, nil
}
// stepName names a spec's step in words.
func stepName(step string) string {
switch step {
case stepDigest:
return "digest"
case stepUnit:
return "unit level's"
}
return "integration level's"
return answer.Result, nil
}
// NodeToolchains is the Node build module's toolchains directory on this node: its cache directory (module.json
@@ -164,40 +124,18 @@ func runLevelProcess(args []string) int {
if !workspaceOK.MatchString(spec.Workspace) {
return say(levelAnswer{Error: fmt.Sprintf("%q is not a workspace id", spec.Workspace)})
}
m := &Module{Inside: true, Recipe: Current(), Work: spec.Work, Cache: spec.Cache, Home: os.Getenv("HOME"), UnitBound: spec.Bound,
m := &Module{Inside: true, Recipe: Current(), Work: spec.Work, Cache: spec.Cache, Home: os.Getenv("HOME"),
IntegrationBounds: Bounds{Setup: spec.Setup, Run: spec.Bound}, Say: func(string, ...any) {}}
m.Run = StepRunner(m.Home)
return say(m.answer(context.Background(), spec))
}
// answer runs the spec's step in this process, already shut off: a digest of a level, the unit level or the integration
// level of the workspace.
func (m *Module) answer(ctx context.Context, spec levelSpec) levelAnswer {
tree := filepath.Join(m.Work, spec.Workspace, "tree")
switch spec.Step {
case stepDigest:
d, err := m.digestHere(ctx, spec.Workspace, spec.Level)
if err != nil {
return levelAnswer{Error: err.Error()}
}
return levelAnswer{Digest: &d}
case stepUnit:
res, err := m.runLevel(ctx, tree, levelOpts{workspace: spec.Workspace, level: LevelUnit, packages: spec.Packages, run: spec.Run,
bounds: Bounds{Run: m.unitBound()}, env: m.besideEnv(spec.Workspace)})
if err != nil {
return levelAnswer{Error: err.Error()}
}
return levelAnswer{Result: res}
case stepIntegration, "":
major, err := m.checkPostgres(ctx)
if err != nil {
return levelAnswer{Error: err.Error()}
}
res, err := m.integrationHere(ctx, spec.Workspace, tree, spec.Packages, spec.Run, major)
if err != nil {
return levelAnswer{Error: err.Error()}
}
return levelAnswer{Result: res}
ctx := context.Background()
major, err := m.checkPostgres(ctx)
if err != nil {
return say(levelAnswer{Error: err.Error()})
}
return levelAnswer{Error: fmt.Sprintf("the step %q is not digest, unit or integration", spec.Step)}
tree := filepath.Join(spec.Work, spec.Workspace, "tree")
res, err := m.integrationHere(ctx, spec.Workspace, tree, spec.Packages, spec.Run, major)
if err != nil {
return say(levelAnswer{Error: err.Error()})
}
return say(levelAnswer{Result: res})
}
@@ -83,9 +83,6 @@ type TestResult struct {
OverWhich string `json:"over_which,omitempty"`
Store string `json:"store,omitempty"`
Bus string `json:"bus,omitempty"`
// JudgedBy is the program that ran the level (ADR 0535 §5): "installed", or "candidate <merge commit>" under the
// runner workspace.
JudgedBy string `json:"judged_by,omitempty"`
}
// event is one line of `go test -json` (test2json, and the build events go test adds).
@@ -1,73 +0,0 @@
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"sync"
"syscall"
"git.novox.be/novox/mesh-sdk/go/buildstep"
)
// mirrorLocks hold one mutex per mirror of this process; the lock file beside the mirror holds off another process.
var mirrorLocks sync.Map
// mirrorDir is where the bare mirror of a repository lies: one per repository in the module's cache (ADR 0536 §2).
func (m *Module) mirrorDir(repository string) string {
return filepath.Join(m.Cache, "mirror", filepath.FromSlash(repository)+".git")
}
// fetchOptions are the options a fetch of repository runs with: the module's own, and the mirror of the repository
// brought to the commit, so git transfers only the objects the mirror lacks. A mirror that cannot be brought to the
// commit is said and left out: the fetch is then the full one, as it was.
func (m *Module) fetchOptions(ctx context.Context, repository, commit, ref string) []buildstep.Option {
opts := m.FetchOptions
if m.Cache == "" || !repositoryOK.MatchString(repository) {
return opts
}
mirror := m.mirrorDir(repository)
if err := m.updateMirror(ctx, mirror, m.remote(repository), commit, ref); err != nil {
if m.Say != nil {
m.Say("the mirror of %s is not used: %v", repository, err)
}
return opts
}
return append(append([]buildstep.Option{}, opts...), buildstep.WithMirror(mirror))
}
// updateMirror brings the mirror to the commit under the mirror's lock: a mutex for the checks of this process, a lock
// file for another process of the node.
func (m *Module) updateMirror(ctx context.Context, mirror, remote, commit, ref string) error {
mu, _ := mirrorLocks.LoadOrStore(mirror, &sync.Mutex{})
mu.(*sync.Mutex).Lock()
defer mu.(*sync.Mutex).Unlock()
if err := os.MkdirAll(mirror, 0o700); err != nil { // git init --bare takes an empty directory
return err
}
lock, err := os.OpenFile(mirror+".lock", os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return err
}
defer lock.Close()
if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX); err != nil {
return fmt.Errorf("lock %s: %w", mirror, err)
}
defer syscall.Flock(int(lock.Fd()), syscall.LOCK_UN)
return buildstep.UpdateMirror(ctx, m.Run, mirror, remote, commit, ref, m.FetchOptions...)
}
// dissociate makes the repository in dir self-contained again, as git clone --dissociate does: the objects it borrowed
// from the mirror are packed into it and its alternates file goes, so a step shut off with the workspace alone (the
// mirror is not bound into it) finds every object. The network saving stays: the borrowing was for the transfer.
func (m *Module) dissociate(ctx context.Context, dir string) error {
alternates := filepath.Join(dir, ".git", "objects", "info", "alternates")
if _, err := os.Stat(alternates); err != nil {
return nil // no mirror was used
}
if r := m.Run(ctx, []string{"GIT_TERMINAL_PROMPT=0"}, "git", "-C", dir, "repack", "-a", "-d", "-q"); r.Status != 0 || r.Err != "" {
return fmt.Errorf("git repack in %s: %s", dir, firstLine(r.Err+" "+r.Stderr))
}
return os.Remove(alternates)
}
@@ -1,190 +0,0 @@
package main
import (
"encoding/json"
"git.novox.be/novox/mesh-sdk/go/buildstep"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
func fetchOnce(t *testing.T, m *Module, states *keptStates, args map[string]any) FetchResult {
t.Helper()
if _, err := fetchTool(t, m)(args); err != nil {
t.Fatal(err)
}
st := states.waitEnd(t)
if st.State != delivery.StepPassed {
t.Fatalf("the step ended %s: %s", st.State, st.Reason)
}
var res FetchResult
if err := json.Unmarshal(st.Result, &res); err != nil {
t.Fatal(err)
}
return res
}
// Issue 890/AC4 (ADR 0536 §2): two fetches of one repository on one module bring the same mirror to their commit, and
// each workspace's alternates file names the mirror's objects.
func TestTwoFetchesOfARepositoryUseOneMirrorAndReferenceIt(t *testing.T) {
f := &fakeStep{gomods: "go.mod\n"}
m, states := fetchModule(t, f)
mirror := filepath.Join(m.Cache, "mirror", "novox", "mesh-sdk.git")
var workspaces []string
for i := 0; i < 2; i++ {
workspaces = append(workspaces, fetchOnce(t, m, states, fetchArgs).Workspace)
}
updates := 0
for _, l := range f.lines() {
if strings.Contains(l, "-C "+mirror+" fetch --no-tags origin "+commitSHA) {
updates++
}
if strings.Contains(l, "mirror") && !strings.Contains(l, mirror) {
t.Errorf("a command names another mirror: %s", l)
}
}
if updates != 2 {
t.Errorf("the mirror %s was updated %d times, want 2:\n%s", mirror, updates, strings.Join(f.lines(), "\n"))
}
// The fake git writes nothing, so the alternates file the SDK wrote stands until the fetch dissociates: it is gone
// at the end and git repack -a -d ran in each tree.
for _, ws := range workspaces {
tree := filepath.Join(m.Work, ws, "tree")
if _, err := os.Stat(filepath.Join(tree, ".git", "objects", "info", "alternates")); err == nil {
t.Errorf("the workspace %s still borrows from the mirror", ws)
}
if !strings.Contains(strings.Join(f.lines(), "\n"), "-C "+tree+" repack -a -d") {
t.Errorf("git repack -a -d did not run in %s", tree)
}
}
}
// A sibling is placed through its own repository's mirror.
func TestASiblingIsPlacedThroughItsOwnMirror(t *testing.T) {
f := &fakeStep{gomods: "go.mod\n"}
m, states := fetchModule(t, f)
args := map[string]any{"repository": "novox/mesh-controller", "commit": commitSHA, "beside": []any{
map[string]any{"repository": "novox/mesh-sdk", "commit": commitSHA}}}
ws := fetchOnce(t, m, states, args).Workspace
for _, repo := range []string{"mesh-controller", "mesh-sdk"} {
mirror := filepath.Join(m.Cache, "mirror", "novox", repo+".git")
if !strings.Contains(strings.Join(f.lines(), "\n"), "-C "+mirror+" fetch --no-tags origin "+commitSHA) {
t.Errorf("the mirror of %s was not updated", repo)
}
}
if _, err := os.Stat(filepath.Join(m.Work, ws, "beside", "mesh-sdk", ".git", "objects", "info", "alternates")); err == nil {
t.Errorf("the sibling still borrows from the mirror")
}
}
// Two checks at once on one node update one mirror one after the other.
func TestTwoUpdatesOfOneMirrorNeverRunAtOnce(t *testing.T) {
var mu sync.Mutex
inside, most := 0, 0
f := &fakeStep{gomods: "go.mod\n"}
f.rule = func(line string) (r runAnswer, ok bool) {
if !strings.Contains(line, "fetch --no-tags origin") || strings.Contains(line, "--depth") {
return r, false
}
mu.Lock()
inside++
if inside > most {
most = inside
}
mu.Unlock()
sleepABit()
mu.Lock()
inside--
mu.Unlock()
return r, true
}
m, states := fetchModule(t, f)
var wg sync.WaitGroup
for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
fetchTool(t, m)(fetchArgs)
}()
}
wg.Wait()
states.waitEnd(t)
states.waitEnd(t)
if most != 1 {
t.Errorf("%d updates of the mirror ran at once", most)
}
}
// A mirror that cannot be updated does not fail the fetch: the fetch goes on without it.
func TestAMirrorThatCannotBeUpdatedLetsTheFetchGoOnWithoutIt(t *testing.T) {
f := &fakeStep{gomods: "go.mod\n"}
f.rule = func(line string) (r runAnswer, ok bool) {
if strings.Contains(line, "mirror") && strings.Contains(line, " fetch ") {
return runAnswer{Status: 1, Stderr: "fatal: no space left\n"}, true
}
return r, false
}
m, states := fetchModule(t, f)
ws := fetchOnce(t, m, states, fetchArgs).Workspace
if _, err := os.Stat(filepath.Join(m.Work, ws, "tree", ".git", "objects", "info", "alternates")); err == nil {
t.Errorf("the workspace borrows from a mirror that is not there")
}
}
func sleepABit() { time.Sleep(30 * time.Millisecond) }
// The workspace is self-contained after the fetch: no alternates file, and git finds the commit with the mirror gone,
// as a step shut off with the workspace alone needs. Real git, a local repository as the forge.
func TestTheWorkspaceIsSelfContainedAfterAFetchThroughTheMirror(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("no git")
}
forge := t.TempDir()
src := filepath.Join(forge, "novox", "mesh-sdk.git")
if err := os.MkdirAll(src, 0o755); err != nil {
t.Fatal(err)
}
git := func(dir string, args ...string) string {
cmd := exec.Command("git", append([]string{"-C", dir, "-c", "user.name=t", "-c", "user.email=t@example.org", "-c", "commit.gpgsign=false"}, args...)...)
cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null")
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v %s", args, err, out)
}
return strings.TrimSpace(string(out))
}
git(src, "init", "-q")
if err := os.WriteFile(filepath.Join(src, "f.txt"), []byte("one\n"), 0o644); err != nil {
t.Fatal(err)
}
git(src, "add", ".")
git(src, "commit", "-q", "-m", "one")
commit := git(src, "rev-parse", "HEAD")
states := newStates()
m := moduleWith(states, "go1.27.1")
m.Home, m.Work, m.Cache = t.TempDir(), t.TempDir(), t.TempDir()
m.Run = StepRunner(m.Home)
m.RemoteBase = "file://" + forge + "/"
m.FetchOptions = []buildstep.Option{buildstep.AllowFileRemote()}
ws := fetchOnce(t, m, states, map[string]any{"repository": "novox/mesh-sdk", "commit": commit}).Workspace
tree := filepath.Join(m.Work, ws, "tree")
if _, err := os.Stat(filepath.Join(tree, ".git", "objects", "info", "alternates")); err == nil {
t.Errorf("the workspace still has an alternates file")
}
if err := os.RemoveAll(filepath.Join(m.Cache, "mirror")); err != nil {
t.Fatal(err)
}
if out, err := exec.Command("git", "-C", tree, "cat-file", "-e", commit+"^{commit}").CombinedOutput(); err != nil {
t.Errorf("git does not find the commit with the mirror gone: %v %s", err, out)
}
if out, err := exec.Command("git", "-C", tree, "fsck", "--connectivity-only").CombinedOutput(); err != nil {
t.Errorf("the workspace is not whole: %v %s", err, out)
}
}
@@ -54,9 +54,6 @@ type Module struct {
// Bubblewrap reads the installed bubblewrap's version (ProbeBubblewrap): a step is refused, and the module is
// unhealthy, while it is missing or older than BubblewrapLeast. Nil probes nothing.
Bubblewrap func(ctx context.Context) (string, error)
// Parallel is how many go.mod directories a level runs at once; zero is half the processors, and at least one
// (ADR 0536 §3: a node runs two checks at once).
Parallel int
// Inside is the integration level's own process, started shut off in bubblewrap by a test step (level.go): its
// commands run as they are, never shut off a second time.
Inside bool
@@ -6,7 +6,6 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"testing"
"git.novox.be/novox/mesh-sdk/go/delivery"
@@ -29,7 +28,6 @@ type pinHarness struct {
pulls string // what git ls-remote answers
onTrunk bool // the pin is fetchable as it is: go mod download never says unknown revision
cmds []cmdRecord
mu sync.Mutex
}
func newPinHarness(t *testing.T, gomod string, vendor bool, pulls string) (*pinHarness, *keptStates) {
@@ -38,9 +36,7 @@ func newPinHarness(t *testing.T, gomod string, vendor bool, pulls string) (*pinH
h := &pinHarness{t: t, m: m, f: f, pulls: pulls}
m.Run = func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
line := name + " " + strings.Join(args, " ")
h.mu.Lock()
h.cmds = append(h.cmds, cmdRecord{env: env, line: line})
h.mu.Unlock()
switch {
case name == "git" && strings.Contains(line, " checkout ") && !strings.Contains(line, "pinned"):
for i, a := range args {
@@ -96,10 +96,7 @@ func (m *Module) placeSiblings(ctx context.Context, root string, siblings []sibl
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, err
}
if err := buildstep.FetchCommit(ctx, m.Run, dir, m.remote(s.repository), s.commit, s.ref, m.fetchOptions(ctx, s.repository, s.commit, s.ref)...); err != nil {
return nil, fmt.Errorf("beside %s: %w", s.repository, err)
}
if err := m.dissociate(ctx, dir); err != nil {
if err := buildstep.FetchCommit(ctx, m.Run, dir, m.remote(s.repository), s.commit, s.ref, m.FetchOptions...); err != nil {
return nil, fmt.Errorf("beside %s: %w", s.repository, err)
}
if link := firstSymlink(dir); link != "" {
@@ -1,172 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// threeModules gives the workspace three go.mod directories, a, b and c, whose modules are fixture/a, fixture/b and
// fixture/c.
func threeModules(t *testing.T, f *integFake) (*Module, *keptStates) {
t.Helper()
m, states := integModule(t, f)
f.gomods = "a/go.mod\nb/go.mod\nc/go.mod\n"
for _, d := range []string{"a", "b", "c"} {
dir := filepath.Join(m.Work, "ws1", "tree", d)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module fixture/"+d+"\n\ngo 1.25.0\n"), 0o644); err != nil {
t.Fatal(err)
}
}
return m, states
}
func runIntegrationOf(t *testing.T, m *Module, states *keptStates, packages ...any) (TestResult, delivery.Step) {
t.Helper()
for _, tool := range m.Tools(m.StepTools()) {
if tool.Name != "build_go_test" {
continue
}
args := map[string]any{"workspace": "ws1", "level": "integration"}
if len(packages) > 0 {
args["packages"] = packages
}
if _, err := tool.Run(args); err != nil {
t.Fatal(err)
}
}
st := states.waitEnd(t)
var res TestResult
if st.State == delivery.StepPassed {
if err := json.Unmarshal(st.Result, &res); err != nil {
t.Fatal(err)
}
}
return res, st
}
// Issue 890/AC1 (ADR 0536 §3): with packages selected in one of three go.mod directories, the integration level
// compiles and runs go test in that directory only.
func TestOnlyTheSelectedDirectoryCompilesAndRuns(t *testing.T) {
f := newIntegFake(t, "slow")
m, states := threeModules(t, f)
if _, st := runIntegrationOf(t, m, states, "fixture/b/x"); st.State != delivery.StepPassed {
t.Fatalf("the step ended %s: %s", st.State, st.Reason)
}
tree := filepath.Join(m.Work, "ws1", "tree")
compiled, ran := 0, 0
for _, l := range strings.Split(f.lines(), "\n") {
if !strings.HasPrefix(l, "go ") || !strings.Contains(l, " test ") {
continue
}
if !strings.HasPrefix(l, "go -C "+filepath.Join(tree, "b")+" test ") {
t.Errorf("go test ran outside the selected directory: %s", l)
}
if strings.Contains(l, "-run ^$") {
compiled++
} else {
ran++
}
}
if compiled != 1 || ran != 1 {
t.Errorf("compiled %d times and ran %d times, want once each:\n%s", compiled, ran, f.lines())
}
}
// Issue 890/AC1: no selection keeps every directory, compiled and run.
func TestWithoutASelectionEveryDirectoryCompilesAndRuns(t *testing.T) {
f := newIntegFake(t, "slow")
m, states := threeModules(t, f)
runIntegrationOf(t, m, states)
compiled, ran := 0, 0
for _, l := range strings.Split(f.lines(), "\n") {
if strings.HasPrefix(l, "go ") && strings.Contains(l, " test ") {
if strings.Contains(l, "-run ^$") {
compiled++
} else {
ran++
}
}
}
if compiled != 3 || ran != 3 {
t.Errorf("compiled %d and ran %d, want 3 and 3:\n%s", compiled, ran, f.lines())
}
}
// Issue 890/AC2: with two directories selected and a runner that blocks, both go test commands run at once; the
// result lists the packages in directory order.
func TestSelectedDirectoriesRunAtOnceAndAnswerInDirectoryOrder(t *testing.T) {
f := newIntegFake(t, "slow")
var mu sync.Mutex
running, most := 0, 0
release := make(chan struct{})
f.onTest = func(line string) {
mu.Lock()
running++
if running > most {
most = running
}
if running == 2 {
close(release)
}
mu.Unlock()
select {
case <-release:
case <-time.After(10 * time.Second):
}
mu.Lock()
running--
mu.Unlock()
}
m, states := threeModules(t, f)
m.Parallel = 3
res, st := runIntegrationOf(t, m, states, "fixture/c/x", "fixture/a/x")
if st.State != delivery.StepPassed {
t.Fatalf("the step ended %s: %s", st.State, st.Reason)
}
if most != 2 {
t.Errorf("at most %d go test commands ran at once, want 2", most)
}
dirs := []string{}
for _, p := range res.Packages {
dirs = append(dirs, p.Dir)
}
if got := fmt.Sprint(dirs); !strings.HasPrefix(got, "[a ") || !strings.HasSuffix(got, " c]") {
t.Errorf("the packages lie in directories %s, want a's before c's", got)
}
}
// The bound holds: with one directory at a time, no two go test commands run at once.
func TestTheDirectoriesAtOnceNeverPassTheBound(t *testing.T) {
f := newIntegFake(t, "slow")
var mu sync.Mutex
running, most := 0, 0
f.onTest = func(string) {
mu.Lock()
running++
if running > most {
most = running
}
mu.Unlock()
time.Sleep(20 * time.Millisecond)
mu.Lock()
running--
mu.Unlock()
}
m, states := threeModules(t, f)
m.Parallel = 1
runIntegrationOf(t, m, states)
if most != 1 {
t.Errorf("%d go test commands ran at once with a bound of 1", most)
}
}
+24 -120
View File
@@ -10,12 +10,8 @@ import (
"os"
"path/filepath"
"regexp"
"runtime"
"strings"
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// testTimeout is go test's own timeout for a level: a bound on a test that never ends, far above any level's bound.
@@ -37,8 +33,7 @@ func (m *Module) testTool() StepTool {
"level": map[string]any{"type": "string", "enum": []string{LevelUnit, LevelIntegration}, "description": "the level"},
"packages": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
"description": "package patterns of each module, ./... when none"},
"run": map[string]any{"type": "string", "description": "optional go test -run pattern: only the tests it matches run (a try's tests)"},
"runner": runnerProperty,
"run": map[string]any{"type": "string", "description": "optional go test -run pattern: only the tests it matches run (a try's tests)"},
},
"required": []string{"workspace", "level"},
},
@@ -73,48 +68,13 @@ func (m *Module) test(ctx context.Context, args map[string]any) (any, error) {
if _, err := os.Stat(tree); err != nil {
return nil, fmt.Errorf("no workspace %s: it was never fetched here, or it is older than a day and was removed", workspace)
}
runner, err := runnerArg(args["runner"])
if err != nil {
return nil, err
switch level {
case LevelUnit:
return m.runLevel(ctx, tree, levelOpts{workspace: workspace, level: LevelUnit, packages: packages, run: pattern, bounds: Bounds{Run: m.unitBound()}, env: m.besideEnv(workspace)})
case LevelIntegration:
return m.integration(ctx, workspace, tree, packages, pattern)
}
var res *TestResult
switch {
case runner == RunnerWorkspace && (level == LevelUnit || level == LevelIntegration):
return m.testAsCandidate(ctx, workspace, level, packages, pattern)
case level == LevelUnit:
res, err = m.runLevel(ctx, tree, levelOpts{workspace: workspace, level: LevelUnit, packages: packages, run: pattern, bounds: Bounds{Run: m.unitBound()}, env: m.besideEnv(workspace)})
case level == LevelIntegration:
res, err = m.integration(ctx, workspace, tree, packages, pattern)
default:
return nil, fmt.Errorf("the level %q is neither unit nor integration", level)
}
if err != nil {
return nil, err
}
res.JudgedBy = RunnerInstalled
return res, nil
}
// testAsCandidate runs a level as the candidate's program (ADR 0535 §1): the integration level is refused first as the
// installed program would refuse it (no postgres), then the whole level runs in the candidate's one shut-off process.
func (m *Module) testAsCandidate(ctx context.Context, workspace, level string, packages []string, pattern string) (*TestResult, error) {
spec := levelSpec{Step: stepUnit, Workspace: workspace, Packages: packages, Run: pattern, Bound: m.unitBound()}
if level == LevelIntegration {
if _, err := m.checkPostgres(ctx); err != nil {
return nil, err // a *buildstep.Refusal: this level only
}
b := m.integrationBounds()
spec = levelSpec{Step: stepIntegration, Workspace: workspace, Packages: packages, Run: pattern, Setup: b.Setup, Bound: b.Run}
}
a, judged, err := m.asCandidate(ctx, spec)
if err != nil {
return nil, err
}
if a.Result == nil {
return nil, fmt.Errorf("the candidate's mesh-build-go answered no result of the %s level", level)
}
a.Result.JudgedBy = judged
return a.Result, nil
return nil, fmt.Errorf("the level %q is neither unit nor integration", level)
}
// besideEnv is MESH_CHECK_BESIDE, naming the directory build_go_fetch placed the workspace's siblings in (ADR 0484
@@ -183,71 +143,26 @@ func (m *Module) runLevel(ctx context.Context, tree string, o levelOpts) (*TestR
return nil, err
}
began := m.clock()
// The directories that hold a selected package run side by side, up to the bound, each into a result of its own;
// the results join in directory order, so the answer does not depend on which ended first (ADR 0536 §3).
type dirRun struct {
res *TestResult
rd *reader
r runAnswer
noise []string
}
var todo []string
for _, d := range dirs {
if len(byDir[d]) > 0 { // no package asked for lies in the others
todo = append(todo, d)
packages := byDir[d]
if len(packages) == 0 {
continue // no package asked for lies in this module
}
}
runs := make([]dirRun, len(todo))
slots := make(chan struct{}, m.parallel())
var wg sync.WaitGroup
var crash any // a panic of a run goes on in the caller, which cleans up after it
var crashMu sync.Mutex
for i, d := range todo {
wg.Add(1)
slots <- struct{}{}
go func() {
defer wg.Done()
defer func() { <-slots }()
defer func() {
if p := recover(); p != nil {
crashMu.Lock()
crash = p
crashMu.Unlock()
}
}()
args := []string{"-C", filepath.Join(tree, d), "test", "-json", "-count=1", "-timeout", testTimeout}
if len(o.tags) > 0 {
args = append(args, "-tags", strings.Join(o.tags, ","))
}
if o.run != "" {
args = append(args, "-run", o.run)
}
args = append(args, byDir[d]...)
dr := &runs[i]
dr.res = &TestResult{}
dr.r = run(ctx, append(m.goEnvFor(filepath.Join(tree, d)), o.env...), "go", args...)
dr.rd = newReader(dr.res, d)
dr.noise = dr.rd.read(dr.r.Stdout)
dr.rd.finish()
}()
}
wg.Wait()
if crash != nil {
panic(crash)
}
for i, d := range todo {
dr := runs[i]
if (dr.r.Status != 0 || dr.r.Err != "") && len(dr.rd.pkgs) == 0 {
args := []string{"-C", filepath.Join(tree, d), "test", "-json", "-count=1", "-timeout", testTimeout}
if len(o.tags) > 0 {
args = append(args, "-tags", strings.Join(o.tags, ","))
}
if o.run != "" {
args = append(args, "-run", o.run)
}
args = append(args, packages...)
r := run(ctx, append(m.goEnvFor(filepath.Join(tree, d)), o.env...), "go", args...)
rd := newReader(res, d)
noise := rd.read(r.Stdout)
rd.finish()
if (r.Status != 0 || r.Err != "") && len(rd.pkgs) == 0 {
// go test said nothing a test said: it could not run (a go.mod it cannot read, a toolchain it cannot find).
return nil, fmt.Errorf("go test in %s: %s", d, firstLine(dr.r.Err+"\n"+strings.Join(dr.noise, "\n")+"\n"+dr.r.Stderr))
}
res.Tests += dr.res.Tests
res.Skipped += dr.res.Skipped
res.Failed = append(res.Failed, dr.res.Failed...)
res.Packages = append(res.Packages, dr.res.Packages...)
res.Slowest = append(res.Slowest, dr.res.Slowest...)
if dr.res.Elapsed > res.Elapsed {
res.Elapsed = dr.res.Elapsed
return nil, fmt.Errorf("go test in %s: %s", d, firstLine(r.Err+"\n"+strings.Join(noise, "\n")+"\n"+r.Stderr))
}
}
if o.wall {
@@ -257,17 +172,6 @@ func (m *Module) runLevel(ctx context.Context, tree string, o levelOpts) (*TestR
return res, nil
}
// runAnswer is what a step runner answers.
type runAnswer = forgekey.Ran
// parallel is how many directories a level runs at once.
func (m *Module) parallel() int {
if m.Parallel > 0 {
return m.Parallel
}
return max(1, runtime.NumCPU()/2)
}
// packagesByDir says which patterns run in which go.mod directory. A relative pattern (./...) runs in every directory, as
// it always did; an import path, as build_go_digest keys its packages, runs only in the directory whose module holds it
// (the longest module path that is the path or its prefix), as another module's path would fail the step. No pattern
@@ -1,112 +0,0 @@
package main
import (
"context"
"strings"
"sync"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
func shutOffModule(t *testing.T, f *fakeStep) (*Module, *keptStates) {
t.Helper()
m, states := fetchModule(t, f)
// A module with bubblewrap runs its steps shut off, with the module cache read-only: a pass of go mod verify holds.
m.Bubblewrap = func(context.Context) (string, error) { return "bubblewrap 0.13.0", nil }
return m, states
}
func countVerifies(f *fakeStep) int {
n := 0
for _, l := range f.lines() {
if strings.HasPrefix(l, "go -C ") && strings.HasSuffix(l, " mod verify") {
n++
}
}
return n
}
// Issue 890/AC5 (ADR 0536 §2): two directories with the same go.mod and go.sum verify once, and a later fetch with the
// same pair verifies no time; a different go.mod verifies again.
func TestGoModVerifyRunsOncePerGoModAndGoSumDigest(t *testing.T) {
same := map[string]string{"a/go.mod": "module a\n", "a/go.sum": "x\n", "b/go.mod": "module a\n", "b/go.sum": "x\n"}
f := &fakeStep{gomods: "a/go.mod\nb/go.mod\n", files: same}
m, states := shutOffModule(t, f)
fetchOnce(t, m, states, fetchArgs)
if n := countVerifies(f); n != 1 {
t.Errorf("two directories with one go.mod and go.sum verified %d times, want 1", n)
}
fetchOnce(t, m, states, fetchArgs)
if n := countVerifies(f); n != 1 {
t.Errorf("a later fetch with the same pair verified again (%d in all)", n)
}
g := &fakeStep{gomods: "a/go.mod\nb/go.mod\n", files: map[string]string{"a/go.mod": "module a\n", "a/go.sum": "x\n", "b/go.mod": "module b\n", "b/go.sum": "x\n"}}
m, states = shutOffModule(t, g)
fetchOnce(t, m, states, fetchArgs)
if n := countVerifies(g); n != 2 {
t.Errorf("two different go.mod verified %d times, want 2", n)
}
}
// Without bubblewrap the module cache is not read-only to a step: a pass is never reused.
func TestAPassOfVerifyIsNotReusedWhenStepsAreNotShutOff(t *testing.T) {
f := &fakeStep{gomods: "a/go.mod\nb/go.mod\n", files: map[string]string{"a/go.mod": "m\n", "b/go.mod": "m\n"}}
m, states := fetchModule(t, f) // no Bubblewrap
fetchOnce(t, m, states, fetchArgs)
fetchOnce(t, m, states, fetchArgs)
if n := countVerifies(f); n != 4 {
t.Errorf("verified %d times, want every directory every fetch (4)", n)
}
}
// A verify that fails leaves no mark: the next fetch verifies again.
func TestAFailedVerifyLeavesNoMark(t *testing.T) {
fail := true
f := &fakeStep{gomods: "go.mod\n", files: map[string]string{"go.mod": "m\n"}}
f.goRule = func(line string) forgekey.Ran {
if strings.HasSuffix(line, " mod verify") && fail {
return forgekey.Ran{Status: 1, Stdout: "x v1: dir has been modified\n"}
}
return forgekey.Ran{}
}
m, states := shutOffModule(t, f)
fetchTool(t, m)(fetchArgs)
states.waitEnd(t)
fail = false
fetchOnce(t, m, states, fetchArgs)
if n := countVerifies(f); n != 2 {
t.Errorf("verified %d times, want 2 (the failed one and the retry)", n)
}
}
// Issue 890 (ADR 0536 §2): the downloads of the directories run side by side, never more at once than the bound.
func TestDownloadsRunSideBySideUpToTheBound(t *testing.T) {
var mu sync.Mutex
running, most := 0, 0
f := &fakeStep{gomods: "a/go.mod\nb/go.mod\nc/go.mod\nd/go.mod\n", files: map[string]string{
"a/go.mod": "a\n", "b/go.mod": "b\n", "c/go.mod": "c\n", "d/go.mod": "d\n"}}
f.goRule = func(line string) forgekey.Ran {
if strings.HasSuffix(line, " mod download") {
mu.Lock()
running++
if running > most {
most = running
}
mu.Unlock()
time.Sleep(40 * time.Millisecond)
mu.Lock()
running--
mu.Unlock()
}
return forgekey.Ran{}
}
m, states := fetchModule(t, f)
m.Parallel = 2
fetchOnce(t, m, states, fetchArgs)
if most != 2 {
t.Errorf("at most %d downloads ran at once, want 2", most)
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ module meshbuildgo
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023121526-2248b985bfac
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023062828-6147a2045467
github.com/distribution/distribution/v3 v3.1.2
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0
-4
View File
@@ -2,10 +2,6 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9 h1:Ufmd2cYE
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023062828-6147a2045467 h1:i85+fFT0wZiTZ9CRVfMzWg1+Zr7oOLl3UH7hEaKcOIc=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023062828-6147a2045467/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023120306-780be2fae5a1 h1:MqP6mWWVxM+niUGPxSSh4Q8lduYkkQ7oTAkDeC9nyv4=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023120306-780be2fae5a1/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023121526-2248b985bfac h1:xx601bHwpO/aq5KyE08t4ymSXGHGnk+D9VHfmOq+5P8=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023121526-2248b985bfac/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
@@ -520,7 +520,7 @@ func TestAPassingVerdictAsksTheApproveAndItsApproveMergesWithinSecondsOverARealB
passed := b.publish(t, "mesh.seat."+ControllerSeat+".event.checked", judgedAt("novox/app", 7, head, "pass", "t1t1t1t1t1t1"))
// No review names the head, so nothing is asked (ADR 0496, issue 1803): the delivery waits for a review.
rt.until("the delivery to wait for a review", eventWithin, func() bool {
rt.until("the delivery to wait for a review", 5*time.Second, func() bool {
e, err := b.kvs["pulls"].Get(ctx, "novox.app.7")
var k PullKey
return err == nil && json.Unmarshal(e.Value(), &k) == nil && k.Verdict == "pass"
@@ -537,7 +537,7 @@ func TestAPassingVerdictAsksTheApproveAndItsApproveMergesWithinSecondsOverARealB
"title": announced.Title, "head_sha": head, "base": "main", "body": "Reviewed-by: lead@shanks at " + head})
_ = passed
var key string
rt.until("the ask to be written", eventWithin, func() bool { key = onlyAsk(b); return key != "" })
rt.until("the ask to be written", 5*time.Second, func() bool { key = onlyAsk(b); return key != "" })
ask := askAt(t, b, key)
t.Logf("the ask was written %s after the edit's event", time.Since(edit))
if !strings.Contains(ask.Ask.Explanation, "reviewed by lead on shanks") || ask.Ask.ID != sdk.AskID(key) {
@@ -547,7 +547,7 @@ func TestAPassingVerdictAsksTheApproveAndItsApproveMergesWithinSecondsOverARealB
t.Fatalf("merged before the Approve")
}
// The pull request's key holds its head, the trunk commit it was asked against and the verdict.
rt.until("the pull request's key to hold head, trunk and verdict", eventWithin, func() bool {
rt.until("the pull request's key to hold head, trunk and verdict", 5*time.Second, func() bool {
e, err := b.kvs["pulls"].Get(ctx, "novox.app.7")
var k PullKey
return err == nil && json.Unmarshal(e.Value(), &k) == nil && k == PullKey{Head: head, Trunk: "t1t1t1t1t1t1", Verdict: "pass"}
@@ -559,7 +559,7 @@ func TestAPassingVerdictAsksTheApproveAndItsApproveMergesWithinSecondsOverARealB
t.Fatalf("the gate asked %s %d times: the bridge writes to the forge", tool, n)
}
}
rt.until("the owed keys to be written", eventWithin, func() bool {
rt.until("the owed keys to be written", 5*time.Second, func() bool {
keys, err := b.kvs["owed"].Keys(ctx)
return err == nil && len(keys) > 0
})
@@ -567,7 +567,7 @@ func TestAPassingVerdictAsksTheApproveAndItsApproveMergesWithinSecondsOverARealB
// seconds, with the Approve's heads, and the gate asks the forge nothing.
approved := b.answer(t, key, OptionApprove)
var handed sdk.Merge
rt.until("the merge to be handed to the bridge", eventWithin, func() bool { handed = mergeAt(b, "fix/x"); return len(handed.Pulls) > 0 })
rt.until("the merge to be handed to the bridge", 5*time.Second, func() bool { handed = mergeAt(b, "fix/x"); return len(handed.Pulls) > 0 })
if len(handed.Pulls) != 1 || handed.Pulls[0].Head.Head != head || handed.Pulls[0].Number != 7 || handed.Pulls[0].State != sdk.MergeMerging ||
handed.Approved[sdk.ApprovedKey("novox/app", 7)] != head || handed.Ask != key {
t.Fatalf("the merge handed is %+v; wanted pull request 7 at the approved head, merging, under the ask %s", handed, key)
@@ -579,7 +579,7 @@ func TestAPassingVerdictAsksTheApproveAndItsApproveMergesWithinSecondsOverARealB
// The bridge says it merged; the mesh's source then holds the merge, in one write.
b.forgeSays(t, sdk.Forge{Kind: sdk.ForgeMerged, Repository: "novox/app", Number: 7, Head: head, Merge: strings.Repeat("b", 40),
Root: "r", Trees: map[string]string{"app": "t"}})
rt.until("source.main to hold the merge", eventWithin, func() bool {
rt.until("source.main to hold the merge", 5*time.Second, func() bool {
e, err := b.kvs["source"].Get(ctx, sdk.SourceMainKey)
if err != nil {
return false
@@ -743,7 +743,7 @@ func TestAGroupOfTwoWithGreenChecksGetsOneAskAndOnItsApproveBothMergeOneAfterThe
// One member is handed, with both heads approved, within seconds of the answer; the second only after the bridge says the
// first merged.
var first sdk.Merge
rt.until("the first member to be handed", eventWithin, func() bool { first = mergeAt(b, "feat/x"); return len(first.Pulls) > 0 })
rt.until("the first member to be handed", 5*time.Second, func() bool { first = mergeAt(b, "feat/x"); return len(first.Pulls) > 0 })
if len(first.Pulls) != 1 || first.Pulls[0].State != sdk.MergeMerging || len(first.Approved) != 2 ||
first.Approved[sdk.ApprovedKey("novox/mesh-controller", 1)] != ctlHead || first.Approved[sdk.ApprovedKey("novox/mesh-catalog", 2)] != catHead {
t.Fatalf("the first merge handed is %+v", first)
@@ -757,14 +757,14 @@ func TestAGroupOfTwoWithGreenChecksGetsOneAskAndOnItsApproveBothMergeOneAfterThe
b.forgeSays(t, sdk.Forge{Kind: sdk.ForgeMerged, Repository: one.Repository, Number: one.Number, Head: one.Head.Head,
Merge: strings.Repeat("a", 40), Root: "r", Trees: map[string]string{"x": "t"}})
var both sdk.Merge
rt.until("the second member to be handed", eventWithin, func() bool { both = mergeAt(b, "feat/x"); return len(both.Pulls) == 2 })
rt.until("the second member to be handed", 5*time.Second, func() bool { both = mergeAt(b, "feat/x"); return len(both.Pulls) == 2 })
if both.Pulls[0].State != sdk.MergeMerged || both.Pulls[1].State != sdk.MergeMerging || both.Pulls[1].Repository == one.Repository {
t.Fatalf("the second merge handed is %+v", both.Pulls)
}
two := both.Pulls[1]
b.forgeSays(t, sdk.Forge{Kind: sdk.ForgeMerged, Repository: two.Repository, Number: two.Number, Head: two.Head.Head,
Merge: strings.Repeat("b", 40), Root: "r", Trees: map[string]string{"x": "t"}})
rt.until("source.main to hold both merges", eventWithin, func() bool {
rt.until("source.main to hold both merges", 5*time.Second, func() bool {
e, err := b.kvs["source"].Get(ctx, sdk.SourceMainKey)
if err != nil {
return false
@@ -942,9 +942,3 @@ func mustDecode[T any](t *testing.T, raw []byte) T {
}
return v
}
// eventWithin is how long a test waits for one step of the gate's path over the real bus. On an idle machine each step
// takes milliseconds (the tests log it), but a slow, loaded build node ran the ask step in 6.69 s against the old 5 s
// (issue 1404). A failed first ask is asked again only after askAgainAfter (30 s, react.go), and 20 s deliberately does not
// cover that: a failed first ask still fails the test.
const eventWithin = 20 * time.Second
@@ -0,0 +1,134 @@
package main
import (
"testing"
"time"
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
)
// Issue 888: the checker judges on the trunk source.main names (mesh-delivery modules/mesh-delivery-checker main.go), and the
// gate moved it only for a merge it handed to the forge bridge (finish). A merge made at the forge directly moved every other
// delivery's trunk (trunkMoved) and left source.main, so the checker kept judging on the trunk before it.
const (
i888Other = "novox/other"
i888OtherH = "0000000000000000000000000000000000000001"
i888App = "novox/app"
i888Head1 = "1111111111111111111111111111111111111111"
i888Head2 = "2222222222222222222222222222222222222222"
i888Merge1 = "3333333333333333333333333333333333333333"
i888Merge2 = "4444444444444444444444444444444444444444"
)
func i888MergedAt(repo string, number int, head, merge string, at time.Time) PullEvent {
p := merged(repo, number, head, merge)
p.MergedAt = at.UTC().Format(time.RFC3339)
return p
}
// 888/AC1: a merge into main the gate did not hand is heard from the forge: source.main holds it, and keeps the others.
func TestAMergeHeardOnlyFromTheForgeMovesSourceMain(t *testing.T) {
w := newWorld(t)
w.source.main = &sdk.Source{V: sdk.Version, Group: "earlier", Heads: map[string]string{i888Other: i888OtherH}}
w.h.PullUpdated(pr(i888App, 3, i888Head1, "fix/x", "internal/a.go"))
w.settleAll()
w.h.PullMerged(i888MergedAt(i888App, 3, i888Head1, i888Merge1, w.now.Add(-time.Minute)))
w.settleAll()
if w.source.main == nil || w.source.main.Heads[i888App] != i888Merge1 || w.source.main.Heads[i888Other] != i888OtherH {
t.Fatalf("source.main is %+v; wanted %s at %s and %s kept", w.source.main, i888App, i888Merge1, i888Other)
}
n := len(w.source.moves)
w.settleAll()
w.h.PullMerged(i888MergedAt(i888App, 3, i888Head1, i888Merge1, w.now.Add(-time.Minute))) // heard again
w.settleAll()
if len(w.source.moves) != n || n != 1 {
t.Fatalf("source.main moved %d then %d times; wanted once", n, len(w.source.moves))
}
}
// 888/AC2: a group the gate merged through the bridge moves source.main once, in finish, though the forge's word of each
// member's merge is heard too.
func TestAGroupTheGateMergedMovesSourceMainOnceThoughTheForgeSaysItsMerges(t *testing.T) {
w := approvedGroup(t)
allGreen(w)
w.op.answer(w.op.open()[0], OptionApprove)
w.settleAll()
w.settleAll()
if len(w.forge.merges) != 2 || len(w.source.moves) != 1 {
t.Fatalf("merges %v, source.main moved %d times", w.forge.merges, len(w.source.moves))
}
commit := w.source.moves[0].Heads[gCat]
w.h.PullMerged(i888MergedAt(gCtl, 1, gHc, w.source.moves[0].Heads[gCtl], w.now))
w.h.PullMerged(i888MergedAt(gCat, 2, gHk, commit, w.now))
w.settleAll()
if len(w.source.moves) != 1 {
t.Fatalf("source.main moved %d times; wanted once, by the group: %+v", len(w.source.moves), w.source.moves)
}
}
// 888/AC3: an older merge heard after a newer one, or replayed as history, never moves source.main backwards.
func TestAnOlderMergeHeardLaterNeverMovesSourceMainBack(t *testing.T) {
w := newWorld(t)
w.h.PullMerged(i888MergedAt(i888App, 4, i888Head2, i888Merge2, w.now.Add(-time.Minute)))
w.settleAll()
w.h.PullMerged(i888MergedAt(i888App, 3, i888Head1, i888Merge1, w.now.Add(-2*time.Minute)))
w.settleAll()
if w.source.main == nil || w.source.main.Heads[i888App] != i888Merge2 || len(w.source.moves) != 1 {
t.Fatalf("source.main is %+v after %d moves; wanted the newer merge %s, moved once", w.source.main, len(w.source.moves), i888Merge2)
}
h := newWorld(t)
h.source.main = &sdk.Source{V: sdk.Version, Group: "now", Heads: map[string]string{i888App: i888Merge2}}
h.h.PullMerged(i888MergedAt(i888App, 3, i888Head1, i888Merge1, h.now.Add(-4*24*time.Hour)))
h.settleAll()
if h.source.main.Heads[i888App] != i888Merge2 || len(h.source.moves) != 0 {
t.Fatalf("a merge replayed as history moved source.main to %+v", h.source.main)
}
}
// 888/AC4: an approved group whose members were merged at the forge before the gate handed them moves source.main once.
func TestAnApprovedGroupMergedAtTheForgeMovesSourceMainOnce(t *testing.T) {
w := approvedGroup(t)
allGreen(w)
w.op.answer(w.op.open()[0], OptionApprove)
mc, mk := "5555555555555555555555555555555555555555", "6666666666666666666666666666666666666666"
w.h.PullMerged(i888MergedAt(gCtl, 1, gHc, mc, w.now))
w.h.PullMerged(i888MergedAt(gCat, 2, gHk, mk, w.now))
w.settleAll()
w.settleAll()
if len(w.source.moves) != 1 || w.source.main.Heads[gCtl] != mc || w.source.main.Heads[gCat] != mk {
t.Fatalf("source.main moved %d times to %+v; wanted once, both members at their merge commits", len(w.source.moves), w.source.main)
}
}
// 888/AC5 (review round 1): an older merge heard from the forge after the bridge merged a newer one, whose own pull.merged is
// not heard yet, never moves source.main back; nor does one older than the gate keeps records for, over a head it does not know.
func TestAnOlderForgeMergeNeverOverwritesANewerBridgeMergeOrAnUnknownHead(t *testing.T) {
w := approvedGroup(t)
allGreen(w)
w.op.answer(w.op.open()[0], OptionApprove)
w.settleAll()
w.settleAll()
if len(w.source.moves) != 1 {
t.Fatalf("the group did not move source.main once: %d", len(w.source.moves))
}
bridged := w.source.main.Heads[gCat]
w.h.PullUpdated(pr(gCat, 7, "7777777777777777777777777777777777777777", "fix/old", "modules/app/module.json"))
w.h.PullMerged(i888MergedAt(gCat, 7, "7777777777777777777777777777777777777777", "8888888888888888888888888888888888888888", w.now.Add(-10*time.Minute)))
w.settleAll()
if w.source.main.Heads[gCat] != bridged || len(w.source.moves) != 1 {
t.Fatalf("an older forge merge moved source.main from the bridge's %s to %s", bridged, w.source.main.Heads[gCat])
}
o := newWorld(t)
o.source.main = &sdk.Source{V: sdk.Version, Group: "pruned", Heads: map[string]string{i888App: i888Merge2}}
at := o.now
o.h.PullUpdated(pr(i888App, 3, i888Head1, "fix/x", "internal/a.go"))
o.later(keptFinal + 24*time.Hour)
o.h.PullMerged(i888MergedAt(i888App, 3, i888Head1, i888Merge1, at.Add(time.Minute)))
o.settleAll()
if o.source.main.Heads[i888App] != i888Merge2 || len(o.source.moves) != 0 {
t.Fatalf("a merge older than the records kept overwrote a head the gate does not know: %+v", o.source.main)
}
}
@@ -346,7 +346,8 @@ func (h *Holder) settleMerges() []func() {
}
}
})
case r.Merged != nil && !r.SourceMoved && !done[key]:
case (r.Merged != nil || d.MergedAs != "") && !r.SourceMoved && !done[key]:
// Merged through the bridge, or at the forge directly after the Approve (issue 888): the group is moved once.
if !h.allMerged(r.Heads) {
continue
}
@@ -420,3 +421,100 @@ func (h *Holder) finish(name, key string, approved map[string]string, last strin
}
h.Logf("[mesh-delivery] group %s is merged whole: source.main holds %s", name, strings.Join(members, ", "))
}
// gateMerges says whether finish moves source.main for a delivery: one the operator approved through an ask, whose merge the
// gate hands to the forge bridge.
func gateMerges(d *Delivery) bool {
return d.Ready != nil && d.Ready.Approved != nil && d.Ready.AskID != "" && len(d.Ready.Heads) > 0
}
// settleForgeMerges moves source.main for each merge into the trunk heard only from the forge (issue 888): a pull request
// merged at the forge directly, not handed by the gate on an Approve. Every other delivery's trunk moved with it (trunkMoved),
// and the checker judges on the trunk source.main names, so source.main moves too. A merge replayed as history, or older than
// a merge of its repository already heard, is never written: source.main never moves backwards. Under the lock; the writing
// happens outside it.
func (h *Holder) settleForgeMerges() []func() {
var out []func()
for _, d := range h.deliveries {
if d.MergedAs == "" || d.InSource || gateMerges(d) {
continue
}
if d.Base != MergeBranch || d.History != "" || h.newerMergeOf(d) {
d.InSource = true // not the trunk, or not its head: nothing to write
h.keep(d)
continue
}
id, repository, base, commit, at, group := d.ID, d.Repository, d.Base, d.MergedAs, d.MergedAt, firstOf(d.Branch, d.Repository)
out = append(out, func() {
err := h.Source.Move(func(prev *sdk.Source) (sdk.Source, bool) {
next := sdk.Source{Group: group, Heads: map[string]string{repository: commit}}
if prev != nil {
held := prev.Heads[repository]
if sameCommit(held, commit) {
return next, false // there already: the bridge's word and the forge's are one merge
}
if held != "" && !h.olderHead(repository, base, held, at) {
return next, false // source.main holds a newer merge, or one the gate cannot date: never moved back
}
for r, head := range prev.Heads {
if r != repository {
next.Heads[r] = head
}
}
}
return next, true
})
h.mu.Lock()
defer h.mu.Unlock()
if err != nil {
h.Logf("[mesh-delivery] %s merged at the forge, and its place in the source cannot be written yet: %v; asked again in %s", id, err, askAgainAfter)
h.again("source.main, moved for a merge heard from the forge")
return
}
if m := h.deliveries[id]; m != nil {
m.InSource = true
h.keep(m)
}
h.Logf("[mesh-delivery] %s merged at the forge: source.main holds %s at %s", id, repository, shortOf(commit, 12))
})
}
return out
}
// mergeOf is a delivery's merge into its trunk as the gate knows it: the forge's word (MergedAs), or else the bridge's
// (Ready.MergeCommit, Ready.Merged), which comes first for a merge the gate handed. Empty when it is not merged.
func mergeOf(d *Delivery) (string, time.Time) {
if d.MergedAs != "" {
return d.MergedAs, d.MergedAt
}
if d.Ready != nil && d.Ready.Merged != nil && d.Ready.MergeCommit != "" {
return d.Ready.MergeCommit, *d.Ready.Merged
}
return "", time.Time{}
}
// newerMergeOf says whether a merge into the same trunk of d's repository was heard that landed after d's.
func (h *Holder) newerMergeOf(d *Delivery) bool {
for _, o := range h.deliveries {
if commit, at := mergeOf(o); o != d && o.Repository == d.Repository && o.Base == d.Base && commit != "" &&
!sameCommit(commit, d.MergedAs) && at.After(d.MergedAt) {
return true
}
}
return false
}
// olderHead says whether the head source.main holds for a repository is a merge the gate knows landed before at, so a merge
// at that time may take its place. A head it does not know (written before its records, or pruned with its delivery) is
// older only when at is within the time final records are kept: a pruned merge ended before then (issue 888, review).
// Takes the lock.
func (h *Holder) olderHead(repository, base, head string, at time.Time) bool {
h.mu.Lock()
defer h.mu.Unlock()
for _, o := range h.deliveries {
if commit, when := mergeOf(o); o.Repository == repository && o.Base == base && commit != "" && sameCommit(commit, head) {
return !when.After(at)
}
}
return at.After(h.Now().Add(-keptFinal))
}
@@ -84,7 +84,10 @@ type Delivery struct {
Others []PullEvent `json:"others,omitempty"`
MergedAs string `json:"merged_as,omitempty"`
MergedAt time.Time `json:"merged_at"`
HeldWhy string `json:"held_why,omitempty"`
// InSource says the merge was looked at for source.main: written there, or left as older than the trunk's head (issue 888).
// A merge the gate handed to the bridge on an Approve is moved there by finish instead (merges.go).
InSource bool `json:"in_source,omitempty"`
HeldWhy string `json:"held_why,omitempty"`
// BackupsAgainAfter is the revision of its walk when a person released it for a failed backup: a record at that
// revision or before still holds the failure the controller was asked to clear, and does not hold it again
// (novox/hq ADR 0404, issue 1068).
@@ -267,6 +267,7 @@ func (h *Holder) readyAsks(now time.Time) []func() {
}
}
asks = append(asks, h.settleMerges()...)
asks = append(asks, h.settleForgeMerges()...)
asks = append(asks, h.withdrawnKeys()...)
asks = append(h.cancels, asks...)
h.cancels = nil
@@ -1,53 +0,0 @@
package main
import (
"fmt"
"testing"
)
// dockerVerdict decides what a test that starts containers does: run, skip or fail. found is the result of looking
// the docker program up, probeErr the result of asking the daemon for its version (nil when it answered). The checker's
// integration level runs shut off (no network, no /run), so on a build node the program is found and the daemon is not
// reachable; that is a skip, not a failure, except where docker is required (MESH_REQUIRE_DOCKER=1).
func dockerVerdict(found bool, lookErr, probeErr error, required bool) (action, reason string) {
var why string
switch {
case !found:
why = fmt.Sprintf("there is no docker in this toolchain (%v)", lookErr)
case probeErr != nil:
why = fmt.Sprintf("no docker daemon is reachable from here (%v)", probeErr)
default:
return "run", ""
}
if required {
return "fail", "MESH_REQUIRE_DOCKER=1 and " + why
}
return "skip", why
}
func TestDockerVerdict(t *testing.T) {
boom := fmt.Errorf("cannot connect")
cases := []struct {
name string
found bool
look, probe error
required bool
want string
}{
{"answers", true, nil, nil, false, "run"},
{"answers, required", true, nil, nil, true, "run"},
{"no program", false, boom, nil, false, "skip"},
{"no program, required", false, boom, nil, true, "fail"},
{"program but no daemon", true, nil, boom, false, "skip"},
{"program but no daemon, required", true, nil, boom, true, "fail"},
}
for _, c := range cases {
got, why := dockerVerdict(c.found, c.look, c.probe, c.required)
if got != c.want {
t.Errorf("%s: got %s, want %s", c.name, got, c.want)
}
if got != "run" && why == "" {
t.Errorf("%s: no reason given", c.name)
}
}
}
@@ -60,24 +60,14 @@ var snapshotGrants = struct{ publish, subscribe []string }{
func needDocker(t *testing.T) {
t.Helper()
_, lookErr := exec.LookPath("docker")
var probeErr error
if lookErr == nil {
// The binary alone proves nothing: the checker's integration level runs shut off (no network, no /run), so on
// a build node docker is found and its daemon is not reachable (issue 1823).
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if out, err := exec.CommandContext(ctx, "docker", "version", "--format", "{{.Server.Version}}").CombinedOutput(); err != nil {
probeErr = fmt.Errorf("%v: %s", err, strings.TrimSpace(string(out)))
if _, err := exec.LookPath("docker"); err != nil {
// The Go toolchain container of the merge check holds no docker. Where docker is expected
// (MESH_REQUIRE_DOCKER=1, which merge-check.sh sets for its docker step) its absence is a failure.
if os.Getenv("MESH_REQUIRE_DOCKER") == "1" {
t.Fatalf("MESH_REQUIRE_DOCKER=1 and there is no docker here: %v", err)
}
}
// Where docker is expected (MESH_REQUIRE_DOCKER=1, which merge-check.sh sets for its docker step) its absence is a failure.
switch action, why := dockerVerdict(lookErr == nil, lookErr, probeErr, os.Getenv("MESH_REQUIRE_DOCKER") == "1"); action {
case "fail":
t.Fatal(why)
case "skip":
t.Skipf("NOT RUN: this test starts a throwaway nats container and %s; "+
"it is proof only where merge-check.sh's docker step runs it (MESH_CHECK_DOCKER=1)", why)
t.Skipf("NOT RUN: this test starts a throwaway nats container and there is no docker in this toolchain (%v); "+
"it is proof only where merge-check.sh's docker step runs it (MESH_CHECK_DOCKER=1)", err)
}
}