diff --git a/cmd/mesh-controller/batches.go b/cmd/mesh-controller/batches.go index 1acbe182..b97b7507 100644 --- a/cmd/mesh-controller/batches.go +++ b/cmd/mesh-controller/batches.go @@ -176,6 +176,11 @@ func (f following) hearMerge(ctx context.Context, m link.SourceMoved, now time.T repository, m.Base, m.Commit, already) return nil } + if already == 0 { + if done, err := f.firstBuilds(ctx, m, entries, read, now); err != nil || done { + return err + } + } fmt.Print(nothingReadsIt(repository, m)) return nil } diff --git a/cmd/mesh-controller/first_builds.go b/cmd/mesh-controller/first_builds.go new file mode 100644 index 00000000..58ff68a4 --- /dev/null +++ b/cmd/mesh-controller/first_builds.go @@ -0,0 +1,98 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/novox/mesh-controller/internal/inventory" + "github.com/novox/mesh-controller/internal/link" +) + +// firstBuildWait is how long after the forge made a merge the controller keeps hearing it again for mesh-delivery's word +// on it, which may be on its way when the forge's announcement is heard (novox/hq ADR 0432). +const firstBuildWait = 2 * time.Minute + +// firstBuilds asks the first build of every module a merge adds to a repository no registered module is built from, when +// the merge was announced as made on the operator's Approve of that head (novox/hq ADR 0432, issue 1146): the modules' +// directories are the ones the announcement says hold a module.json at the merge commit, as touchedBy reads them. The +// merge is kept, so the build's outcome is judged by the rule that registers it (mayRegisterFrom, approvedMove). Without +// the Approve nothing is asked, and it says why. Answers whether the merge was dealt with here. +func (f following) firstBuilds(ctx context.Context, m link.SourceMoved, entries []inventory.Entry, + read map[string][]inventory.ReadRepository, now time.Time) (bool, error) { + inv := f.open.inventory + repository := m.Owner + "/" + m.Repo + added, why, err := firstBuildsOf(ctx, inv, m, entries, read, now) + switch { + case errors.Is(err, errFirstNotApproved): + fmt.Printf("%s merged into %s (%.8s) adds %s, and no build is asked: %v\n", repository, m.Base, m.Commit, + strings.Join(addedDirs(m, entries, read), ", "), err) + return true, nil + case err != nil: + return true, notNow(err) + case len(added) == 0: + return false, nil + } + merged, err := time.Parse(time.RFC3339Nano, m.MergedAt) + if err != nil { + merged = now + } + event, err := json.Marshal(keptMerge{SourceMoved: m}) + if err != nil { + return true, err + } + if _, err := inv.AddMerge(ctx, inventory.BatchedMerge{Repository: repository, Branch: m.Base, Commit: m.Commit, + Merged: merged.UTC(), Heard: now, Event: event}); err != nil { + return true, notNow(err) + } + 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) + return true, nil +} + +// errFirstNotApproved says a merge into a repository the mesh builds nothing from is not one the operator approved. +var errFirstNotApproved = errors.New("it is no merge made on the operator's Approve of its head, so a hand `build` " + + "registers a module from a repository the mesh does not build yet") + +// addedDirs are the module directories the merge adds to a repository no registered module is built from, none where +// the merge is not into the trunk or some module is built from the repository already. +func addedDirs(m link.SourceMoved, entries []inventory.Entry, read map[string][]inventory.ReadRepository) []string { + if m.Base != "main" { + return nil + } + for _, e := range entries { + if !e.Provided && sameRepository(e.Source.Repository, m) { + return nil + } + } + _, added, _ := touchedBy(nil, entries, m, read) + return added +} + +// firstBuildsOf is the directories whose first build a merge asks, and the warrant it was made on; none when the merge +// is no first merge into a repository the mesh builds nothing from. errFirstNotApproved when it is, and the operator's +// Approve of its head is not on record, a try-again error while mesh-delivery's word may still be on its way. +func firstBuildsOf(ctx context.Context, inv catalogueReader, m link.SourceMoved, entries []inventory.Entry, + read map[string][]inventory.ReadRepository, now time.Time) ([]string, string, error) { + added := addedDirs(m, entries, read) + if len(added) == 0 { + return nil, "", nil + } + why, err := approvedAnnounced(ctx, inv, m.Owner, m.Repo, keptMerge{SourceMoved: m}) + if err == nil { + return added, why, nil + } + merged, perr := time.Parse(time.RFC3339Nano, m.MergedAt) + if errors.Is(err, errNotAnnounced) && perr == nil && now.Sub(merged) < firstBuildWait { + return nil, "", fmt.Errorf("%w: %w", link.ErrTryAgain, err) + } + var nope error = fmt.Errorf("%w (%v)", errFirstNotApproved, err) + if inventory.Unreachable(err) { + nope = err + } + return nil, "", nope +} diff --git a/cmd/mesh-controller/first_builds_integration_test.go b/cmd/mesh-controller/first_builds_integration_test.go new file mode 100644 index 00000000..f3b51e3e --- /dev/null +++ b/cmd/mesh-controller/first_builds_integration_test.go @@ -0,0 +1,81 @@ +//go:build integration + +package main + +import ( + "context" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/asks" + "github.com/novox/mesh-controller/internal/inventory" + "github.com/novox/mesh-controller/internal/link" +) + +// 1146/AC7: mesh-delivery #1 merged on the operator's Approve into a repository the mesh builds nothing from. The forge's +// announcement, heard as the controller hears it (SourceMoved, hearMerge), asks the first build of the module the merge +// commit holds; the build's outcome registers it; and the module is in the catalogue. Without the Approve nothing is +// asked. Store-backed: skips without one. +func TestAMergeOnTheApproveIntoANewRepositoryAsksTheFirstBuildAndItsOutcomeRegistersTheModule(t *testing.T) { + const head, commit = "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", "0177ea7a00000000" + for _, approved := range []bool{true, false} { + open := aMesh(t) + ctx := t.Context() + inv := open.inventory + asked := asksWithPaths(t) + + a := aDeliveryMergeOf(t, head, map[string]string{"novox/mesh-delivery@a1a1a1a1a1a1": head}) + a.merge.Number = 1 + wasKept, wasAsk := keptDeliveryMerge, routerAskOf + keptDeliveryMerge = func(context.Context, string, string, int, string) (deliveryMerge, bool, error) { + return a.merge, approved, 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 }) + + m := link.SourceMoved{Owner: "novox", Repo: "mesh-delivery", Base: "main", HeadSHA: head, Commit: commit, Number: 1, + 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.Fatalf("approved %v: %v", approved, err) + } + if !approved { + if len(*asked) != 0 { + t.Fatalf("1146/AC7: a merge with no Approve asked %v", *asked) + } + continue + } + want := [3]string{"novox/mesh-delivery", "", "main"} + if len(*asked) != 1 || (*asked)[0] != want { + t.Fatalf("1146/AC7: the merge asked %v, want %v", *asked, want) + } + build := onTrunk("b-", "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) + } + src, err := inv.SourceOf(ctx, "mesh-delivery-view") + if err != nil || src.Repository != "novox/mesh-delivery" { + t.Fatalf("1146/AC7: the module is built from %q (%v)", src.Repository, err) + } + if _, known := inventoryHolds(t, inv, "mesh-delivery-view"); !known { + t.Fatal("1146/AC7: the module is not in the catalogue") + } + } +} + +func inventoryHolds(t *testing.T, inv *inventory.Inventory, module string) (inventory.Entry, bool) { + t.Helper() + entries, err := inv.Catalogued(t.Context()) + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + if e.Manifest.Module == module { + return e, true + } + } + return inventory.Entry{}, false +} diff --git a/cmd/mesh-controller/first_builds_test.go b/cmd/mesh-controller/first_builds_test.go new file mode 100644 index 00000000..d49ec572 --- /dev/null +++ b/cmd/mesh-controller/first_builds_test.go @@ -0,0 +1,77 @@ +package main + +import ( + "context" + "errors" + "slices" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/asks" + "github.com/novox/mesh-controller/internal/inventory" + "github.com/novox/mesh-controller/internal/link" +) + +// 1146/AC6: the first merge into a repository no registered module is built from asks the first build of every module +// the merge commit holds when it was made on the operator's Approve of that head, and asks nothing without it. +func TestAFirstMergeIntoANewRepositoryAsksItsModulesBuildsOnlyOnTheApprove(t *testing.T) { + const head = "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1" + now := time.Date(2026, 10, 19, 0, 6, 0, 0, time.UTC) + mergedAt := now.Add(-time.Hour).Format(time.RFC3339Nano) + heard := link.SourceMoved{Owner: "novox", Repo: "mesh-delivery", Base: "main", HeadSHA: head, Commit: "0177ea7a", + Number: 1, MergedAt: mergedAt, ModuleDirsSaid: true, ModuleDirs: []string{"watchman", "view"}, + Paths: []string{"view/module.json", "watchman/module.json", "README.md"}} + built := inventory.Entry{Source: inventory.Source{Repository: "novox/mesh-catalog", Seat: "git"}} + for _, c := range []struct { + what string + change func(*anApprovedMerge, *link.SourceMoved, *[]inventory.Entry) + unkept bool + dirs []string + refused bool + retry bool + }{ + {what: "the Approve of the merged head", dirs: []string{"view", "watchman"}}, + {what: "a Decline", refused: true, change: func(a *anApprovedMerge, _ *link.SourceMoved, _ *[]inventory.Entry) { + a.warrant.Option, a.warrant.Label, a.merge.Option = "decline", "Decline", "decline" + }}, + {what: "no word of mesh-delivery on a merge made long ago", unkept: true, refused: true}, + {what: "no word yet on a merge just made", unkept: true, retry: true, change: func(_ *anApprovedMerge, m *link.SourceMoved, _ *[]inventory.Entry) { + m.MergedAt = now.Add(-time.Second).Format(time.RFC3339Nano) + }}, + {what: "a module already built from the repository", change: func(_ *anApprovedMerge, _ *link.SourceMoved, e *[]inventory.Entry) { + *e = append(*e, inventory.Entry{Source: inventory.Source{Repository: "novox/mesh-delivery", Seat: "git"}}) + }}, + {what: "a merge into another branch", change: func(_ *anApprovedMerge, m *link.SourceMoved, _ *[]inventory.Entry) { m.Base = "feat" }}, + {what: "a merge that holds no module.json", change: func(_ *anApprovedMerge, m *link.SourceMoved, _ *[]inventory.Entry) { + m.ModuleDirs, m.Paths = nil, []string{"README.md"} + }}, + } { + t.Run(c.what, func(t *testing.T) { + a := aDeliveryMergeOf(t, head, map[string]string{"novox/mesh-delivery@a1a1a1a1a1a1": head}) + a.merge.Number = 1 + m, entries := heard, []inventory.Entry{built} + if c.change != nil { + c.change(&a, &m, &entries) + } + wasKept, wasAsk := keptDeliveryMerge, routerAskOf + t.Cleanup(func() { keptDeliveryMerge, routerAskOf = wasKept, wasAsk }) + keptDeliveryMerge = func(context.Context, string, string, int, string) (deliveryMerge, bool, error) { + return a.merge, !c.unkept, nil + } + routerAskOf = func(context.Context, catalogueReader, string) (*asks.Ask, *asks.Warrant, error) { + return &a.ask, &a.warrant, nil + } + dirs, _, err := firstBuildsOf(t.Context(), nil, m, entries, nil, now) + switch { + case c.retry && !errors.Is(err, link.ErrTryAgain): + t.Fatalf("1146/AC6: %s: want a try-again, got %v", c.what, err) + case c.refused && !errors.Is(err, errFirstNotApproved): + t.Fatalf("1146/AC6: %s: want the Approve missing, got %v (dirs %v)", c.what, err, dirs) + case !c.refused && !c.retry && err != nil: + t.Fatalf("1146/AC6: %s: %v", c.what, err) + case !slices.Equal(dirs, c.dirs): + t.Fatalf("1146/AC6: %s: first builds asked of %v, want %v", c.what, dirs, c.dirs) + } + }) + } +} diff --git a/cmd/mesh-controller/moved.go b/cmd/mesh-controller/moved.go index 456f166b..0344feb8 100644 --- a/cmd/mesh-controller/moved.go +++ b/cmd/mesh-controller/moved.go @@ -236,7 +236,17 @@ func approvedMove(ctx context.Context, inv registrations, forms *sourceForms, bu if !known { return "", fmt.Errorf("the controller kept no merge of %s/%s at %.8s", owner, name, r.Commit) } - k := announcedOf(merged) + return approvedAnnounced(ctx, inv, owner, name, announcedOf(merged)) +} + +// errNotAnnounced says mesh-delivery's word on a merge is not kept (yet): the merge may have been made by someone else, +// or its word may still be on its way. +var errNotAnnounced = errors.New("mesh-delivery announced no such merge") + +// approvedAnnounced is the warrant of the merge the forge announced as k: mesh-delivery announced it, and the ask it made +// it on holds the operator's Approve of that head. Why it holds, or why not. Shared by the approved move of a known +// module and the first build of a new repository's modules (ADR 0420 ยง7, ADR 0432). +func approvedAnnounced(ctx context.Context, inv catalogueReader, owner, name string, k keptMerge) (string, error) { if k.Number <= 0 || k.HeadSHA == "" { return "", errors.New("the forge's announcement of the merge names no pull request or head") } @@ -245,7 +255,7 @@ func approvedMove(ctx context.Context, inv registrations, forms *sourceForms, bu return "", err } if !kept { - return "", fmt.Errorf("mesh-delivery announced no merge of %s/%s#%d at %.8s", owner, name, k.Number, k.HeadSHA) + return "", fmt.Errorf("%w: %s/%s#%d at %.8s", errNotAnnounced, owner, name, k.Number, k.HeadSHA) } ask, w, err := routerAskOf(ctx, inv, d.AskID) if err != nil { diff --git a/cmd/mesh-controller/upgrades.go b/cmd/mesh-controller/upgrades.go index 94d25d4d..95be4d23 100644 --- a/cmd/mesh-controller/upgrades.go +++ b/cmd/mesh-controller/upgrades.go @@ -406,7 +406,12 @@ func askNewModules(ctx context.Context, inv *inventory.Inventory, m link.SourceM if len(added) == 0 || len(from) == 0 { return nil } - source := buildSource{Repository: from[0].Source.Repository, Seat: from[0].Source.Seat} + return askBuilds(ctx, inv, m, buildSource{Repository: from[0].Source.Repository, Seat: from[0].Source.Seat}, added) +} + +// askBuilds asks the first build of each directory of a merge's repository, as askNewModules does, from the source given. +func askBuilds(ctx context.Context, inv *inventory.Inventory, m link.SourceMoved, source buildSource, + added []string) []string { var asked []string for _, dir := range added { path := dir diff --git a/testdata/store-tests b/testdata/store-tests index 970640e6..3ba3497a 100644 --- a/testdata/store-tests +++ b/testdata/store-tests @@ -164,3 +164,4 @@ ./internal/inventory TestWhetherAMachineReadsModulesIsItsLatestWord ./cmd/mesh-controller TestAComposedDeclarationCarriesModulesAndBuildsOnlyForANodeThatReadsThem ./cmd/mesh-controller TestAPlanPassesATierWhoseMachineNeverReportsAfterTheBoundAndSendsTheNextToTheOthers +./cmd/mesh-controller TestAMergeOnTheApproveIntoANewRepositoryAsksTheFirstBuildAndItsOutcomeRegistersTheModule