diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go index 554595ce..cadba393 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go @@ -27,6 +27,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/health" "git.novox.be/novox/mesh-sdk/go/process" + "git.novox.be/novox/mesh-sdk/go/provisions" "mesh-delivery-assigner/internal/judge" "mesh-delivery-assigner/internal/assign" @@ -434,6 +435,60 @@ func TestWantsAreWrittenAndNoDifferenceIs(t *testing.T) { if !self { t.Error("ace's own want does not say it composes its own once placed") } + + // ADR 0603 (L3, D15): one source revision changes a provider (db), its new consumer (app) and a build module (tool, whose + // recipe moved: ace runs its first build and the second, under the agreed toolchain, is the one the source revision names). + // The keys are written as their writers write them: the indexer's modules and manifests, the builder's builds, the + // controller's grants, the placement seat's placement and the provisioner's provisions. The build module's want moves at + // once and the provider's with it; the consumer's waits for its provision, with the words naming it, and moves once the + // provisioner provides it (rule 8, ADR 0570 §4): nothing waits for a turn or an order, only for a key. + offer := `"provides":[{"name":"database","scope":"mesh","answered-by":"helper"}]` + dbDigest := b.manifest(t, "db", `{"module":"db","version":"1",`+offer+`}`) + appDigest := b.manifest(t, "app", `{"module":"app","version":"1","requires":["database"]}`) + toolDigest := b.manifest(t, "tool", `{"module":"tool","version":"1"}`) + fpOf := func(c byte) string { return delivery.SourcePrefix2 + strings.Repeat(string(c), 64) } + toolOld := fpOf('1') + for module, row := range map[string]struct { + fp, digest string + }{"db": {fpOf('2'), dbDigest}, "app": {fpOf('3'), appDigest}, "tool": {fpOf('4'), toolDigest}} { + mod, _ := delivery.EncodeModule(delivery.Module{FP2: row.fp, Manifest: row.digest, Repository: "novox/" + module, Commit: strings.Repeat("2", 40), Path: delivery.RootPath}) + b.putJSON(t, delivery.ModulesBucket, module, mod) + fps := []string{row.fp} + if module == "tool" { + fps = append(fps, toolOld) + } + for _, fp := range fps { + built, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, By: "builder.novox.1", Step: "pushed", Manifest: row.digest}) + bkey, _ := delivery.BuilderBuildKey(module, fp) + b.putJSON(t, delivery.BuilderBuildsBucket, bkey, built) + gkey, _ := delivery.PartGrantKey(module, fp) + grant, _ := delivery.EncodeGrant(delivery.Grant{}) + b.putJSON(t, delivery.PartGrantsBucket, gkey, grant) + } + b.place(t, "ace", module) + } + runsOld, _ := delivery.EncodeHave(delivery.Have{FP: toolOld, Phase: delivery.PhaseRunning, Health: delivery.HealthOK}) + b.putJSON(t, delivery.HaveBucket, "ace.tool", runsOld) + asked := func(state, why string) { + raw, err := provisions.Encode(provisions.Provision{V: provisions.Version, Provision: "database", Consumer: provisions.End{Module: "app", Node: "ace"}, + Provider: provisions.End{Module: "db", Node: "ace"}, State: state, Why: why, At: time.Now()}) + if err != nil { + t.Fatal(err) + } + b.putJSON(t, provisions.ProvisionsBucket, "ace.app.database", raw) + } + asked(provisions.Asked, "the provider's build is not done") + if w := b.wantNow(t, "ace", "tool", "the build module's want at the second build", func(w delivery.Want) bool { return w.FP == fpOf('4') }); w.FP != fpOf('4') { + t.Errorf("tool's want = %+v", w) + } + b.wantNow(t, "ace", "db", "the provider's want at its fingerprint", func(w delivery.Want) bool { return w.FP == fpOf('2') }) + if e, err := b.buckets[delivery.AssignerWantsBucket].Get(b.ctx, "want.ace.app"); err == nil { + t.Errorf("app was wanted while its provision was asked: %s", e.Value()) + } + asked(provisions.Provided, "") + if w := b.wantNow(t, "ace", "app", "the consumer's want once its provision is provided", func(w delivery.Want) bool { return w.FP == fpOf('3') }); w.FP != fpOf('3') { + t.Errorf("app's want = %+v", w) + } } // 1297/B9 (the assigner's half, ADR 0528 clause 6): started whole, the assigner holds a fingerprint whose health check diff --git a/modules/mesh-delivery-builder/internal/build/run_test.go b/modules/mesh-delivery-builder/internal/build/run_test.go index 03d64ce4..55ed66ff 100644 --- a/modules/mesh-delivery-builder/internal/build/run_test.go +++ b/modules/mesh-delivery-builder/internal/build/run_test.go @@ -589,6 +589,45 @@ func TestAKeyIsBuiltOnlyWithTheToolchainItsFingerprintNamesAndNothingIsWrittenOt } } +// ADR 0603 §3 (D9): a source revision changing the Go build module's recipe and a Go module builds the module at once with +// the recipe the nodes run and again, a second key under the new fingerprint, once the agreement moves to the new recipe; +// the first key is neither rebuilt nor waited on. +func TestARecipeThatMovesBuildsTheModuleAgainUnderItsNewFingerprint(t *testing.T) { + r := newRig(t, goBundle) + r.consider(t, "modules/m") + calls := len(r.bm.calls) + fp2 := "src2:" + hex64('2') + for _, n := range []string{"ace", "novox"} { + b, err := delivery.EncodeToolchain(delivery.Toolchain{Language: "go", Version: "go1.27.1", Recipe: "2", At: time.Now()}) + if err != nil { + t.Fatal(err) + } + r.tools.Put(buildrun.GoBuildModule, n+".go", b, false) + } + r.b.Reconsider(context.Background()) + r.b.Wait() + if len(r.bm.calls) != calls { + t.Errorf("%d calls, was %d: the first fingerprint was built again for the new recipe", len(r.bm.calls), calls) + } + data, err := delivery.EncodeModule(delivery.Module{Repository: "novox/mesh-catalog", Commit: "7a74cee83f88e6585a5511c3cc53bdf0f5303d20", FP: "src1:" + hex64('f'), FP2: fp2, + Manifest: mdigest('a'), Path: "modules/m", Toolchains: map[string]delivery.ToolchainUsed{"go": {Version: "go1.27.1", Recipe: "2"}}}) + if err != nil { + t.Fatal(err) + } + r.b.Module(context.Background(), "m", data) + r.b.Wait() + second, err := delivery.BuilderBuildKey("m", fp2) + if err != nil { + t.Fatal(err) + } + if v := valueOf(t, r.st, second); v.State != delivery.BuildDone || v.Toolchains["go"].Recipe != "2" { + t.Errorf("second build = %+v, want done with recipe 2", v) + } + if v := valueOf(t, r.st, r.key); v.State != delivery.BuildDone { + t.Errorf("first build = %+v, want it left done", v) + } +} + func TestNodesTheyDisagreeOnLeaveTheKeyUnwritten(t *testing.T) { // 1615/AC4 r := newRig(t, goBundle) r.tools.Put(buildrun.GoBuildModule, "ace.go", tcValue(t, "go", "go1.26.0"), false) diff --git a/modules/mesh-delivery-checker/internal/check/build_change.go b/modules/mesh-delivery-checker/internal/check/build_change.go new file mode 100644 index 00000000..bc57791a --- /dev/null +++ b/modules/mesh-delivery-checker/internal/check/build_change.go @@ -0,0 +1,66 @@ +package check + +import ( + "context" + "fmt" + "maps" + "slices" + "strings" + + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" + "git.novox.be/novox/mesh-sdk/go/catalogue" + + "git.novox.be/novox/mesh-delivery/sdk/graph" +) + +// A change to a build module or a base is built first, and what stands on it is composed from the manifests (novox/hq ADR +// 0603 §10): the check builds the build module (a candidate's mesh-build-go judges its own change, ADR 0535) and the bases, +// and composes the rest from their manifests, because a module built with the new recipe is built once the agreement +// moves, a second time (ADR 0603 §3). The verdict names the changed modules whose fingerprint names the build module or +// the base the change alters, as warnings of its composition: words for the operator, no part decides on them. + +// buildChangeWarnings are the composition warnings of a change that alters the Go build module, or a module other changed +// modules are built on: one line each, naming those modules, sorted. None for a change that alters neither, or when the +// manifests cannot be read (nothing is said that was not read). +func (st *state) buildChangeWarnings(ctx context.Context) []string { + changed := map[string]catalogue.Manifest{} + for _, p := range st.c.Pulls { + for module, cm := range p.Fps { + if cm.FP2 == "" || cm.Manifest == "" || st.j.Read == nil { + continue + } + m, err := st.j.Read.Manifest(ctx, cm.Manifest) + if err != nil { + st.j.logf("%s: the manifest of %s cannot be read, so the modules built with a changed build module or base are not named: %v", st.c.Key, module, err) + return nil + } + changed[module] = m + } + } + var out []string + for _, t := range st.targets { + if t.module != buildrun.GoBuildModule || !changesBuildGo(t.pull.Repository, t.changed) { + continue + } + var built []string + for module, m := range changed { + if m.Build != nil && slices.ContainsFunc(m.Build.Artifacts, func(a catalogue.Artifact) bool { return a.Language == "go" }) { + built = append(built, module) + } + } + slices.Sort(built) + if len(built) > 0 { + out = append(out, fmt.Sprintf("the change alters the build module %s: %s, built with it, are composed from their manifests and built again once the agreed toolchain moves (ADR 0603 §10)", + buildrun.GoBuildModule, strings.Join(built, ", "))) + } + break + } + g := graph.OfManifests(changed) + for _, base := range slices.Sorted(maps.Keys(changed)) { + if on := g.Dependents(base); len(on) > 0 { + out = append(out, fmt.Sprintf("the change alters %s, a base of %s: they are composed from their manifests and built after it (ADR 0603 §10)", + base, strings.Join(on, ", "))) + } + } + return out +} diff --git a/modules/mesh-delivery-checker/internal/check/composition_test.go b/modules/mesh-delivery-checker/internal/check/composition_test.go index 5e3e6d4e..eceeb202 100644 --- a/modules/mesh-delivery-checker/internal/check/composition_test.go +++ b/modules/mesh-delivery-checker/internal/check/composition_test.go @@ -3,9 +3,12 @@ package check import ( "context" "errors" + "maps" + "slices" "strings" "testing" + "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" ) @@ -99,3 +102,51 @@ func TestACompositionNotHadIsNotRun(t *testing.T) { type fixedTrunks struct{ heads map[string]string } func (f fixedTrunks) Heads() map[string]string { return f.heads } + +// ADR 0603 §10: a change that alters the Go build module, or a module others stand on, names in the composition's warnings +// (and so the check's line) the changed modules built with it, from their manifests; a change that alters neither says nothing. +func TestAChangeToABuildModuleOrBaseNamesTheModulesBuiltOnIt(t *testing.T) { + dig := func(c string) string { return strings.Repeat(c, 64) } + goBuild := &catalogue.Build{Artifacts: []catalogue.Artifact{{Name: "bin", Language: "go"}}} + for _, row := range []struct { + name string + changed []string + manifest map[string]catalogue.Manifest + want []string + }{ + {name: "the build module changes", changed: []string{buildGoFile}, + manifest: map[string]catalogue.Manifest{"web": {Module: "web", Build: goBuild}, "mail": {Module: "mail", Build: goBuild}, "docs": {Module: "docs"}}, + want: []string{"mesh-build-go: mail, web, built with it"}}, + {name: "a base changes", changed: []string{"README.md"}, + manifest: map[string]catalogue.Manifest{"image": {Module: "image"}, + "web": {Module: "web", Build: &catalogue.Build{On: []catalogue.BuildsOn{{Arg: "BASE", Module: "image", Artifact: "img"}}}}}, + want: []string{"alters image, a base of web"}}, + {name: "neither changes", changed: []string{"README.md"}, + manifest: map[string]catalogue.Manifest{"web": {Module: "web", Build: goBuild}}}, + } { + b := candidateBench(t, true) + b.fetch.Changed = row.changed + b.ex.Compositions = &fakeCompositions{v: delivery.CompositionVerdict{Group: "feat/x", Heads: "0123456789ab", State: delivery.CompositionPass, SDK: "x", Machines: []string{"g14"}}} + read, fps := fixedManifests{}, map[string]delivery.CandidateModule{} + for module, m := range row.manifest { + d := dig(string(rune('a' + len(read)))) + read[d], fps[module] = m, delivery.CandidateModule{FP2: "src2:" + dig("f"), Manifest: d} + } + b.ex.Read = read + p := pullOf(catalogueRepository, 9, head1) + p.Fps, p.Modules = fps, slices.Sorted(maps.Keys(fps)) + v := b.judge(t, caseOf(t, p)).Verdict + if v.State != delivery.VerdictPass || v.Composition == nil { + t.Fatalf("%s: %+v", row.name, v) + } + words := compositionWords(v.Composition, v.Took.Composition) + for _, w := range row.want { + if !strings.Contains(words, w) { + t.Errorf("%s: the line says %q, want it to name %q", row.name, words, w) + } + } + if row.want == nil && v.Composition.Words != "" { + t.Errorf("%s: warned %q though neither a build module nor a base changed", row.name, v.Composition.Words) + } + } +} diff --git a/modules/mesh-delivery-checker/internal/check/judge.go b/modules/mesh-delivery-checker/internal/check/judge.go index 9d7a2283..f15c9429 100644 --- a/modules/mesh-delivery-checker/internal/check/judge.go +++ b/modules/mesh-delivery-checker/internal/check/judge.go @@ -287,6 +287,7 @@ func (st *state) composed(ctx context.Context, v *delivery.Verdict) { v.Composition = &delivery.Composition{State: delivery.CompositionNotRun, Words: "not made: " + err.Error()} return } + comp.Warnings = append(comp.Warnings, st.buildChangeWarnings(ctx)...) // ADR 0603 §10 withComposition(v, comp) if v.Composition != nil && v.Composition.State != delivery.CompositionNotRun { v.Composition.FPSet = st.c.FPSet // a carry keeps it while the fingerprints stay the same (ADR 0548 §5) diff --git a/modules/mesh-delivery-indexer/internal/index/graph.go b/modules/mesh-delivery-indexer/internal/index/graph.go index f9195d3b..d50caa64 100644 --- a/modules/mesh-delivery-indexer/internal/index/graph.go +++ b/modules/mesh-delivery-indexer/internal/index/graph.go @@ -2,12 +2,14 @@ package index import ( "context" + "errors" "fmt" "maps" "slices" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/process" "git.novox.be/novox/mesh-delivery/sdk/graph" ) @@ -60,11 +62,18 @@ func graphNode(e graphEdges, providers func(string) []catalogue.Manifest) graph. } // writeGraph writes the graph node of every module of edges whose value changed, at most GraphMost of them, then -// deletes each graph key whose module the modules state no longer holds. Nil Graph writes nothing. +// deletes each graph key whose module the modules state no longer holds. Nil Graph writes nothing, and neither does a +// graph state that is not on the bus yet (process.ErrNotYet; main.go: until then no graph is written), said in the log; +// a listing that fails otherwise skips the deletion pass, said, and never fails the merge or the recompute that wrote. func (ix *Indexer) writeGraph(ctx context.Context, edges map[string]graphEdges) error { if ix.Graph == nil { return nil } + keys, listErr := ix.Graph.Keys(ctx) + if errors.Is(listErr, process.ErrNotYet) { + ix.logf("the module graph is not written: its state is not on the bus yet") + return nil + } names := slices.Sorted(maps.Keys(edges)) if len(names) > GraphMost { ix.logf("the module graph holds at most %d modules: %d are known, so %s and the %d after it are not written", @@ -104,19 +113,21 @@ func (ix *Indexer) writeGraph(ctx context.Context, edges map[string]graphEdges) } } held, err := ix.Modules.Keys(ctx) - if err != nil { - return fmt.Errorf("listing the modules for the graph: %w", err) - } - keys, err := ix.Graph.Keys(ctx) - if err != nil { - return fmt.Errorf("listing the graph: %w", err) + switch { + case listErr != nil: + ix.logf("the graph keys of modules the modules state no longer holds are not deleted: listing the graph: %v", listErr) + return nil + case err != nil: + ix.logf("the graph keys of modules the modules state no longer holds are not deleted: listing the modules: %v", err) + return nil } for _, key := range keys { if slices.Contains(held, key) { continue } if err := write(ctx, ix.Graph, key, func(_ []byte, found bool) ([]byte, bool, error) { return nil, found, nil }); err != nil { - return fmt.Errorf("graph.%s: %w", key, err) + ix.logf("graph.%s is not deleted: %v", key, err) + continue } ix.logf("graph.%s deleted: the modules state holds no module %s", key, key) } diff --git a/modules/mesh-delivery-indexer/internal/index/index_test.go b/modules/mesh-delivery-indexer/internal/index/index_test.go index e6f9da4d..b584aa67 100644 --- a/modules/mesh-delivery-indexer/internal/index/index_test.go +++ b/modules/mesh-delivery-indexer/internal/index/index_test.go @@ -19,6 +19,7 @@ const ( // key under the indexer; unbound, when set, is every read's error (a state not yet on the bus). type memStore struct { unbound error + keysErr error // the listing's error vals map[string][]byte revs map[string]uint64 next uint64 @@ -36,6 +37,9 @@ func (m *memStore) Get(_ context.Context, key string) ([]byte, uint64, bool, err return v, m.revs[key], ok, nil } func (m *memStore) Keys(context.Context) ([]string, error) { + if m.keysErr != nil { + return nil, m.keysErr + } var keys []string for k := range m.vals { keys = append(keys, k) diff --git a/modules/mesh-delivery-indexer/internal/index/toolchains_test.go b/modules/mesh-delivery-indexer/internal/index/toolchains_test.go index 0fbe30d6..ffac6f5f 100644 --- a/modules/mesh-delivery-indexer/internal/index/toolchains_test.go +++ b/modules/mesh-delivery-indexer/internal/index/toolchains_test.go @@ -2,12 +2,15 @@ package index import ( "context" + "errors" + "fmt" "reflect" "strings" "testing" "time" "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/process" "git.novox.be/novox/mesh-delivery/sdk/graph" @@ -114,12 +117,17 @@ func toolchainValue(t *testing.T, language, version, recipe string) []byte { } func reportToolchain(t *testing.T, ix *Indexer, module, node, language, version string) { + t.Helper() + reportToolchainRecipe(t, ix, module, node, language, version, "1") +} + +func reportToolchainRecipe(t *testing.T, ix *Indexer, module, node, language, version, recipe string) { t.Helper() key, err := delivery.ToolchainKey(node, language) if err != nil { t.Fatal(err) } - if err := ix.ApplyToolchain(context.Background(), module, key, toolchainValue(t, language, version, "1"), false); err != nil { + if err := ix.ApplyToolchain(context.Background(), module, key, toolchainValue(t, language, version, recipe), false); err != nil { t.Fatal(err) } } @@ -172,6 +180,31 @@ func TestAgreedToolchainChangesAGoModulesReasonWithNoMerge(t *testing.T) { // 14 } } +// ADR 0603 §3 (D9): a source revision that changes the Go build module's recipe and a Go module builds the module at once, with +// the recipe the nodes run, and again once the agreement moves to the new one: the module never waits and never loses its +// fingerprint between the two, and its toolchain edge names the recipe each was computed with. +func TestARecipeChangeBuildsAGoModuleAtOnceAndAgainOnceTheAgreementMoves(t *testing.T) { + ix, mods := newFormIndexer(t, mod{"avahi", catalogueManifest(t, "avahi")}) + for _, node := range []string{"novox", "ace"} { + reportToolchain(t, ix, "mesh-build-go", node, "go", "go1.27.1") + } + first, _ := moduleOf(t, mods, "avahi") + if first.FP2 == "" || graphOf(t, ix, "avahi").Toolchains["go"].Recipe != "1" { + t.Fatalf("with the running recipe: fp2 %q, graph %+v", first.FP2, graphOf(t, ix, "avahi")) + } + // The release carrying the new recipe is merged, but the nodes still run the old one: one node reporting the new recipe is + // no strict majority, so the fingerprint stays, nothing waits. + reportToolchainRecipe(t, ix, "mesh-build-go", "novox", "go", "go1.27.1", "2") + if held, _ := moduleOf(t, mods, "avahi"); held.FP2 != first.FP2 || held.None2 != "" { + t.Errorf("one node on the new recipe: fp2 %q none2 %q, want the fingerprint of the running recipe, %q", held.FP2, held.None2, first.FP2) + } + reportToolchainRecipe(t, ix, "mesh-build-go", "ace", "go", "go1.27.1", "2") + again, _ := moduleOf(t, mods, "avahi") + if again.FP2 == "" || again.FP2 == first.FP2 || again.None2 != "" || graphOf(t, ix, "avahi").Toolchains["go"].Recipe != "2" { + t.Errorf("the agreement moved: fp2 %q (was %q) none2 %q graph %+v, want a second fingerprint at recipe 2", again.FP2, first.FP2, again.None2, graphOf(t, ix, "avahi")) + } +} + func TestNodesReportingDifferentVersionsLeaveTheReasonNamingBoth(t *testing.T) { // 1444/AC6 // Both reports are read before the module's first fingerprint is computed (a fingerprint computed before they // disagree is kept: TestAModuleKeepsTheToolchainsItsFingerprintWasComputedWith). @@ -258,6 +291,34 @@ func TestBasesAreComputedFirstAndNoFingerprintTakesTheBuilder(t *testing.T) { // if _, held := graph.vals["avahi"]; !held { t.Error("graph.avahi went with another module") } + // Review finding 3 (ADR 0603 §2): a graph state not on the bus yet (process.ErrNotYet), or one whose listing fails, never + // fails a recompute; the first writes nothing and says so, the second skips the deletion pass and says so. + graph.vals["ghost"], graph.revs["ghost"] = []byte("x"), 1 + var said []string + ix.Logf = func(format string, args ...any) { said = append(said, fmt.Sprintf(format, args...)) } + for _, row := range []struct { + name string + err error + say string + }{ + {"not on the bus yet", process.ErrNotYet, "not on the bus yet"}, + {"a listing that fails", errors.New("the bus is slow"), "are not deleted"}, + } { + said, graph.keysErr = nil, row.err + if err := ix.Recompute(context.Background()); err != nil { + t.Errorf("%s: the recompute failed: %v", row.name, err) + } + if _, held := graph.vals["ghost"]; !held || !strings.Contains(strings.Join(said, "\n"), row.say) { + t.Errorf("%s: ghost held %v, log %q, want it kept and %q said", row.name, held, said, row.say) + } + } + graph.keysErr = nil + if err := ix.Recompute(context.Background()); err != nil { + t.Fatal(err) + } + if _, held := graph.vals["ghost"]; held { + t.Error("graph.ghost is still held once the graph can be listed") + } a := agentBuild("build-1792587792086548572", agentSrcY, "2026-10-21T15:03:00Z") _ = ix.Builds.Put(a[1].([]byte)) if err := ix.Recompute(context.Background()); err != nil { diff --git a/modules/mesh-delivery-indexer/internal/index/verdicts_test.go b/modules/mesh-delivery-indexer/internal/index/verdicts_test.go index 28acdca8..051fe168 100644 --- a/modules/mesh-delivery-indexer/internal/index/verdicts_test.go +++ b/modules/mesh-delivery-indexer/internal/index/verdicts_test.go @@ -76,6 +76,11 @@ func TestTheIndexerJudgesEveryManifestOnce(t *testing.T) { if j, err := ix.JudgeAll(ctx, nil); err != nil || j.Written != 0 || len(said) != 1 { t.Fatalf("a judge that judges alike: %+v %v, said %d", j, err, len(said)) } + // ADR 0603 §4 (D9): a source revision that changes the indexer builds the new indexer, which at its start judges every + // manifest it holds again, none of them fresh: all of them judged, nothing written for a verdict that stands. + if j, err := ix.JudgeAtStart(ctx); err != nil || j.Judged != 2 || j.Written != 0 || len(said) != 1 { + t.Fatalf("the new indexer at its start: %+v %v, said %d, want both manifests judged and nothing written", j, err, len(said)) + } // The manifest mended: its new digest is accepted and the module's refusal goes. mended := hold("bad", `{"module":"bad","version":"2"}`) if j, err := ix.JudgeAll(ctx, map[string]bool{mended: true}); err != nil || j.Written != 1 || len(j.Refused) != 0 { diff --git a/sdk/graph/graph.go b/sdk/graph/graph.go index 6648ced8..b0c7951c 100644 --- a/sdk/graph/graph.go +++ b/sdk/graph/graph.go @@ -230,8 +230,9 @@ func (g Graph) Requirers(provision string) []string { return out } -// Publisher is the module of the graph whose repository and directory publish the mesh package pkg (PackageRepository), -// or "" when the graph holds none. +// Publisher is the module of the graph whose repository and directory publish the mesh package pkg (PackageRepository): +// the module whose directory is the package's directory or the nearest one above it (the longest match, a package +// published from inside a module's directory belonging to that module), or "" when the graph holds none. func (g Graph) Publisher(pkg string) string { repository, dir, ok := delivery.PackageRepository(pkg) if !ok { @@ -240,12 +241,24 @@ func (g Graph) Publisher(pkg string) string { if dir == "" { dir = delivery.RootPath } + best, bestLen := "", -1 for _, name := range slices.Sorted(maps.Keys(g)) { - if n := g[name]; n.Repository == repository && n.Path == dir { + n := g[name] + if n.Repository != repository { + continue + } + switch { + case n.Path == dir: return name + case n.Path == delivery.RootPath: + if bestLen < 0 { + best, bestLen = name, 0 + } + case strings.HasPrefix(dir, n.Path+"/") && len(n.Path) > bestLen: + best, bestLen = name, len(n.Path) } } - return "" + return best } // buildEdges are the modules n's build edges reach: its bases, the publisher of each of its packages, and the build diff --git a/sdk/graph/graph_test.go b/sdk/graph/graph_test.go index 9c34b9db..2848f19c 100644 --- a/sdk/graph/graph_test.go +++ b/sdk/graph/graph_test.go @@ -32,7 +32,11 @@ func TestGraphEdges(t *testing.T) { n.Bases, n.Packages, n.Toolchains = []string{"base"}, []string{"git.novox.be/novox/lib"}, gotool }), "top": node("novox/mesh-catalog", "modules/top", func(n *Node) { n.Bases = []string{"app"} }), - "db": node("novox/mesh-catalog", "modules/db", func(n *Node) { n.Provides = []string{"database"} }), + // A package published from inside a module's directory belongs to that module (ADR 0604 §1): users imports build-go's. + "users": node("novox/other", "modules/users", func(n *Node) { + n.Packages = []string{"git.novox.be/novox/mesh-catalog/modules/build-go/buildrun"} + }), + "db": node("novox/mesh-catalog", "modules/db", func(n *Node) { n.Provides = []string{"database"} }), "web": node("novox/mesh-catalog", "modules/web", func(n *Node) { n.Requires, n.Awaits = []string{"database", "db"}, []string{"database"} }), @@ -40,7 +44,7 @@ func TestGraphEdges(t *testing.T) { for module, want := range map[string][]string{ "base": {"app", "top"}, "lib": {"app", "top"}, - "build-go": {"app", "top"}, // build-go's own toolchain edge reaches itself, which is left out + "build-go": {"app", "top", "users"}, // build-go's own toolchain edge reaches itself, which is left out "top": nil, "db": nil, // a provision edge is no build edge } { @@ -63,6 +67,16 @@ func TestGraphEdges(t *testing.T) { if got := g.Requirers("db"); !reflect.DeepEqual(got, []string{"web"}) { t.Errorf("requirers of db = %v", got) } + for pkg, want := range map[string]string{ + "git.novox.be/novox/mesh-catalog/modules/build-go/buildrun": "build-go", // inside its directory + "git.novox.be/novox/mesh-catalog/modules/build-go": "build-go", // its directory + "git.novox.be/novox/mesh-catalog/modules/build-gone": "", // beside it, not inside + "git.novox.be/novox/lib/sub": "lib", // a root module publishes what is below it + } { + if got := g.Publisher(pkg); got != want { + t.Errorf("publisher of %s = %q, want %q", pkg, got, want) + } + } if g.Publisher("git.novox.be/novox/lib") != "lib" || g.Publisher("git.novox.be/novox/mesh-sdk/go") != "" { t.Error("a package's publisher is not the module at its repository and directory") }