20 Commits
Author SHA1 Message Date
jschoubben 90fd07ac26 Merge pull request 'Give the indexer a new build: its last build's program never reached the artifact store, so novox cannot apply (issue 1933)' (#32) from fix/1933-rebuild-indexer into main
Reviewed-on: #32
2026-10-23 23:38:21 +00:00
jschoubben 90a79b1585 Give the indexer a new build: its last build's program never reached the artifact store, so novox cannot apply (issue 1933)
mesh/delivery delivered
mesh/check checking 90a79b15854b on 90fd07ac260a
2026-10-24 01:35:33 +02:00
jschoubben 45ab994777 Merge pull request 'The checker keeps a failed test's whole message with its cause first, and its status names the cause (issue 1901, with mesh-catalog)' (#30) from fix/1901-rebased into main
Reviewed-on: #30
2026-10-23 22:36:03 +00:00
jschoubben 55f486e040 Merge pull request 'The indexer keeps an unreadable manifest's inputs, so an SDK upgrade judges it again at start (issue 1923)' (#29) from fix/1923-stale-none2 into main
Reviewed-on: #29
2026-10-23 22:35:49 +00:00
jschoubben 7a88eb0a56 Merge pull request 'The checker reads mesh-sdk's list-form level digest, so kept verdicts hold for a check naming a package like token (issue 1930, with mesh-sdk and mesh-catalog)' (#31) from fix/1930-digest-shape into main
Reviewed-on: #31
2026-10-23 22:08:43 +00:00
jschoubben 9261347a8f Read a level's digest from mesh-sdk's list form, so kept verdicts hold for a check whose end names a package like token (issue 1930)
mesh/delivery covered after its failure: a later walk that contains it was delivered: plan-1792794985391468127 (novox/mesh-delivery at 45ab9947 into main…
mesh/delivery-group group fix/1930-digest-shape failed: a member failed
mesh/check checking 9261347a8f91 on 7a88eb0a5609
2026-10-23 23:37:49 +02:00
mesh-tools 263ff54a4e Merge pull request 'Integration tests that R4g never ran now pass: the assigner's fixture, the builder's restart test, four checker tests (issues 1927, 1928, 1929)' (#28) from fix/1927-assigner-fpset into main 2026-10-23 21:24:32 +00:00
jschoubben cd3e55e056 Bring the checker's integration tests to what R4g decided: Go-only repository, echoed beside, readable kept (issue 1929)
mesh/delivery delivered
mesh/check pass cd3e55e05605 on fae4d48e09a5 | unit 0 run 483 kept | int 0 run 540 kept | clone 17s | composition passed on 3 machines
2026-10-23 23:18:11 +02:00
jschoubben b303e32a41 Close the builder test's nodes from the bed's start, so no pull in hand takes the first ask (issue 1928) 2026-10-23 23:03:38 +02:00
jschoubben a31bd7d4a5 Give the assigner test's candidate the SDK's fingerprint set, which the SDK refuses without (issue 1927) 2026-10-23 22:57:05 +02:00
jschoubben 32542ebc14 Keep an unreadable manifest's inputs and judge its verdict again at start, so an upgraded SDK clears it (issue 1923)
mesh/check pass 32542ebc14c3 on 7a88eb0a5609 | unit 122 run, 366 kept | integration 132 run, 414 kept | composition passed on novox
mesh/delivery delivered
2026-10-23 22:17:09 +02:00
jschoubben a697ee86d4 Test 1901/AC3: an older pair of build module and checker still agree (issue 1901)
mesh/delivery delivered
mesh/delivery-group group fix/1901-rebased delivered: every member is delivered
mesh/check checking a697ee86d4af on 45ab99477701
2026-10-23 22:14:29 +02:00
jschoubben 0373a3b24e mesh-delivery-checker: a failed test's verdict keeps its last lines and the status line names its error (issue 1901) 2026-10-23 22:13:41 +02:00
jschoubben fae4d48e09 Merge pull request 'R4g+R5 (mesh-delivery): builder and checker put asks, the assigner and its composition check, the view's wants and backlog (design 47)' (#27) from feat/r4g into main
Reviewed-on: #27
2026-10-23 17:49:01 +00:00
jschoubben b2c545c40a Place mesh-controller beside the node-engine's tests, which read its manifest (issue 1295, R4g)
mesh/delivery failed: its walk failed, carrying its own commit: a gate on a first machine (what it carried put back), a build, a machine
mesh/delivery-group group feat/r4g failed: a member failed
mesh/check checking b2c545c40aec on 85aca46348a0 | novox/mesh-delivery#27 in group feat/r4g
2026-10-23 18:36:13 +02:00
jschoubben 85aca46348 Merge pull request 'Checks under 5 minutes (checker): a new head's check before trunk-move re-judges, and a build module's change judged by its own code (issues 1897, 1898; ADR 0535, 0536)' (#25) from feat/fastcheck into main
Reviewed-on: #25
2026-10-23 13:50:08 +00:00
mesh-tools db5d88f893 Merge pull request 'Drop the indexer's modules keys the catalogue's trunk no longer holds, at each merge and at start (issue 1900)' (#26) from fix/1900-indexer-drops-removed-modules into main 2026-10-23 12:56:31 +00:00
jschoubben 24753bfc2c Grant gitea_contains in the indexer's invokes and test every called forge tool is granted (issue 1900)
mesh/check pass 24753bfc2c02 on 75a4821eff9e | unit 431 run, 0 kept | integration 476 run, 0 kept | over bound: integration (slowest integration mesh-…
mesh/delivery covered after its stop: a later walk that contains it was delivered: plan-1792794967663205383 (novox/mesh-delivery at 55f486e0 into main, d…
2026-10-23 14:48:41 +02:00
jschoubben 1dc9d80cfb Keep a key written ahead of the source head and a key with no path when reconciling (issue 1900) 2026-10-23 14:44:14 +02:00
jschoubben 26bfdbb637 Drop the indexer's modules keys the trunk no longer holds, at each merge and at start (issue 1900) 2026-10-23 14:31:55 +02:00
25 changed files with 864 additions and 56 deletions
@@ -181,8 +181,10 @@ func TestAChangeIsComposedOnTheBus(t *testing.T) {
b.putJSON(t, provider.AssignmentsBucket, "ace.other", []byte(`{"node":"ace","module":"other","build":"build-2"}`))
heads := []delivery.Head{{Repository: "novox/web", Number: 7, Head: strings.Repeat("2", 40)}}
hash, _ := delivery.HeadsHash(heads)
cand, err := delivery.EncodeCandidate(delivery.Candidate{Group: "feat/x", HeadsHash: hash, Pulls: []delivery.CandidatePull{{Head: heads[0],
Modules: []string{"web"}, Fps: map[string]delivery.CandidateModule{"web": {FP2: delivery.SourcePrefix2 + strings.Repeat("b", 64), Manifest: changed}}}}})
// 1927/AC1: the candidate carries the fingerprint set the SDK computes, as the indexer writes it.
pulls := []delivery.CandidatePull{{Head: heads[0],
Modules: []string{"web"}, Fps: map[string]delivery.CandidateModule{"web": {FP2: delivery.SourcePrefix2 + strings.Repeat("b", 64), Manifest: changed}}}}
cand, err := delivery.EncodeCandidate(delivery.Candidate{Group: "feat/x", HeadsHash: hash, Pulls: pulls, FPSet: delivery.FingerprintSet(pulls)})
if err != nil {
t.Fatal(err)
}
@@ -179,7 +179,11 @@ type bed struct {
// startBed starts the bus, the buckets the controller makes from the builder's and the build modules' manifests, the
// build modules' streams and the builder's durable consumers on them, two nodes' toolchains, the indexer's manifests, and
// the fake build modules. It runs no builder.
func startBed(t *testing.T) *bed {
func startBed(t *testing.T) *bed { return startBedWith(t, false) }
// startBedWith is startBed with the nodes' capacity 0 from the start when closed: a puller that reads its capacity before a
// test could close the nodes may be in a pull already and takes the first ask put (issue 1928).
func startBedWith(t *testing.T, closed bool) *bed {
dir, err := os.MkdirTemp(os.Getenv("TMPDIR"), "b")
if err != nil {
t.Fatal(err)
@@ -243,6 +247,7 @@ func startBed(t *testing.T) *bed {
b.builds = mk(delivery.BuilderBuildsBucket, delivery.BuilderBuildsHistory)
b.instances = mk(instancesBucket, 1)
b.fake = &fakeBuildModules{js: js, ctx: ctx, calls: map[string]int{}}
b.fake.closed.Store(closed)
b.fake.serve(t, "mesh-build-go", "n1", "n2")
b.fake.serve(t, "mesh-build-node", "n1", "n2")
b.fake.serve(t, "mesh-build-image", "n1") // the image build module runs on n1 only
@@ -781,8 +786,7 @@ func (b *bed) asksPut(t *testing.T, nc *nats.Conn) func() []delivery.StepAsk {
// 1887/AC3: a builder that started again while its asks waited in the streams (no node had a free slot) puts no second
// ask: it waits for the asks its building key names, and the key is done once a node took them.
func TestARestartedBuilderPutsNoSecondAsk(t *testing.T) {
b := startBed(t)
b.fake.closed.Store(true)
b := startBedWith(t, true) // 1928/AC1: no node can take an ask from the start, so no pull in hand takes the first one
b.module(t, "netcheck", "catalogue-a67f0196-netcheck.module.json", "modules/netcheck")
sub, err := nats.Connect(b.srvURL, nats.UserInfo("ctl", "pw"))
if err != nil {
@@ -792,11 +796,13 @@ func TestARestartedBuilderPutsNoSecondAsk(t *testing.T) {
asked := b.asksPut(t, sub)
b.start(t, "n1", nil)
v := b.await(t, "netcheck", func(v delivery.BuilderBuild) bool { return v.State == delivery.BuildBuilding && len(v.Asks) == 2 })
for deadline := time.Now().Add(5 * time.Second); len(asked()) < 2 && time.Now().Before(deadline); time.Sleep(20 * time.Millisecond) {
for deadline := time.Now().Add(20 * time.Second); len(asked()) < 2; time.Sleep(20 * time.Millisecond) {
if time.Now().After(deadline) {
t.Fatalf("asks put = %+v within 20 s, want the two the key names", asked())
}
}
b.stop()
b.start(t, "n1", nil) // the same builder, started again, while both asks wait
time.Sleep(500 * time.Millisecond)
b.fake.open()
done := b.await(t, "netcheck", isState(delivery.BuildDone))
if got := asked(); len(got) != 2 {
@@ -10,6 +10,7 @@ import (
"io"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"sync/atomic"
@@ -81,6 +82,9 @@ type fakeBuildModules struct {
mu sync.Mutex
calls map[string]int // by `<module>.<tool>`
testTool *testing.T
// beside is, by workspace, the siblings the fetch placed in it as `<owner/repo> <commit>`, sorted: a digest echoes them
// (1929/AC2; mesh-sdk buildstep.besideOf), or the level runs whole and keeps nothing.
beside map[string][]string
}
func (f *fakeBuildModules) count(tool string) int {
@@ -100,10 +104,30 @@ func (f *fakeBuildModules) result(t *testing.T, tool string, args map[string]any
}
fetched := readCaptured(t, name)
fetched["commit"], fetched["base"] = args["commit"], args["base"]
var refs []struct {
Repository string `json:"repository"`
Commit string `json:"commit"`
}
raw, _ := json.Marshal(args["beside"])
_ = json.Unmarshal(raw, &refs)
var lines []string
for _, r := range refs {
lines = append(lines, r.Repository+" "+r.Commit)
}
sort.Strings(lines)
f.mu.Lock()
if f.beside == nil {
f.beside = map[string][]string{}
}
f.beside[fmt.Sprint(fetched["workspace"])] = lines
f.mu.Unlock()
v = fetched
case strings.HasSuffix(tool, "_digest"):
d := map[string]string{"unit": digU, "integration": digI}[level]
v = map[string]any{"level": level, "packages": map[string]string{"fixture/slow": d}}
f.mu.Lock()
beside := f.beside[fmt.Sprint(args["workspace"])]
f.mu.Unlock()
v = map[string]any{"level": level, "packages": map[string]string{"fixture/slow": d}, "beside": beside}
case strings.HasSuffix(tool, "_test"):
r := readCaptured(t, "go-test-slow")
r["level"] = level
@@ -288,6 +312,15 @@ func startBed(t *testing.T) *bed {
}
b.buckets[name] = kv
}
// 1929/AC1: the indexer's value of the repository's module, with no TypeScript manifest, so the Go build module alone
// judges the repository (an unknown repository is judged by both, issue 1887 A12).
known, err := delivery.EncodeModule(delivery.Module{Repository: repo, Commit: trunk, Path: "modules/m", None2: "the fingerprint is computed with its chain"})
if err != nil {
t.Fatal(err)
}
if _, err := b.buckets[delivery.ModulesBucket].Put(ctx, "m", known); err != nil {
t.Fatal(err)
}
b.setOnline(t, "n1", true)
for _, module := range []string{"mesh-build-go", "mesh-build-node"} {
b.setHealth(t, "n1", module)
@@ -436,7 +469,7 @@ func (b *bed) owed(t *testing.T, number int, head, state string) delivery.Owed {
// TestCheckerJudgesACandidateAndOwesItsStatus: a candidate the indexer wrote, one pull request of mesh-delivery, is fetched
// merged onto the trunk the gate names, digested and tested at both levels on the node, kept, previewed; the verdict passes
// with the counts of both levels and the composition not run, and the status mesh/check is owed on the head as success.
func TestCheckerJudgesACandidateAndOwesItsStatus(t *testing.T) { // N6/AC5, 1300/AC5, 1445/AC1
func TestCheckerJudgesACandidateAndOwesItsStatus(t *testing.T) { // N6/AC5, 1300/AC5, 1445/AC1, 1929/AC1
b := startBed(t)
key := b.candidate(t, "feat/x", pull(repo, 7, head1))
b.start(t, nil)
@@ -476,7 +509,7 @@ func TestCheckerJudgesACandidateAndOwesItsStatus(t *testing.T) { // N6/AC5, 1300
// TestACheckOfEqualInputsRunsNothing: a second candidate (another head, the same digests) is checked with every package
// taken from the first's kept verdicts: no test step runs and no second kept key appears.
func TestACheckOfEqualInputsRunsNothing(t *testing.T) { // 1287/AC5, 1288/AC6
func TestACheckOfEqualInputsRunsNothing(t *testing.T) { // 1287/AC5, 1288/AC6, 1929/AC2
b := startBed(t)
first := b.candidate(t, "feat/x", pull(repo, 7, head1))
b.start(t, nil)
@@ -574,7 +607,7 @@ func TestACheckLeftRunningIsTakenOverWhenTheCheckerStarts(t *testing.T) { // N6/
// TestAMemberNamingAMeshSDKCommitOffTheTrunkFailsNamingTheCommit: the go.mod the forge answers names a commit that main does not
// hold; the verdict fails naming the member and the commit and no test runs (ADR 0484 clause 9).
func TestAMemberNamingAMeshSDKCommitOffTheTrunkFailsNamingTheCommit(t *testing.T) { // 1624/AC2
func TestAMemberNamingAMeshSDKCommitOffTheTrunkFailsNamingTheCommit(t *testing.T) { // 1624/AC2, 1929/AC3
b := startBed(t)
b.stalePin.Store(true)
key := b.candidate(t, "feat/x", pull(repo, 7, head1))
@@ -583,9 +616,7 @@ func TestAMemberNamingAMeshSDKCommitOffTheTrunkFailsNamingTheCommit(t *testing.T
if v.State != delivery.VerdictFail || !strings.Contains(v.Failed.Words, "9aaa5c80ac5c") || !strings.Contains(v.Failed.Words, repo+"#7") {
t.Fatalf("verdict = %+v", v)
}
if n := b.fake.count("mesh-build-go.build_go_test"); n != 0 {
t.Errorf("%d test steps ran after a stale pin", n)
}
// 1929/AC3: no assertion that no test ran: one ask runs fetch, digest and tests (ADR 0525 §3).
}
// triesStore is the tries bucket as the tools write it.
@@ -671,7 +702,7 @@ func TestTheCheckerSaysOwedReadableOnTheBus(t *testing.T) { // R2-1
break
}
if said.Module != "mesh-delivery-checker" || said.Node != "n1" || !said.Keeps("owed") || !said.Readable("owed") ||
!said.Readable("previews") || !said.Readable("verdicts") || said.Readable("kept") || said.Readable("tries") || len(said.States) != 5 {
t.Fatalf("the checker said %+v, want its five states with owed, previews and verdicts readable", said)
!said.Readable("previews") || !said.Readable("verdicts") || !said.Readable("kept") || said.Readable("tries") || len(said.States) != 5 {
t.Fatalf("the checker said %+v, want its five states with kept, owed, previews and verdicts readable (1929/AC4; 1887 A5b)", said)
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ module mesh-delivery-checker
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023213200-403a5d72ab93
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0
)
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468 h1:UcMee+IV2ii5E4dRZdQUaSaeh9IedTFYumuQbBiB0k0=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023213200-403a5d72ab93 h1:FAiFY2KBKEoZSH49OZTt7/zRBIQxyl5g/KhYrpggbN4=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023213200-403a5d72ab93/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
@@ -26,7 +26,7 @@ const (
// checks/deps). Which commit of each is besideCommit's rule.
var besideRules = map[string][]string{
controllerRepo: {catalogueRepository, nodeEngineRepo, SDKRepository},
nodeEngineRepo: {catalogueRepository, SDKRepository},
nodeEngineRepo: {catalogueRepository, SDKRepository, controllerRepo},
SDKRepository: {catalogueRepository, nodeEngineRepo},
catalogueRepository: {SDKRepository, controllerRepo},
"novox/mesh-delivery": {SDKRepository, catalogueRepository},
@@ -0,0 +1,83 @@
package check
import (
"encoding/json"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// 1930/AC3: given a check ask whose stored end keeps the digest's packages as a list of {package, digest} (issue 1930),
// or one stored before it with the packages a map keyed by package path, when the same heads are checked a second time,
// then the kept verdicts the first check wrote are read from it: every package it passed is kept, none run again.
func TestKeptVerdictsAreReadFromTheDigestsListForm(t *testing.T) {
for _, tc := range []struct {
name string
old bool
}{{"the list form", false}, {"a map stored before issue 1930", true}} {
t.Run(tc.name, func(t *testing.T) {
b := newBench(t)
var stored []string
b.asks.Answer = func(a delivery.StepAsk) delivery.Step {
s := b.answer(a)
if tc.old && s.State == delivery.StepPassed {
s.Result = oldDigestForm(t, s.Result)
}
stored = append(stored, string(s.Result))
return s
}
first := b.judge(t, caseOf(t, pullOf(repoA, 7, head1))).Verdict
u := first.Levels[delivery.LevelUnit]
if first.State != delivery.VerdictPass || u.Run == 0 || u.Kept != 0 {
t.Fatalf("the first check = %+v", first)
}
if len(stored) == 0 {
t.Fatal("no ask's end was stored")
}
list := strings.Contains(stored[0], `"packages":[{"package":"fixture/slow"`)
if list == tc.old || strings.Contains(stored[0], `"fixture/slow":"`) != tc.old {
t.Fatalf("the stored end is not in the form under test: %s", stored[0])
}
b.steps.calls = nil
second := b.judge(t, caseOf(t, pullOf(repoA, 7, head1))).Verdict
if s := second.Levels[delivery.LevelUnit]; second.State != delivery.VerdictPass || s.Kept != u.Run || s.Run != 0 {
t.Fatalf("the second check's unit level = %+v, want %d kept and none run", s, u.Run)
}
if n := len(b.steps.tool("test")); n != 0 {
t.Errorf("%d test steps ran on the second check", n)
}
})
}
}
// oldDigestForm rewrites a check answer's level digests to the form stored before issue 1930: packages as a map keyed by
// package path.
func oldDigestForm(t *testing.T, result json.RawMessage) json.RawMessage {
t.Helper()
var a struct {
Fetch json.RawMessage `json:"fetch"`
Levels []map[string]any `json:"levels"`
}
if err := json.Unmarshal(result, &a); err != nil {
t.Fatal(err)
}
for _, l := range a.Levels {
d, ok := l["digest"].(map[string]any)
if !ok {
continue
}
m := map[string]any{}
list, _ := d["packages"].([]any)
for _, p := range list {
pd := p.(map[string]any)
m[pd["package"].(string)] = pd["digest"]
}
d["packages"] = m
}
raw, err := json.Marshal(a)
if err != nil {
t.Fatal(err)
}
return raw
}
@@ -0,0 +1,73 @@
package check
import (
"encoding/json"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// The failure the build module reported for group r4g on 2026-10-23: the test logged a line, then failed with a multi-line
// t.Fatalf. Its output is what the build module now sends (its last lines, with the error line as Cause).
func fatalAfterLog() Failure {
return Failure{
Package: "github.com/novox/mesh-controller/internal/broker", Test: "TestTheCataloguesDerivedSubjectsMeet",
Cause: "3 subscription(s) derive to a subject no emitter owns:",
Output: " agreement_catalogue_test.go:130: judged against /var/lib/mesh-build-go-work/id/beside/mesh-catalog as cloned\n" +
" agreement_catalogue_test.go:186: 3 subscription(s) derive to a subject no emitter owns:\n" +
" a.b.c\n d.e.f\n g.h.i\n--- FAIL: TestTheCataloguesDerivedSubjectsMeet (0.00s)",
}
}
func TestAFailedTestsVerdictKeepsItsWholeFailureMessage(t *testing.T) { // 1901/AC1
v := levelFailure(&target{}, "unit", Tested{Failed: []Failure{fatalAfterLog()}})
for _, want := range []string{"3 subscription(s) derive to a subject no emitter owns:", "a.b.c", "d.e.f", "g.h.i"} {
if !strings.Contains(v.Failed.Words, want) {
t.Errorf("words lack %q: %q", want, v.Failed.Words)
}
}
if len(v.Failed.Words) > MaxWords+200 {
t.Errorf("words are %d bytes, bound %d", len(v.Failed.Words), MaxWords)
}
}
func TestTheStatusLineNamesTheFailureNotTheFirstLoggedLine(t *testing.T) { // 1901/AC2
v := levelFailure(&target{}, "unit", Tested{Failed: []Failure{fatalAfterLog()}})
got := firstFailed(*v)
if !strings.Contains(got, "3 subscription(s) derive to a subject no emitter owns:") || strings.Contains(got, "judged against") || strings.Contains(got, "\n") {
t.Errorf("firstFailed = %q", got)
}
// With no cause told apart (an older build module), the words still start at the first line, as before.
f := fatalAfterLog()
f.Cause = ""
v = levelFailure(&target{}, "unit", Tested{Failed: []Failure{f}})
if !strings.HasPrefix(v.Failed.Words, "agreement_catalogue_test.go:130") {
t.Errorf("words = %q", v.Failed.Words)
}
}
func TestWordsLongerThanTheBoundKeepTheirLastLines(t *testing.T) { // 1901/AC1
f := Failure{Output: strings.Repeat("log line of some length\n", 500) + "the cause is here"}
v := levelFailure(&target{}, "unit", Tested{Failed: []Failure{f}})
if !strings.HasSuffix(v.Failed.Words, "the cause is here") || len(v.Failed.Words) > MaxWords+200 {
t.Errorf("%d bytes, ends %q", len(v.Failed.Words), v.Failed.Words[len(v.Failed.Words)-30:])
}
_ = delivery.VerdictFail
}
// An older build module sends no "cause": its answer is still accepted and the verdict's words are its output, as before.
func TestAnOlderBuildModulesFailureIsAcceptedWithItsWholeOutput(t *testing.T) { // 1901/AC3
old := `{"package":"p","test":"TestX","output":"x_test.go:1: logged\nx_test.go:2: the old kept lines"}`
var f Failure
if err := json.Unmarshal([]byte(old), &f); err != nil || f.Cause != "" {
t.Fatalf("1901/AC3: err = %v, cause = %q", err, f.Cause)
}
v := levelFailure(&target{}, "unit", Tested{Failed: []Failure{f}})
if v.Failed.Words != "x_test.go:1: logged\nx_test.go:2: the old kept lines" {
t.Errorf("1901/AC3: words = %q", v.Failed.Words)
}
if got := firstFailed(*v); !strings.Contains(got, "logged") || strings.Contains(got, "\n") {
t.Errorf("1901/AC3: status = %q", got)
}
}
@@ -447,13 +447,11 @@ func (st *state) level(ctx context.Context, t *target, la delivery.LevelAnswer)
return fail(t.node, fmt.Sprintf("the check of %s#%d answered the level %q, which is no level", t.pull.Repository, t.pull.Number, level)), 0
}
var digests map[string]string
if t.module == buildrun.GoBuildModule && len(la.Digest) > 0 {
var d Digested
if err := json.Unmarshal(la.Digest, &d); err == nil {
digests = d.Packages
if t.runner != "" {
t.judgedBy(d.JudgedBy) // the installed module judged it: the runner is not used again
}
if t.module == buildrun.GoBuildModule && la.Digest != nil {
// The packages come as a list, never keyed by a package path (issue 1930); an answer stored before reads too.
digests = la.Digest.Map()
if t.runner != "" {
t.judgedBy(la.Digest.JudgedBy) // the installed module judged it: the runner is not used again
}
}
if t.runner != "" && la.Judged != "" {
@@ -525,13 +523,35 @@ func failedName(f Failure) string {
return f.Package + "." + f.Test
}
// firstLine is the first non-empty line of a failure's output, shortened.
// MaxWords is the most a failed test's words in a verdict hold (issue 1901). **The words keep the failure's last lines**,
// where a test says why it failed: the first line is often something it logged before (the group r4g verdict kept a
// "judged against" line and lost the t.Fatalf after it), and the 120 characters kept before cut a docker command's echo
// before docker's own error. Four kilobytes holds a long message and is small beside a verdict's other parts.
const MaxWords = 4096
// failureWords is a failure's words: its output's last lines up to MaxWords, with the line the build module told apart as
// the test's error first when the output does not start with it, so the first line of the words is the cause and the
// status line, which is cut short, names it.
func failureWords(f Failure) string {
out := strings.TrimSpace(f.Output)
if len(out) > MaxWords {
out = out[len(out)-MaxWords:]
// Start on a whole line, and a whole character.
if i := strings.IndexByte(out, '\n'); i >= 0 {
out = "... " + strings.TrimLeft(out[i:], "\n")
}
out = strings.ToValidUTF8(out, "")
}
if c := strings.TrimSpace(f.Cause); c != "" && !strings.HasPrefix(firstLine(out), c) {
out = c + "\n" + out
}
return out
}
// firstLine is the first non-empty line of a failure's words.
func firstLine(s string) string {
for _, l := range strings.Split(s, "\n") {
if l = strings.TrimSpace(l); l != "" {
if len(l) > 120 {
l = l[:120]
}
return l
}
}
@@ -548,7 +568,7 @@ func levelFailure(t *target, level string, res Tested) *delivery.Verdict {
if t.module == buildrun.NodeBuildModule && f.Package == "" {
name = res.Module
}
v.Failed = delivery.FailedFirst{Test: level + ": " + name, Words: firstLine(f.Output)}
v.Failed = delivery.FailedFirst{Test: level + ": " + name, Words: failureWords(f)}
default:
why := res.Why
if why == "" {
@@ -37,18 +37,21 @@ func firstFailed(v delivery.Verdict) string {
f := v.Failed
switch {
case f.Test != "":
if f.Words != "" {
return f.Test + ": " + f.Words
if w := firstWordsLine(f.Words); w != "" {
return f.Test + ": " + w
}
return f.Test
case f.Machine != "" && f.Words != "":
return f.Machine + ": " + f.Words
case f.Machine != "" && firstWordsLine(f.Words) != "":
return f.Machine + ": " + firstWordsLine(f.Words)
case f.Machine != "":
return f.Machine
}
return f.Words
return firstWordsLine(f.Words)
}
// firstWordsLine is the first line of a failure's words: the words are several lines (issue 1901) and the status line one.
func firstWordsLine(w string) string { return firstLine(w) }
// pieces are the parts of a line, from the most to the least needed.
type pieces struct {
head, commits, counts, shortCounts, times, shortTimes, over, builds, composition, shortComposition, group string
@@ -244,6 +244,39 @@ func TestTheSiblingsAreWhereTheTestsStateThem(t *testing.T) { // review D2, ADR
}
}
// 1295/R4g: the node-engine's tests read the controller's manifest, so a mesh-host pull request has mesh-controller beside it:
// at the group member's head when the group holds one, else at its trunk.
func hostCase(t *testing.T, pulls ...delivery.CandidatePull) *Case {
t.Helper()
c, err := delivery.DecodeCandidate(candidateOf(t, "feat/x", pulls...))
if err != nil {
t.Fatal(err)
}
cs := CaseOf(c, map[string]string{"novox/mesh-host": trunkA, controllerRepo: trunkA2})
if cs == nil {
t.Fatal("no case")
}
return cs
}
func TestTheNodeEnginesTestsHaveTheControllerBesideThem(t *testing.T) {
const host = "novox/mesh-host"
b := newBench(t)
b.ex.Trunks = trunksOf(t, map[string]string{host: trunkA, controllerRepo: trunkA2, catalogueRepository: trunkS, SDKRepository: trunkS})
b.judge(t, hostCase(t, pullOf(host, 5, head1)))
if c := besideOf(t, b, host)[controllerRepo]; c.Commit != trunkA2 || c.Ref != "" {
t.Errorf("the controller beside the node-engine alone = %+v, want its trunk %s", c, trunkA2)
}
b = newBench(t)
b.ex.Trunks = trunksOf(t, map[string]string{host: trunkA, controllerRepo: trunkA2, catalogueRepository: trunkS, SDKRepository: trunkS})
b.ex.Forge = &fakeForge{goMods: map[string]string{controllerRepo + " go.mod " + head2: capturedGoMod(t, "cb54756c3865")},
onTrunk: map[string]bool{"cb54756c3865": true}}
b.judge(t, hostCase(t, pullOf(host, 5, head1), pullOf(controllerRepo, 9, head2)))
if c := besideOf(t, b, host)[controllerRepo]; c.Commit != head2 || c.Ref != "refs/pull/9/head" {
t.Errorf("the controller beside the node-engine in a group = %+v, want the member's head %s", c, head2)
}
}
func TestThePinOfTheControllerThatCannotBeReadFailsTheCheck(t *testing.T) { // review D2
b := newBench(t)
b.ex.Trunks = trunksOf(t, map[string]string{"novox/mesh-controller": trunkA})
@@ -59,6 +59,8 @@ type Failure struct {
Package string `json:"package"`
Test string `json:"test,omitempty"`
Output string `json:"output,omitempty"`
// Cause is the first line of what the test reported as its error, when the build module told it apart (issue 1901).
Cause string `json:"cause,omitempty"`
}
// TestTime is one test and the seconds it took.
@@ -478,3 +478,74 @@ func TestAMergeOfMeshSDKChangingOnlyGoLeavesItsNamedTreeEqual(t *testing.T) {
t.Errorf("the new form moved: %q/%q, then %q/%q", first.FP2, first.None2, second.FP2, second.None2)
}
}
// TestIndexerJudgesAnUnreadableVerdictAgainAtStart: the modules bucket holds avahi as an older SDK wrote it, with the reason
// 'the manifest cannot be read', the inputs the merge carried (the tree, the lock hash by file name) and the digest of its
// manifest, which manifests.<digest> holds and the running SDK reads; the go toolchain is reported. Within 5 s of the start
// and with no repository merge the value carries a fingerprint and no reason (issue 1923).
func TestIndexerJudgesAnUnreadableVerdictAgainAtStart(t *testing.T) { // 1923/AC3
manifest, err := os.ReadFile("../../internal/index/testdata/catalogue-a67f0196-avahi.module.json")
if err != nil {
t.Fatal(err)
}
const goSum = "3b5028f0c1e9bde04dd766abd63eb5931bc516bae695cf46f00abcd8510b0381"
digest := delivery.ManifestDigest(manifest)
_, ctx, _, modules := startIndexer(t, func(context.Context, jetstream.KeyValue) {}, func(ctx context.Context, js jetstream.JetStream) {
mkey, err := delivery.ManifestsKey(digest)
if err != nil {
t.Fatal(err)
}
mdata, err := delivery.EncodeManifest(delivery.Manifest{Module: "avahi", JSON: string(manifest)})
if err != nil {
t.Fatal(err)
}
manifests, err := js.KeyValue(ctx, delivery.ManifestsBucket)
if err != nil {
t.Fatal(err)
}
if _, err := manifests.Put(ctx, mkey, mdata); err != nil {
t.Fatal(err)
}
stale, err := delivery.EncodeModule(delivery.Module{Repository: "novox/mesh-catalog", Commit: "89abcdef0123456789abcdef0123456789abcdef", Path: "modules/avahi",
Manifest: digest, None2: "the manifest cannot be read: claims node-build-go, which is a seat in the mesh's own namespace that it does not define",
Inputs2: &delivery.KeptInputs2{Tree: "tree-avahi", Locks: map[string]string{"go.sum": goSum}}})
if err != nil {
t.Fatal(err)
}
held, err := js.KeyValue(ctx, delivery.ModulesBucket)
if err != nil {
t.Fatal(err)
}
if _, err := held.Put(ctx, "avahi", stale); err != nil {
t.Fatal(err)
}
kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: delivery.ToolchainsBucket("mesh-build-go"), History: 1})
if err != nil {
t.Fatal(err)
}
key, err := delivery.ToolchainKey("n1", "go")
if err != nil {
t.Fatal(err)
}
value, err := delivery.EncodeToolchain(delivery.Toolchain{Language: "go", Version: "go1.27.1", Recipe: "1", At: time.Now()})
if err != nil {
t.Fatal(err)
}
if _, err := kv.Put(ctx, key, value); err != nil {
t.Fatal(err)
}
})
var got delivery.Module
for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
if e, err := modules.Get(ctx, "avahi"); err == nil {
if m, err := delivery.DecodeModule(e.Value()); err == nil {
if got = m; got.FP2 != "" {
break
}
}
}
}
if got.FP2 == "" || got.None2 != "" {
t.Errorf("avahi = fp2 %q none2 %q within 5 s of the start, want a fingerprint and no reason", got.FP2, got.None2)
}
}
@@ -2,6 +2,9 @@
// bridge's messages on the stream FORGE and keeps the candidate groups (candidates.<group>): the pull requests that share
// a branch, their heads and the modules each touches. It reads the controller's builds to know which module a changed
// directory is. It serves no request subject and holds no timer: every reaction is a message or a watch (ADR 0437 §8).
//
// Its build of 2026-10-24 00:37 named a program whose push never reached the artifact store, and a retry sends that
// same build again; this paragraph was added only to give the indexer a new build (issue 1933).
package main
import (
@@ -288,7 +291,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
}
defer calls.Close()
ix := &index.Indexer{Store: kvStore{candidates}, Modules: kvStore{modules}, Manifests: kvStore{manifests}, Builds: known, Logf: logger.Printf,
Named: namedVia(calls.Call)}
Named: namedVia(calls.Call), Trunk: trunkVia(calls.Call), Contains: containsVia(calls.Call)}
// The gate's source bucket (mesh-delivery-gate_source) comes with the gate (release 3e, novox/hq ADR 0450 §1): its key
// main is the mesh's source revision, which stamps the modules (issue 1344). Where it does not exist yet the indexer does
// not wait for it (that needs a timer, ADR 0437 §8): it says so, and is started again with the gate.
@@ -325,6 +328,10 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
if err := ix.RecomputeAtStart(ctx); err != nil {
logger.Printf("computing the new-form fingerprints at start: %v", err)
}
// The modules the trunk no longer holds go from the bucket (issue 1900); a merge does the same from its own message.
if err := ix.Reconcile(ctx); err != nil {
logger.Printf("dropping the modules the trunk does not hold at start: %v", err)
}
// A build module says each time it wrote its toolchains key: its stream, and the indexer's durable consumer on it, are
// made by the controller as the forge's are. Where the stream is not there yet the indexer says so and is started again
// with the build module, as with the gate's source.
@@ -46,3 +46,50 @@ func namedVia(call toolCaller) func(ctx context.Context, repository, sha string,
return a.Paths, nil
}
}
// trunkVia asks the forge for the module directories of a commit (issue 1900): the keys of the `trees` the same tool
// answers without paths, one per directory that holds a module.json (the root as ""; the tool lists no other).
func trunkVia(call toolCaller) func(ctx context.Context, repository, sha string) ([]string, error) {
return func(_ context.Context, repository, sha string) ([]string, error) {
owner, repo, ok := strings.Cut(delivery.RepositoryName(repository), "/")
if !ok {
return nil, fmt.Errorf("%q is not <owner>/<name>", repository)
}
raw, err := call(commitTreesTool, map[string]any{"owner": owner, "repo": repo, "sha": sha})
if err != nil {
return nil, err
}
var a struct {
Trees map[string]string `json:"trees"`
}
if err := json.Unmarshal(raw, &a); err != nil {
return nil, fmt.Errorf("the answer of %s is not {root, trees}: %w", commitTreesTool, err)
}
dirs := make([]string, 0, len(a.Trees))
for dir := range a.Trees {
dirs = append(dirs, dir)
}
return dirs, nil
}
}
// containsVia asks the forge whether a commit is in another (gitea.gitea_contains: contains is true for the same commit or an ancestor).
func containsVia(call toolCaller) func(ctx context.Context, repository, commit, in string) (bool, error) {
return func(_ context.Context, repository, commit, in string) (bool, error) {
owner, repo, ok := strings.Cut(delivery.RepositoryName(repository), "/")
if !ok {
return false, fmt.Errorf("%q is not <owner>/<name>", repository)
}
raw, err := call("gitea.gitea_contains", map[string]any{"owner": owner, "repo": repo, "commit": commit, "in": in})
if err != nil {
return false, err
}
var a struct {
Contains *bool `json:"contains"`
}
if err := json.Unmarshal(raw, &a); err != nil || a.Contains == nil {
return false, fmt.Errorf("the answer of gitea.gitea_contains names no contains: %v", err)
}
return *a.Contains, nil
}
}
@@ -3,6 +3,8 @@ package main
import (
"context"
"encoding/json"
"slices"
"strings"
"testing"
)
@@ -29,3 +31,22 @@ func TestNamedViaAsksTheTreesToolWithThePathsAndTakesTheirObjects(t *testing.T)
t.Error("an answer missing a path asked was taken")
}
}
func TestTheTrunkIsTheDirectoriesTheForgeListsTrees(t *testing.T) { // 1900/AC3
var gotArgs any
call := func(key string, body any) (json.RawMessage, error) {
gotArgs = body
return json.RawMessage(`{"root":"r","trees":{"modules/a":"x","modules/b":"y"}}`), nil
}
dirs, err := trunkVia(call)(context.Background(), "novox/mesh-catalog", strings.Repeat("a", 40))
if err != nil {
t.Fatal(err)
}
slices.Sort(dirs)
if !slices.Equal(dirs, []string{"modules/a", "modules/b"}) {
t.Errorf("dirs = %v", dirs)
}
if _, has := gotArgs.(map[string]any)["paths"]; has {
t.Error("the trunk asked for paths")
}
}
@@ -52,7 +52,10 @@ type Indexer struct {
// manifest's paths (ADR 0495 clause 1, the lead's decision L8 of R4e). Nil asks nothing, and a module naming paths
// has none.
Named func(ctx context.Context, repository, sha string, paths map[string][]string) (map[string]map[string]string, error)
Logf func(format string, args ...any)
Trunk func(ctx context.Context, repository, sha string) ([]string, error)
// Contains says whether a commit is the second or an ancestor of it (gitea.gitea_contains).
Contains func(ctx context.Context, repository, commit, in string) (bool, error)
Logf func(format string, args ...any)
mu sync.Mutex // guards source, toolchains and previews, which the watches and the forge messages all reach
source sourceNow
@@ -69,7 +69,7 @@ func inputsOf(module, raw, tree string, locks map[string]string, base baseFP) mo
func inputsOfNamed(module, raw, tree string, locks, objects map[string]string, base baseFP) moduleInputs {
m, err := catalogue.ParseManifestForInputs([]byte(raw))
if err != nil {
return moduleInputs{None2: "the manifest cannot be read: " + err.Error()}
return moduleInputs{None2: None2Unreadable + err.Error()}
}
if m.Module != module {
return moduleInputs{None2: fmt.Sprintf("the manifest is of module %q, not %q", m.Module, module)}
@@ -111,7 +111,8 @@ func TestABaseEntersByTheFingerprintOfItsModuleOrNamesItsAbsence(t *testing.T) {
}
// Through the indexer: modules.mesh-tools holds a fingerprint of the new form (a record written by a later release).
ix, mods, _ := manifestsIndexer(t, rec(t, "build-1", "avahi", catalogueRepo, "modules/avahi", "main", "", "2026-10-19T09:00:00Z", aInputs))
mods.put("mesh-tools", mustEncode(t, delivery.Module{Repository: catalogueRepo, Commit: merge, None: "x", FP2: fp, Manifest: strings.Repeat("a", 64)}))
mods.put("mesh-tools", mustEncode(t, delivery.Module{Repository: "novox/mesh-tools", // a base held of another repository stays when the catalogue merges (issue 1900)
Commit: merge, None: "x", FP2: fp, Manifest: strings.Repeat("a", 64)}))
f := withManifests(mergedMsg(catalogueRepo, map[string]string{"modules/avahi": "t"}), map[string]string{"modules/avahi": aModuleWithABase(t)}, nil)
if err := ix.Apply(context.Background(), f); err != nil {
t.Fatal(err)
@@ -2,8 +2,10 @@ package index
import (
"context"
"fmt"
"maps"
"slices"
"strings"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
@@ -33,18 +35,22 @@ func (ix *Indexer) applyMerged(ctx context.Context, f delivery.Forge) error {
objects, namedErr := ix.namedObjects(ctx, f)
overlay := map[string]delivery.Module{}
dirOf := map[string]string{}
named, every := map[string]bool{}, true // every module the message's manifests name, and whether each names one
for _, dir := range slices.Sorted(maps.Keys(f.Manifests)) {
raw := f.Manifests[dir]
name := manifestModule(raw)
if name == "" {
ix.logf("%s at %s: the manifest in %q names no module, so no module is written for it", f.Repository, f.Merge, dir)
every = false
continue
}
key, err := delivery.ModulesKey(name)
if err != nil {
every = false
ix.logf("%s: the module %q has no usable key, skipped: %v", f.Repository, name, err)
continue
}
named[key] = true
cur, has := held[key]
if has && cur.Repository != "" && !delivery.SameRepository(cur.Repository, f.Repository) {
ix.logf("%s at %s: %q names the module %s, which the indexer holds of %s; left as it is (a move between repositories is the operator's)", f.Repository, f.Merge, dir, name, cur.Repository)
@@ -64,12 +70,24 @@ func (ix *Indexer) applyMerged(ctx context.Context, f delivery.Forge) error {
} else {
in := inputsOfNamed(name, raw, f.Trees[dir], f.Locks[dir], objects[dir], ix.baseFP(ctx))
next.Inputs2, next.None2, next.ArtifactPaths = in.Kept, in.None2, in.ArtifactPaths
if in.Kept == nil && strings.HasPrefix(in.None2, None2Unreadable) {
// The manifest cannot be read by this SDK; the inputs the merge carried are kept all the same, so a later SDK
// that reads it judges the module again at start with no merge (issue 1923).
next.Inputs2 = unreadInputs(f.Trees[dir], f.Locks[dir], f.Paths[dir])
}
if in.Kept == nil && namedErr[dir] != "" {
next.None2 = in.None2 + ": the forge could not say them: " + namedErr[dir]
}
}
overlay[key] = next
}
// A module of this repository the merge commit holds no module.json for is gone from the trunk, and so is its key
// (issue 1900). A message that carries no manifests (an older writer) or one that cannot be read whole says nothing.
if len(f.Manifests) > 0 && every {
if err := ix.dropRemoved(ctx, f.Repository, held, func(key string, _ delivery.Module) bool { return named[key] }); err != nil {
return err
}
}
// What this merge moved reaches the modules standing on it (a base's tree, build-agent's fingerprint), in the graph's order.
return ix.recomputeWith(ctx, overlay)
}
@@ -149,3 +167,115 @@ func (ix *Indexer) namedObjects(ctx context.Context, f delivery.Forge) (map[stri
}
return objects, nil
}
// dropRemoved deletes, from the modules the bucket holds of the repository, each one the trunk no longer holds (issue 1900):
// held(key, module) says whether the trunk holds it. A module of another repository, or of none known, is never touched.
func (ix *Indexer) dropRemoved(ctx context.Context, repository string, held map[string]delivery.Module, trunkHolds func(key string, m delivery.Module) bool) error {
for _, key := range slices.Sorted(maps.Keys(held)) {
cur := held[key]
if cur.Repository == "" || !delivery.SameRepository(cur.Repository, repository) || trunkHolds(key, cur) {
continue
}
err := write(ctx, ix.Modules, key, func(value []byte, found bool) ([]byte, bool, error) {
if !found {
return nil, false, nil
}
if m, err := delivery.DecodeModule(value); err != nil || !delivery.SameRepository(m.Repository, repository) {
return nil, false, nil
}
return nil, true, nil
})
if err != nil {
return fmt.Errorf("modules.%s: %w", key, err)
}
ix.logf("modules.%s deleted: %s holds no module of that name at its trunk (issue 1900)", key, repository)
}
return nil
}
// Reconcile deletes the modules keys whose module the trunk does not hold, for each repository the latest source names a
// head of: the module directories at that head are asked of the forge (Trunk), and a module of the repository whose
// directory is not among them goes (issue 1900). It is run at the indexer's start; a merge does the same from its own
// message (applyMerged). A repository whose trunk cannot be read, or reads as holding no module at all, is left as it is
// and said: a failure never empties the bucket.
func (ix *Indexer) Reconcile(ctx context.Context) error {
if ix.Modules == nil || ix.Trunk == nil {
return nil
}
ix.mu.Lock()
heads := maps.Clone(ix.source.heads)
ix.mu.Unlock()
held, err := ix.heldModules(ctx)
if err != nil {
return err
}
for _, repository := range slices.Sorted(maps.Keys(heads)) {
mine := map[string]delivery.Module{}
for key, m := range held {
if m.Repository != "" && delivery.SameRepository(m.Repository, repository) {
mine[key] = m
}
}
if len(mine) == 0 {
continue
}
dirs, err := ix.Trunk(ctx, repository, heads[repository])
if err != nil {
ix.logf("%s at %s: the modules of the trunk cannot be read, so none is dropped: %v", repository, heads[repository], err)
continue
}
if len(dirs) == 0 {
ix.logf("%s at %s: the trunk reads as holding no module, so none is dropped", repository, heads[repository])
continue
}
there := map[string]bool{}
for _, d := range dirs {
there[pathOf(strings.Trim(d, "/"))] = true
}
head := heads[repository]
// The source can lag a merge already applied: a key written at a commit the head read does not hold (a merge heard
// first) is not read as removed, only one whose commit the head is, or contains.
stays := func(_ string, m delivery.Module) bool {
if m.Path == "" || there[m.Path] {
return true
}
if m.Commit == head {
return false
}
if m.Commit == "" || ix.Contains == nil {
return true
}
in, err := ix.Contains(ctx, repository, m.Commit, head)
if err != nil {
ix.logf("%s: whether %s is in %s cannot be read, so the module at %s is kept: %v", repository, m.Commit, head, m.Path, err)
return true
}
return !in
}
if err := ix.dropRemoved(ctx, repository, mine, stays); err != nil {
return err
}
}
return nil
}
// None2Unreadable begins the reason of a module whose manifest the running SDK cannot read.
const None2Unreadable = "the manifest cannot be read: "
// unreadInputs are the inputs a merge carried for a module whose manifest cannot be read: the directory's tree, the lock
// hashes by file name (the artifacts that own them are not known without the manifest) and the objects of the named
// paths the message carried; nil when the message carried no tree. Judging the manifest again (rejudged) turns them into
// the inputs of the module (issue 1923).
func unreadInputs(tree string, locks, paths map[string]string) *delivery.KeptInputs2 {
if tree == "" {
return nil
}
in := &delivery.KeptInputs2{Tree: tree}
if len(locks) > 0 {
in.Locks = maps.Clone(locks)
}
if len(paths) > 0 {
in.Paths = maps.Clone(paths)
}
return in
}
@@ -0,0 +1,144 @@
package index
import (
"context"
"errors"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
func twoModules(t *testing.T) (*Indexer, *memStore) {
t.Helper()
ix, mods := modulesIndexer(t)
if err := ix.Apply(context.Background(), mergedMsg(catalogueRepo, map[string]string{"modules/a": "t", "modules/b": "t"})); err != nil {
t.Fatal(err)
}
for _, k := range []string{"a", "b"} {
if _, ok := mods.vals[k]; !ok {
t.Fatalf("setup: modules.%s not written", k)
}
}
return ix, mods
}
func TestAMergeThatRemovesAModuleDeletesItsKey(t *testing.T) { // 1900/AC1
ix, mods := twoModules(t)
if err := ix.Apply(context.Background(), mergedMsg(catalogueRepo, map[string]string{"modules/a": "t2"})); err != nil {
t.Fatal(err)
}
if _, ok := mods.vals["b"]; ok {
t.Error("modules.b is still held after a merge whose trunk has no module b")
}
if _, ok := mods.vals["a"]; !ok {
t.Error("modules.a, which the trunk still holds, was deleted")
}
}
func TestAMergeLeavesTheModulesOfAnotherRepository(t *testing.T) { // 1900/AC1 (scope)
ix, mods := twoModules(t)
if err := ix.Apply(context.Background(), mergedMsg("novox/mesh-delivery", map[string]string{"modules/c": "t"})); err != nil {
t.Fatal(err)
}
if _, ok := mods.vals["a"]; !ok {
t.Error("a merge of another repository deleted modules.a")
}
if _, ok := mods.vals["b"]; !ok {
t.Error("a merge of another repository deleted modules.b")
}
}
func TestAMergeWithoutManifestsDeletesNothing(t *testing.T) { // 1900/AC1: older writers send none, which does not say the trunk is empty
ix, mods := twoModules(t)
f := mergedMsg(catalogueRepo, map[string]string{"modules/a": "t2"})
f.Manifests = nil
if err := ix.Apply(context.Background(), f); err != nil {
t.Fatal(err)
}
if _, ok := mods.vals["b"]; !ok {
t.Error("a merge message with no manifests deleted modules.b")
}
}
func TestAPathWithoutAModuleJSONIsNeverAModule(t *testing.T) { // 1900/AC2: testdata such as fixture-build
ix, mods := twoModules(t)
f := mergedMsg(catalogueRepo, map[string]string{"modules/a": "t2"})
f.Trees["testdata/fixture-build"] = "t" // a directory in the tree whose module.json the message does not carry
if err := ix.Apply(context.Background(), f); err != nil {
t.Fatal(err)
}
if _, ok := mods.vals["fixture-build"]; ok {
t.Error("modules.fixture-build written for a path without a module.json")
}
}
func TestStartingDeletesTheKeysTheTrunkDoesNotHold(t *testing.T) { // 1900/AC3
ix, mods := twoModules(t)
ctx := context.Background()
if err := ix.ApplySource(ctx, 3, sourceOf(catalogueRepo, merge)); err != nil {
t.Fatal(err)
}
var asked string
ix.Trunk = func(_ context.Context, repository, sha string) ([]string, error) {
asked = repository + "@" + sha
return []string{"modules/a"}, nil
}
ix.Contains = func(context.Context, string, string, string) (bool, error) { return true, nil }
if err := ix.Reconcile(ctx); err != nil {
t.Fatal(err)
}
if asked != catalogueRepo+"@"+merge {
t.Errorf("the trunk was asked as %q, want the source's head of the repository", asked)
}
if _, ok := mods.vals["b"]; ok {
t.Error("modules.b is still held though the trunk holds no module at modules/b")
}
if _, ok := mods.vals["a"]; !ok {
t.Error("modules.a was deleted")
}
}
func TestStartingDeletesNothingWhenTheTrunkCannotBeRead(t *testing.T) { // 1900/AC3: failure is loud, never a mass delete
ix, mods := twoModules(t)
ctx := context.Background()
if err := ix.ApplySource(ctx, 3, sourceOf(catalogueRepo, merge)); err != nil {
t.Fatal(err)
}
for name, trunk := range map[string]func(context.Context, string, string) ([]string, error){
"an error": func(context.Context, string, string) ([]string, error) { return nil, errors.New("forge down") },
"no module at all": func(context.Context, string, string) ([]string, error) { return nil, nil },
} {
ix.Trunk = trunk
_ = ix.Reconcile(ctx)
if len(mods.vals) != 2 {
t.Errorf("%s: %d keys held, want 2 untouched", name, len(mods.vals))
}
}
_ = delivery.RootPath
}
func TestAKeyWrittenAheadOfTheSourceHeadStaysAndOneWithoutAPathIsSkipped(t *testing.T) { // 1900/AC3 (review): the source lags a merge already applied
ix, mods := twoModules(t) // written at the merge commit C
ctx := context.Background()
const older = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
if err := ix.ApplySource(ctx, 3, sourceOf(catalogueRepo, older)); err != nil {
t.Fatal(err)
}
mods.put("nopath", mustEncode(t, delivery.Module{Repository: catalogueRepo, Commit: older, None2: "x", Manifest: strings.Repeat("a", 64)}))
ix.Trunk = func(context.Context, string, string) ([]string, error) { return []string{"modules/a"}, nil }
ix.Contains = func(_ context.Context, _, commit, in string) (bool, error) { return commit == in, nil } // C is not in B
if err := ix.Reconcile(ctx); err != nil {
t.Fatal(err)
}
for _, k := range []string{"a", "b", "nopath"} {
if _, ok := mods.vals[k]; !ok {
t.Errorf("modules.%s was deleted", k)
}
}
ix.Contains = func(context.Context, string, string, string) (bool, error) { return false, errors.New("forge down") }
_ = ix.Reconcile(ctx)
if _, ok := mods.vals["b"]; !ok {
t.Error("modules.b deleted though whether its commit is in the head cannot be read")
}
}
@@ -0,0 +1,77 @@
package index
import (
"context"
"strings"
"testing"
)
// unreadableAvahi is avahi's manifest with a field the running parser refuses: the shape of a manifest an older SDK could
// not read.
func unreadableAvahi(t *testing.T) string {
t.Helper()
raw := strings.Replace(catalogueManifest(t, "avahi"), `"version": "1",`, `"version": "1", "scope": "everything",`, 1)
if raw == catalogueManifest(t, "avahi") {
t.Fatal("the fixture has no version line")
}
return raw
}
func TestAMergeOfAnUnreadableManifestKeepsTheInputsBesideTheReason(t *testing.T) { // 1923/AC1
_, mods := newFormIndexer(t, mod{"avahi", unreadableAvahi(t)})
m, ok := moduleOf(t, mods, "avahi")
if !ok || !strings.HasPrefix(m.None2, "the manifest cannot be read: ") || m.FP2 != "" {
t.Fatalf("module = %+v, want the reason 'the manifest cannot be read' and no fingerprint", m)
}
if m.Inputs2 == nil || m.Inputs2.Tree != "tree-avahi" || m.Inputs2.Locks["go.sum"] != avahiGoSum {
t.Errorf("inputs = %+v, want the directory's tree and its lock hashes kept", m.Inputs2)
}
}
func TestAStaleUnreadableVerdictIsJudgedAgainAtStartAndAnotherReasonIsLeft(t *testing.T) { // 1923/AC2
ctx := context.Background()
// The fingerprint a merge of the readable manifest gives.
ixGood, good := newFormIndexer(t, mod{"avahi", catalogueManifest(t, "avahi")})
reportToolchain(t, ixGood, "mesh-build-go", "novox", "go", "go1.27.1")
reportToolchain(t, ixGood, "mesh-build-go", "ace", "go", "go1.27.1")
want, _ := moduleOf(t, good, "avahi")
if want.FP2 == "" {
t.Fatalf("the readable manifest gives no fingerprint: %+v", want)
}
// The same module as an older SDK wrote it: the reason, the inputs kept at the merge, the manifest stored by digest.
ix, mods := newFormIndexer(t, mod{"avahi", unreadableAvahi(t)})
reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.27.1")
reportToolchain(t, ix, "mesh-build-go", "ace", "go", "go1.27.1")
stale, _ := moduleOf(t, mods, "avahi")
stale.Manifest = want.Manifest
ix.Manifests.(*memStore).put(want.Manifest, good2(t, ixGood, want.Manifest))
mods.put("avahi", mustEncode(t, stale))
if err := ix.Recompute(ctx); err != nil {
t.Fatal(err)
}
got, _ := moduleOf(t, mods, "avahi")
if got.FP2 != want.FP2 || got.None2 != "" {
t.Errorf("after the start: fp2 %q none2 %q, want %q and no reason", got.FP2, got.None2, want.FP2)
}
// A manifest still unreadable keeps its value as it was.
ix2, mods2 := newFormIndexer(t, mod{"avahi", unreadableAvahi(t)})
before := string(mods2.vals["avahi"])
if err := ix2.Recompute(ctx); err != nil {
t.Fatal(err)
}
if after := string(mods2.vals["avahi"]); after != before {
t.Errorf("a manifest still unreadable changed the value:\n%s\n%s", before, after)
}
}
// good2 is the stored manifest value of the digest in the indexer that merged the readable manifest.
func good2(t *testing.T, ix *Indexer, digest string) []byte {
t.Helper()
v, _, found, err := ix.Manifests.Get(context.Background(), digest)
if err != nil || !found {
t.Fatalf("manifests.%s: %v %v", digest, found, err)
}
return v
}
@@ -156,6 +156,7 @@ func (ix *Indexer) recomputeModules(ctx context.Context, overlay map[string]deli
}
slices.Sort(keys)
held := map[string]delivery.Module{}
stale := map[string]bool{} // values an SDK that could not read their manifest wrote, judged again (issue 1923)
all := map[string]*known{}
for _, key := range keys {
cur, ok := overlay[key]
@@ -176,11 +177,15 @@ func (ix *Indexer) recomputeModules(ctx context.Context, overlay map[string]deli
if cur.Manifest == "" || cur.Inputs2 == nil {
continue
}
m, err := ix.manifestOf(ctx, cur.Manifest)
raw, m, err := ix.manifestRaw(ctx, cur.Manifest)
if err != nil {
ix.logf("modules.%s: its manifest %s cannot be read, left: %v", key, cur.Manifest, err)
continue
}
if again, ok := ix.rejudged(ctx, m, cur, raw); ok {
cur, stale[key] = again, true
held[key] = cur
}
all[key] = &known{key: key, cur: cur, languages: languagesOf(m), bases: basesOf(m), contexts: hasContext(m)}
}
tools := ix.agreed()
@@ -275,10 +280,10 @@ func (ix *Indexer) recomputeModules(ctx context.Context, overlay map[string]deli
finals[key] = final
continue
}
if k.cur.FP2 == r.fp && k.cur.None2 == r.none && reflect.DeepEqual(k.cur.Toolchains, r.tcs) {
if !stale[key] && k.cur.FP2 == r.fp && k.cur.None2 == r.none && reflect.DeepEqual(k.cur.Toolchains, r.tcs) {
continue
}
digest, inputs := k.cur.Manifest, k.cur.Inputs2
digest, inputs, paths := k.cur.Manifest, k.cur.Inputs2, k.cur.ArtifactPaths
err := write(ctx, ix.Modules, key, func(value []byte, found bool) ([]byte, bool, error) {
if !found {
return nil, false, nil
@@ -291,7 +296,7 @@ func (ix *Indexer) recomputeModules(ctx context.Context, overlay map[string]deli
return nil, false, nil
}
cur.FP2, cur.None2, cur.Toolchains = r.fp, r.none, r.tcs
cur.Inputs2 = inputs
cur.Inputs2, cur.ArtifactPaths = inputs, paths
data, err := delivery.EncodeModule(cur)
return data, false, err
})
@@ -326,24 +331,41 @@ func (ix *Indexer) writeOverlay(ctx context.Context, overlay, finals map[string]
return nil
}
// manifestOf is the parsed manifest manifests.<digest> holds.
func (ix *Indexer) manifestOf(ctx context.Context, digest string) (catalogue.Manifest, error) {
// manifestRaw is manifests.<digest>: the manifest as stored and parsed by the running SDK.
func (ix *Indexer) manifestRaw(ctx context.Context, digest string) (string, catalogue.Manifest, error) {
key, err := delivery.ManifestsKey(digest)
if err != nil {
return catalogue.Manifest{}, err
return "", catalogue.Manifest{}, err
}
value, _, found, err := ix.Manifests.Get(ctx, key)
if err != nil {
return catalogue.Manifest{}, err
return "", catalogue.Manifest{}, err
}
if !found {
return catalogue.Manifest{}, fmt.Errorf("manifests.%s is not there", key)
return "", catalogue.Manifest{}, fmt.Errorf("manifests.%s is not there", key)
}
kept, err := delivery.DecodeManifest(value)
if err != nil {
return catalogue.Manifest{}, err
return "", catalogue.Manifest{}, err
}
return catalogue.ParseManifestForInputs([]byte(kept.JSON))
m, err := catalogue.ParseManifestForInputs([]byte(kept.JSON))
return kept.JSON, m, err
}
// rejudged is a value an SDK that could not read its manifest wrote, as the running SDK judges it: its kept inputs (the
// tree, the lock hashes by file name, the named paths' objects) taken through the manifest as a merge takes them, and the
// reason that gives. It answers false for any other value, and for one whose manifest the running SDK still cannot make
// inputs of (issue 1923).
func (ix *Indexer) rejudged(ctx context.Context, m catalogue.Manifest, cur delivery.Module, raw string) (delivery.Module, bool) {
if cur.FP2 != "" || cur.Inputs2 == nil || !strings.HasPrefix(cur.None2, None2Unreadable) {
return cur, false
}
in := inputsOfNamed(m.Module, raw, cur.Inputs2.Tree, cur.Inputs2.Locks, cur.Inputs2.Paths, ix.baseFP(ctx))
if in.Kept == nil {
return cur, false
}
cur.Inputs2, cur.None2, cur.ArtifactPaths = in.Kept, in.None2, in.ArtifactPaths
return cur, true
}
// hasContext is whether an artifact of the manifest reads another repository's tree.
+34 -3
View File
@@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"reflect"
"regexp"
"slices"
"strings"
"testing"
@@ -94,9 +95,10 @@ func TestManifestDeclarations(t *testing.T) { // 1238/AC1, 1444 (reads the build
if m.SecretsOwner != "mesh-delivery-indexer" {
t.Errorf("secrets-owner = %q", m.SecretsOwner)
}
// One invoke: the forge's trees with the named paths' objects, since the indexer is the one reader of a manifest's paths
// (ADR 0499 clauses 2 and 3, ADR 0495 clause 1, the lead's decision L8 of R4e).
if want := []string{"gitea.gitea_commit_trees"}; !reflect.DeepEqual(m.Invokes, want) {
// Two invokes: the forge's trees (the named paths' objects, since the indexer is the one reader of a manifest's paths:
// ADR 0499 clauses 2 and 3, ADR 0495 clause 1; and the module directories of the trunk, issue 1900) and whether a
// commit is in another (issue 1900).
if want := []string{"gitea.gitea_commit_trees", "gitea.gitea_contains"}; !reflect.DeepEqual(m.Invokes, want) {
t.Errorf("invokes = %v, want %v", m.Invokes, want)
}
// No emits, no streams, no bus, no seat, no tool (ADR 0437 §4).
@@ -169,3 +171,32 @@ func TestServesNoRequestSubjectAndHoldsNoTicker(t *testing.T) { // 1238/AC9 (ADR
t.Fatalf("read %d source files, want the program's", files)
}
}
// Every forge tool the program calls is granted in invokes, or the bus refuses the call (issue 1900).
func TestEveryGiteaToolTheCodeCallsIsInInvokes(t *testing.T) {
m, _ := load(t)
files, err := filepath.Glob("cmd/mesh-delivery-indexer/*.go")
if err != nil || len(files) == 0 {
t.Fatalf("no program files: %v", err)
}
re := regexp.MustCompile(`gitea\.gitea_[a-z_]+`)
seen := 0
for _, f := range files {
if strings.HasSuffix(f, "_test.go") {
continue
}
src, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
for _, tool := range re.FindAllString(string(src), -1) {
seen++
if !slices.Contains(m.Invokes, tool) {
t.Errorf("%s calls %s, which invokes does not grant", f, tool)
}
}
}
if seen < 2 {
t.Errorf("found %d tool names in the program, want at least the two it calls", seen)
}
}
+2 -1
View File
@@ -21,7 +21,8 @@
"mesh-delivery-gate.source"
],
"invokes": [
"gitea.gitea_commit_trees"
"gitea.gitea_commit_trees",
"gitea.gitea_contains"
],
"own-secrets": {
"broker": "${dir:state}/broker"