From ef0ca69cb06ee39cac77fb224fc1e23c02eff5a2 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 19 Oct 2026 00:48:56 +0200 Subject: [PATCH] The first build of a module a merge adds on the operator's Approve passes the guard of a build through a verb on the warrant it checked (issue 1146, AC9) --- cmd/mesh-controller/build.go | 4 +- cmd/mesh-controller/build_source.go | 38 ++++++++- .../build_source_integration_test.go | 6 +- .../first_build_ask_integration_test.go | 81 +++++++++++++++++++ .../first_build_warrant_test.go | 29 +++++++ cmd/mesh-controller/first_builds.go | 11 ++- .../first_builds_integration_test.go | 17 +++- testdata/store-tests | 1 + 8 files changed, 178 insertions(+), 9 deletions(-) create mode 100644 cmd/mesh-controller/first_build_ask_integration_test.go create mode 100644 cmd/mesh-controller/first_build_warrant_test.go diff --git a/cmd/mesh-controller/build.go b/cmd/mesh-controller/build.go index b89e30b2..2f360a66 100644 --- a/cmd/mesh-controller/build.go +++ b/cmd/mesh-controller/build.go @@ -473,7 +473,7 @@ func buildOneAsked(ctx context.Context, source buildSource, path, ref string, wa return "", err } // Through a verb, only a repository the catalogue builds from (novox/hq ADR 0266). - if err := verbMayAsk(ctx, source, repository); err != nil { + if err := verbMayAsk(ctx, source, repository, path); err != nil { return "", err } @@ -812,7 +812,7 @@ func buildAndShow(ctx context.Context, source buildSource, path, ref string, wai if err != nil { return err } - if err := verbMayAsk(ctx, source, repository); err != nil { + if err := verbMayAsk(ctx, source, repository, path); err != nil { return err } ident, err := openIdentity(ctx) diff --git a/cmd/mesh-controller/build_source.go b/cmd/mesh-controller/build_source.go index f7799028..da098867 100644 --- a/cmd/mesh-controller/build_source.go +++ b/cmd/mesh-controller/build_source.go @@ -567,13 +567,49 @@ func sourceWords(repository, seat string) string { return buildSource{Repository: repository, Seat: seat}.String() } +// firstBuildWarrant is what the controller checked before it asks the first build of the modules a merge adds to a +// repository nothing is built from: the merge was made on the operator's Approve of its head (approvedAnnounced). It +// travels in the context of that ask alone, in this process, so no verb carries one. +type firstBuildWarrant struct { + repository string // owner/name on the git seat + paths map[string]bool + why string +} + +type firstBuildWarrantKey struct{} + +// withFirstBuildWarrant is ctx carrying the warrant for asking the first builds of paths in repository. +func withFirstBuildWarrant(ctx context.Context, repository string, paths []string, why string) context.Context { + w := &firstBuildWarrant{repository: repository, paths: map[string]bool{}, why: why} + for _, p := range paths { + w.paths[strings.Trim(p, "/")] = true + } + return context.WithValue(ctx, firstBuildWarrantKey{}, w) +} + +func firstBuildWarrantIn(ctx context.Context) *firstBuildWarrant { + w, _ := ctx.Value(firstBuildWarrantKey{}).(*firstBuildWarrant) + return w +} + +// covers says the warrant is for exactly this ask: the repository on the git seat, and a module path the merge holds. +func (w *firstBuildWarrant) covers(source buildSource, path string) bool { + return w != nil && source.Seat == gitSeat && strings.EqualFold(strings.Trim(source.Repository, "/"), w.repository) && + w.paths[strings.Trim(path, "/")] +} + // verbMayAsk refuses, for a call through a verb, a build of a repository the catalogue builds no module from // (novox/hq ADR 0266): the build node would run what an agent wrote, and its outcome could never be // registered anyway. url is the repository as it is cloned. At the terminal anything may be asked. -func verbMayAsk(ctx context.Context, source buildSource, url string) error { +func verbMayAsk(ctx context.Context, source buildSource, url, path string) error { if startedAtTheTerminal() { return nil } + // **Or the first build of a module a merge adds on the operator's Approve** (novox/hq ADR 0432, issue 1146): the + // controller's own ask, carrying the warrant it checked, for that repository and the module paths the merge holds. + if firstBuildWarrantIn(ctx).covers(source, path) { + return nil + } open, err := openStores(ctx) if err != nil { return err diff --git a/cmd/mesh-controller/build_source_integration_test.go b/cmd/mesh-controller/build_source_integration_test.go index 07857b98..19ab0247 100644 --- a/cmd/mesh-controller/build_source_integration_test.go +++ b/cmd/mesh-controller/build_source_integration_test.go @@ -134,18 +134,18 @@ func TestAVerbAsksNoBuildOfARepositoryTheCatalogueDoesNotBuildFrom(t *testing.T) ctx := t.Context() t.Setenv(verbVar, "build") t.Setenv(link.CallerVar, "node-tools.anchor, through the mesh-controller seat") - err := verbMayAsk(ctx, buildSource{Repository: "agent/sudo", Seat: "git"}, "http://forge.internal:20000/agent/sudo.git") + err := verbMayAsk(ctx, buildSource{Repository: "agent/sudo", Seat: "git"}, "http://forge.internal:20000/agent/sudo.git", "") var policy *heldAtTheTerminal if !errors.As(err, &policy) { t.Fatalf("a verb's build of an agent's repository was asked: %v", err) } if err := verbMayAsk(ctx, buildSource{Repository: "novox/mesh-catalog", Seat: "git"}, - "http://forge.internal:20000/novox/mesh-catalog.git"); err != nil { + "http://forge.internal:20000/novox/mesh-catalog.git", ""); err != nil { t.Fatalf("a verb's build of the catalogue repository was refused: %v", err) } t.Setenv(verbVar, "") t.Setenv(link.CallerVar, "") - if err := verbMayAsk(ctx, buildSource{Repository: "agent/sudo", Seat: "git"}, ""); err != nil { + if err := verbMayAsk(ctx, buildSource{Repository: "agent/sudo", Seat: "git"}, "", ""); err != nil { t.Fatalf("the terminal was refused: %v", err) } } diff --git a/cmd/mesh-controller/first_build_ask_integration_test.go b/cmd/mesh-controller/first_build_ask_integration_test.go new file mode 100644 index 00000000..7b6eb93c --- /dev/null +++ b/cmd/mesh-controller/first_build_ask_integration_test.go @@ -0,0 +1,81 @@ +//go:build integration + +package main + +import ( + "context" + "errors" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/asks" + "github.com/novox/mesh-controller/internal/broker" + "github.com/novox/mesh-controller/internal/catalogue" + "github.com/novox/mesh-controller/internal/link" +) + +// aForgeAndABuildSeat gives a test what a real ask of a build needs: the forge holding the git seat, assigned to anchor, so +// the clone URL composes; the build seat's queue on a real bus; and the environment of the serving controller, which is +// never the controller's terminal (a verb's, in effect: startedAtTheTerminal is false). +func aForgeAndABuildSeat(t *testing.T, open *stores) *broker.JetStream { + t.Helper() + js, _ := aBuildQueue(t) + forge, err := catalogue.ParseManifest([]byte(`{"module":"gitea","version":"1", + "claims":[{"name":"git","scope":"mesh"}],"provides":[{"name":"git","scope":"mesh"}], + "serves":{"git":{"scheme":"http","port":3000}}}`)) + if err != nil { + t.Fatal(err) + } + register(t, open, forge) + if _, err := assign(t.Context(), open, "anchor", "gitea"); err != nil { + t.Fatal(err) + } + t.Setenv(servedVar, "1") + return js +} + +// 1146/AC9, through the real ask (askABuild, buildOneAsked, verbMayAsk) and a real bus: the first build of a module a +// merge adds on the operator's Approve is queued on the build seat; the same ask without that warrant, and the warrant's +// ask for a path the merge does not hold, are refused by the guard of a build through a verb. +func TestTheApprovedFirstBuildPassesTheGuardOfAVerbAndIsQueued(t *testing.T) { + const head, commit = "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", "0177ea7a00000000" + open := aMesh(t) + ctx := t.Context() + js := aForgeAndABuildSeat(t, open) + + a := aDeliveryMergeOf(t, head, map[string]string{"novox/mesh-delivery@a1a1a1a1a1a1": head}) + wasKept, wasAsk := keptDeliveryMerge, routerAskOf + keptDeliveryMerge = func(context.Context, string, string, int, string) (deliveryMerge, bool, error) { + return a.merge, true, nil + } + routerAskOf = func(context.Context, catalogueReader, string) (*asks.Ask, *asks.Warrant, error) { + return &a.ask, &a.warrant, nil + } + t.Cleanup(func() { keptDeliveryMerge, routerAskOf = wasKept, wasAsk }) + + // Without the warrant the guard refuses, whatever the repository holds. + _, err := askABuild(ctx, buildSource{Repository: "novox/mesh-delivery", Seat: "git"}, "", "main") + var policy *heldAtTheTerminal + if !errors.As(err, &policy) { + t.Fatalf("1146/AC9: a first build asked without the warrant was not refused: %v", err) + } + // And a path the merge does not hold is refused on the warrant. + warranted := withFirstBuildWarrant(ctx, "novox/mesh-delivery", []string{""}, "the Approve") + if _, err := askABuild(warranted, buildSource{Repository: "novox/mesh-delivery", Seat: "git"}, "other", "main"); !errors.As(err, &policy) { + t.Fatalf("1146/AC9: the warrant covered a path the merge does not hold: %v", err) + } + + m := link.SourceMoved{Owner: "novox", Repo: "mesh-delivery", Base: "main", HeadSHA: head, Commit: commit, Number: 7, + MergedAt: time.Now().Add(-time.Hour).UTC().Format(time.RFC3339Nano), Paths: []string{"module.json", "main.go"}, + ModuleDirs: []string{"."}, ModuleDirsSaid: true} + if err := (following{open: open}).SourceMoved(ctx, m); err != nil { + t.Fatal(err) + } + queue, err := link.ReadQueue(ctx, js, link.TheBuildMachine) + if err != nil { + t.Fatal(err) + } + if len(queue.Asks) != 1 { + t.Fatalf("1146/AC9: the build queue holds %+v, want the one first build", queue) + } +} diff --git a/cmd/mesh-controller/first_build_warrant_test.go b/cmd/mesh-controller/first_build_warrant_test.go new file mode 100644 index 00000000..e72b3965 --- /dev/null +++ b/cmd/mesh-controller/first_build_warrant_test.go @@ -0,0 +1,29 @@ +package main + +import "testing" + +// 1146/AC9: the warrant the controller checked covers exactly the repository on the git seat and the module paths the +// merge holds; nothing else passes verbMayAsk on it. +func TestTheFirstBuildWarrantCoversOnlyItsRepositoryAndItsModulePaths(t *testing.T) { + ctx := withFirstBuildWarrant(t.Context(), "novox/mesh-delivery", []string{"", "view"}, "the Approve") + w := firstBuildWarrantIn(ctx) + for _, c := range []struct { + source buildSource + path string + covers bool + }{ + {buildSource{Repository: "novox/mesh-delivery", Seat: "git"}, "", true}, + {buildSource{Repository: "novox/mesh-delivery", Seat: "git"}, "/view/", true}, + {buildSource{Repository: "novox/mesh-delivery", Seat: "git"}, "other", false}, + {buildSource{Repository: "agent/mesh-delivery", Seat: "git"}, "", false}, + {buildSource{Repository: "novox/mesh-delivery"}, "", false}, + {buildSource{Repository: "novox/mesh-delivery", Seat: "elsewhere"}, "", false}, + } { + if got := w.covers(c.source, c.path); got != c.covers { + t.Errorf("1146/AC9: %v at %q covered = %v, want %v", c.source, c.path, got, c.covers) + } + } + if firstBuildWarrantIn(t.Context()).covers(buildSource{Repository: "novox/mesh-delivery", Seat: "git"}, "") { + t.Error("1146/AC9: a context with no warrant covers an ask") + } +} diff --git a/cmd/mesh-controller/first_builds.go b/cmd/mesh-controller/first_builds.go index 7283c44f..bd48ac6a 100644 --- a/cmd/mesh-controller/first_builds.go +++ b/cmd/mesh-controller/first_builds.go @@ -76,7 +76,16 @@ func askFirstBuilds(ctx context.Context, inv *inventory.Inventory, m link.Source } fmt.Printf("%s merged into %s (%.8s) on the operator's Approve (%s): the mesh builds nothing from it yet, so the "+ "first build of %s is asked\n", repository, m.Base, m.Commit, why, strings.Join(added, ", ")) - askBuilds(ctx, inv, m, buildSource{Repository: repository, Seat: gitSeat}, added) + // The ask carries the warrant just checked, for this repository and these module paths alone: the guard of a build + // through a verb (verbMayAsk) lets exactly that through. + paths := make([]string, 0, len(added)) + for _, dir := range added { + if dir == "." { + dir = "" + } + paths = append(paths, dir) + } + askBuilds(withFirstBuildWarrant(ctx, repository, paths, why), inv, m, buildSource{Repository: repository, Seat: gitSeat}, added) // Answered: it no longer waits, so the word's arrival again asks nothing twice. return inv.AnswerMerge(ctx, repository, m.Commit, inventory.WaitingRecord, false) } diff --git a/cmd/mesh-controller/first_builds_integration_test.go b/cmd/mesh-controller/first_builds_integration_test.go index c1b4adb4..dcd75159 100644 --- a/cmd/mesh-controller/first_builds_integration_test.go +++ b/cmd/mesh-controller/first_builds_integration_test.go @@ -39,7 +39,20 @@ func TestAMergeOnTheApproveIntoANewRepositoryAsksTheFirstBuildAndItsOutcomeRegis open := aMesh(t) ctx := t.Context() inv := open.inventory - asked := asksWithPaths(t) + // The real ask, guard and queue included: only its answers are kept, so the build's outcome can be matched. + aForgeAndABuildSeat(t, open) + asked := &[][3]string{} + var ids []string + realAsk := askABuild + askABuild = func(c context.Context, source buildSource, path, ref string) (string, error) { + id, err := realAsk(c, source, path, ref) + if err == nil { + *asked = append(*asked, [3]string{source.Repository, path, ref}) + ids = append(ids, id) + } + return id, err + } + t.Cleanup(func() { askABuild = realAsk }) if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "operator-channel", Version: "1", DefinesSeats: []catalogue.SeatDeclaration{{Name: broker.AsksSeat, Records: []string{"asks"}}}}, inventory.Source{Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/operator-channel", BuiltFrom: "c0ffee"}); err != nil { @@ -129,7 +142,7 @@ func TestAMergeOnTheApproveIntoANewRepositoryAsksTheFirstBuildAndItsOutcomeRegis if len(*asked) != 1 || (*asked)[0] != want { t.Fatalf("1146/AC7: the merge asked %v, want %v once", *asked, want) } - build := onTrunk("b-", "novox/mesh-delivery", "git", "", map[string]any{"module": "mesh-delivery-view", "version": "1"}) + build := onTrunk(ids[0], "novox/mesh-delivery", "git", "", map[string]any{"module": "mesh-delivery-view", "version": "1"}) build.Commit = commit if _, _, err := takeIn(ctx, inv, build); err != nil { t.Fatalf("1146/AC7: the outcome of the first build was refused: %v", err) diff --git a/testdata/store-tests b/testdata/store-tests index 3ba3497a..edf4aeff 100644 --- a/testdata/store-tests +++ b/testdata/store-tests @@ -165,3 +165,4 @@ ./cmd/mesh-controller TestAComposedDeclarationCarriesModulesAndBuildsOnlyForANodeThatReadsThem ./cmd/mesh-controller TestAPlanPassesATierWhoseMachineNeverReportsAfterTheBoundAndSendsTheNextToTheOthers ./cmd/mesh-controller TestAMergeOnTheApproveIntoANewRepositoryAsksTheFirstBuildAndItsOutcomeRegistersTheModule +./cmd/mesh-controller TestTheApprovedFirstBuildPassesTheGuardOfAVerbAndIsQueued