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/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 {