diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/integration_test.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/integration_test.go index 0c4b668f..1e98288d 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/integration_test.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/integration_test.go @@ -443,7 +443,7 @@ func startBed(t *testing.T) *bed { } // The indexer and the assigner as the owners of the states the checker follows: their read tools and views families // (ADR 0615, DLV-M2). - for _, st := range []string{candidatesState, modulesState, manifestsState, delivery.PackagesState, delivery.IndexerPredefinedState} { + for _, st := range []string{candidatesState, modulesState, manifestsState, delivery.PackagesState, delivery.IndexerPredefinedState, seatDefinitionsState} { firstviewtest.Serve(t, ctx, js, ctl, delivery.IndexerModule, st, "n1_"+delivery.CheckerModule) } firstviewtest.Serve(t, ctx, js, ctl, delivery.AssignerModule, delivery.PlacementState, "n1_"+delivery.CheckerModule) diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/judge.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/judge.go index bbeef436..0aa34c2e 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/judge.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/judge.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "sort" + "strconv" "strings" "sync" "time" @@ -26,6 +27,7 @@ import ( type judgeDeps struct { asks check.Asks renames check.Renames + seatDefs check.SeatDefinitions tools *buildrun.ToolchainSet trunks *check.Trunks kept check.KV @@ -69,6 +71,68 @@ func (k keptPredefined) PredefinedList(ctx context.Context) (json.RawMessage, er return kept.List, nil } +// keptSeatDefinitions reads a pull request's entries of the indexer's seat definitions from its first view: each whose +// `unparsed` says why its head's definition does not parse (the entry's shape is the indexer's read tool's answer). +type keptSeatDefinitions struct { + keys func(ctx context.Context) ([]string, error) + get func(ctx context.Context, key string) ([]byte, error) +} + +// seatDefinitionsOf reads the state's picture (a key gone between the keys and its get is none). +func seatDefinitionsOf(kv jetstream.KeyValue) keptSeatDefinitions { + return keptSeatDefinitions{keys: func(ctx context.Context) ([]string, error) { + keys, err := kv.Keys(ctx) + if errors.Is(err, jetstream.ErrNoKeysFound) { + return nil, nil + } + return keys, err + }, get: func(ctx context.Context, key string) ([]byte, error) { + e, err := kv.Get(ctx, key) + if errors.Is(err, jetstream.ErrKeyNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return e.Value(), nil + }} +} + +func (k keptSeatDefinitions) Unparsed(ctx context.Context, repository string, pull int) ([]check.UnparsedSeat, error) { + suffix := ".pull." + delivery.EscapeName(repository) + "." + strconv.Itoa(pull) + keys, err := k.keys(ctx) + if err != nil { + return nil, err + } + sort.Strings(keys) + var out []check.UnparsedSeat + for _, key := range keys { + if !strings.HasSuffix(key, suffix) { + continue + } + raw, err := k.get(ctx, key) + if err != nil { + return nil, err + } + if raw == nil { + continue + } + var v struct { + Seat string `json:"seat"` + Module string `json:"module"` + Directory string `json:"directory"` + Unparsed string `json:"unparsed"` + } + if err := json.Unmarshal(raw, &v); err != nil { + return nil, fmt.Errorf("%s is no seat definition entry: %w", key, err) + } + if v.Unparsed != "" { + out = append(out, check.UnparsedSeat{Seat: v.Seat, Module: v.Module, Directory: v.Directory, Why: v.Unparsed}) + } + } + return out, nil +} + // nodeNames reads every node's name from the node-engine's have, one key `.node` per node with a record (novox/hq // ADR 0567 §12), at each ask: a node enrolled since is named in the next check. type nodeNames struct{ kv jetstream.KeyValue } @@ -156,7 +220,7 @@ func newJudge(d judgeDeps) check.Judge { nodes = d.nodes } ex := &check.Examiner{Nodes: nodes, Compositions: d.compositions, Asks: d.asks, Toolchains: d.tools, - Kept: check.Kept{Store: d.kept}, Previews: d.previews, Modules: d.repos, Packages: d.repos, Published: d.published, Forge: d.forge, Runs: d.repos, Builds: d.builds, BuildEnds: d.ends, BuildWait: d.buildWait, Announce: d.announce, Trunks: d.trunks, Manifests: d.repos, Read: d.repos, Dirs: d.repos, Predefined: d.predefined, Tags: d.tags, Renames: d.renames, Logf: d.logf} + Kept: check.Kept{Store: d.kept}, Previews: d.previews, Modules: d.repos, Packages: d.repos, Published: d.published, Forge: d.forge, Runs: d.repos, Builds: d.builds, BuildEnds: d.ends, BuildWait: d.buildWait, Announce: d.announce, Trunks: d.trunks, Manifests: d.repos, Read: d.repos, Dirs: d.repos, Predefined: d.predefined, Tags: d.tags, Renames: d.renames, SeatDefinitions: d.seatDefs, Logf: d.logf} if d.nodes != nil && d.builds != nil && d.repos != nil { ex.Serving = runningServes{have: d.nodes, builds: d.builds, read: d.repos, logf: d.logf} } diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/judge_test.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/judge_test.go index 0b88b7f0..f02d7ed3 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/judge_test.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/judge_test.go @@ -4,14 +4,18 @@ import ( "context" "encoding/json" "fmt" + "maps" "os" "reflect" + "slices" "strings" "testing" "time" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" "git.novox.be/novox/mesh-sdk/go/delivery" + + "mesh-delivery-checker/internal/check" ) type memManifests map[string][]byte @@ -142,6 +146,20 @@ func TestThePublishedVersionsAreTheIndexersPackagesState(t *testing.T) { if got := p.LatestPackages(); len(got) != 1 || got[sdk] == "" { t.Errorf("after a delete: %v", got) } + t.Run("a pull request's unparsed seat definitions are its entries of the indexer's seat-definitions", func(t *testing.T) { + state := map[string]string{ + "web-api.trunk": `{"seat":"web-api","source":"trunk","digest":"d"}`, + "web-api.pull." + delivery.EscapeName("novox/web") + ".7": `{"seat":"web-api","source":"pull","module":"web-contracts","directory":"go","unparsed":"seat.json: bad"}`, + "mail-api.pull." + delivery.EscapeName("novox/web") + ".7": `{"seat":"mail-api","source":"pull","digest":"e"}`, + "web-api.pull." + delivery.EscapeName("novox/web") + ".17": `{"seat":"web-api","source":"pull","unparsed":"another pull request's"}`, + } + k := keptSeatDefinitions{keys: func(context.Context) ([]string, error) { return slices.Collect(maps.Keys(state)), nil }, + get: func(_ context.Context, key string) ([]byte, error) { return []byte(state[key]), nil }} + got, err := k.Unparsed(context.Background(), "novox/web", 7) + if want := []check.UnparsedSeat{{Seat: "web-api", Module: "web-contracts", Directory: "go", Why: "seat.json: bad"}}; err != nil || !reflect.DeepEqual(got, want) { + t.Errorf("unparsed of novox/web#7 = %+v, %v; want %+v", got, err, want) + } + }) } // A manifest whose config value falls back to a predefined key (ADR 0348 §7) is read, not logged as unreadable for the diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/main.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/main.go index 9f216641..e0b3de85 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/main.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/main.go @@ -74,8 +74,11 @@ var ( const ( candidatesState = "candidates" - modulesState = "modules" - manifestsState = "manifests" + // seatDefinitionsState is the indexer's state of the seat definitions (design 26), keyed `.trunk` and + // `.pull..`. + seatDefinitionsState = "seat-definitions" + modulesState = "modules" + manifestsState = "manifests" ) // run is the program: it returns when ctx ends or the bus cannot be used. @@ -144,6 +147,8 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro packages := reader.Follow(delivery.IndexerModule, delivery.PackagesState) predefined := reader.Follow(delivery.IndexerModule, delivery.IndexerPredefinedState) placement := reader.Follow(delivery.AssignerModule, delivery.PlacementState) + // The seventh: the indexer's seat definitions, so a pull request whose head's definition does not parse fails. + seatDefs := reader.Follow(delivery.IndexerModule, seatDefinitionsState) stepBound, err := durationEnv(getenv, "MESH_CHECKER_STEP_BOUND", check.DefaultStepBound) if err != nil { @@ -264,6 +269,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro nodes: openNodeNames(ctx, js, logger.Printf), predefined: keptPredefined{predefined}, renames: seatRenames{call: caller.Call}, + seatDefs: seatDefinitionsOf(seatDefs), }) ch.Announce = announce tryer.Judge = ch.Judge diff --git a/modules/mesh-delivery-checker/internal/check/examiner_test.go b/modules/mesh-delivery-checker/internal/check/examiner_test.go index d3519a79..cfd92e1d 100644 --- a/modules/mesh-delivery-checker/internal/check/examiner_test.go +++ b/modules/mesh-delivery-checker/internal/check/examiner_test.go @@ -1041,7 +1041,11 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) { // renames is what the change does to web's renamed-from ("bring", "drop") and what the seat mesh-config's verb // renames answers ("served", "open" on g14, "not served", "unasked" with no seat to ask, "error"); DLV-C5. renames, seat string + // unparsed is the indexer's words of why the pull request's head's seat definition of web-api does not parse. + unparsed string }{ + {name: "a seat definition that does not parse", unparsed: "seats/web-api/seat.json: unexpected end of JSON input", + fails: "novox/mesh-delivery#7: the seat definition of web-api (module web-contracts, go) does not parse: seats/web-api/seat.json: unexpected end of JSON input"}, {name: "a rename brought, the verb not served", renames: "bring", seat: "not served", fails: "web (novox/mesh-delivery#7) renames new-key from old-key: refused, the holder of the seat mesh-config does not serve its verb renames"}, {name: "a rename brought, the verb served", renames: "bring", seat: "served"}, @@ -1122,6 +1126,9 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) { b.ex.Renames = fake } } + if row.unparsed != "" { + b.ex.SeatDefinitions = fakeSeatDefinitions{"novox/mesh-delivery#7": {{Seat: "web-api", Module: "web-contracts", Directory: "go", Why: row.unparsed}}} + } var judged []delivery.ComposeJob answer := b.asks.Answer b.asks.Answer = func(a delivery.StepAsk) delivery.Step { @@ -1178,3 +1185,10 @@ type fakeRenames struct { func (f fakeRenames) Renames(context.Context, string) (meshconfig.RenamesAnswer, error) { return f.answer, f.err } + +// fakeSeatDefinitions are the indexer's unparsed seat definitions by pull request (pullKey). +type fakeSeatDefinitions map[string][]UnparsedSeat + +func (f fakeSeatDefinitions) Unparsed(_ context.Context, repository string, pull int) ([]UnparsedSeat, error) { + return f[fmt.Sprintf("%s#%d", repository, pull)], nil +} diff --git a/modules/mesh-delivery-checker/internal/check/judge.go b/modules/mesh-delivery-checker/internal/check/judge.go index a8e98824..7ec487ca 100644 --- a/modules/mesh-delivery-checker/internal/check/judge.go +++ b/modules/mesh-delivery-checker/internal/check/judge.go @@ -128,6 +128,8 @@ type Examiner struct { // Manifests are the indexer's manifest digests by module: the catalogue a change's manifests are judged in by the // indexer's judge (manifests_judge.go, novox/hq ADR 0578 §2); nil judges no manifest. Manifests TrunkManifests + // SeatDefinitions reads the indexer's seat definitions of a pull request (seatdefs.go, design 26); nil reads none. + SeatDefinitions SeatDefinitions // Renames asks the seat mesh-config's verb renames for a change that brings or drops a `renamed-from` (renames.go, // design 32); nil fails such a change, saying it cannot ask. Renames Renames @@ -273,7 +275,7 @@ func (j *Examiner) Judge(ctx context.Context, c *Case) (Outcome, error) { // 0578 §2), also after a failure: the verdict names every failure, not only the first (the operator, 2026-11-02). tests := st.judgeAll(ctx) st.seriesChanges(ctx) // DLV-C4: named in the verdict's line, whatever the verdict - if v := settled(joinFailures(tests, st.judgeManifests(ctx), st.refuseCycle(ctx), st.judgeRenames(ctx))); v != nil { + if v := settled(joinFailures(tests, st.judgeManifests(ctx), st.refuseCycle(ctx), st.judgeRenames(ctx), st.refuseUnparsed(ctx))); v != nil { return st.finish(ctx, *v), nil } return st.finish(ctx, delivery.Verdict{State: delivery.VerdictPass}), nil diff --git a/modules/mesh-delivery-checker/internal/check/seatdefs.go b/modules/mesh-delivery-checker/internal/check/seatdefs.go new file mode 100644 index 00000000..4669fd27 --- /dev/null +++ b/modules/mesh-delivery-checker/internal/check/seatdefs.go @@ -0,0 +1,55 @@ +package check + +import ( + "context" + "fmt" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// A pull request whose head's seat definition does not parse fails its check, naming the seat and where it lives (design +// 26; the lead's ruling on A2i's finding 1): the indexer keeps such a definition as the pull request's entry of its state +// seat-definitions with `unparsed` set, and cannot refuse the manifest itself, whose digest the trunk may share. + +// UnparsedSeat is one seat definition of a pull request's head that does not parse: the seat (the contracts module's name +// when its index did not parse), its module's directory and the indexer's words of why. +type UnparsedSeat struct { + Seat, Module, Directory, Why string +} + +// SeatDefinitions reads the indexer's seat definitions of one pull request (its state seat-definitions, through its +// first view, DLV-M2). +type SeatDefinitions interface { + Unparsed(ctx context.Context, repository string, pull int) ([]UnparsedSeat, error) +} + +// refuseUnparsed fails the check of each pull request with a seat definition that does not parse; a state that cannot be +// read fails it too, saying so. +func (st *state) refuseUnparsed(ctx context.Context) *delivery.Verdict { + if st.j.SeatDefinitions == nil || st.c.Try != nil { + return nil + } + var out *delivery.Verdict + failed := func(words string) { + f := delivery.FailedFirst{Machine: SeatDefinitionsMachine, Words: words} + if out == nil { + out = &delivery.Verdict{State: delivery.VerdictFail, Failed: f} + return + } + addFailure(out, f) + } + for _, p := range st.c.Pulls { + bad, err := st.j.SeatDefinitions.Unparsed(ctx, p.Repository, p.Number) + if err != nil { + failed(fmt.Sprintf("%s: the indexer's seat definitions of it cannot be read, so none is known to parse: %v", pullKey(p), err)) + continue + } + for _, u := range bad { + failed(fmt.Sprintf("%s: the seat definition of %s (module %s, %s) does not parse: %s", pullKey(p), u.Seat, u.Module, u.Directory, u.Why)) + } + } + return out +} + +// SeatDefinitionsMachine names what refused a seat definition in a verdict's failure. +const SeatDefinitionsMachine = "seat-definitions" diff --git a/modules/mesh-delivery-checker/manifest_test.go b/modules/mesh-delivery-checker/manifest_test.go index 97565506..3fdc27ca 100644 --- a/modules/mesh-delivery-checker/manifest_test.go +++ b/modules/mesh-delivery-checker/manifest_test.go @@ -123,6 +123,7 @@ func TestManifestDeclarations(t *testing.T) { // 1678/AC1 if want := []string{"gitea.gitea_commits", "gitea.gitea_compare", "gitea.gitea_get_file", "gitea.gitea_list_branches", "mesh-delivery-assigner.assigner_read_placement", "mesh-delivery-indexer.indexer_read_candidates", "mesh-delivery-indexer.indexer_read_manifests", "mesh-delivery-indexer.indexer_read_modules", "mesh-delivery-indexer.indexer_read_packages", "mesh-delivery-indexer.indexer_read_predefined", + "mesh-delivery-indexer.indexer_read_seat_definitions", "seat:mesh-config.renames", "seat:node-engine.compose-candidate"}; !reflect.DeepEqual(sorted(m.Invokes), want) { t.Errorf("invokes = %v, want the forge's reads and compose-candidate", m.Invokes) } @@ -133,7 +134,7 @@ func TestManifestDeclarations(t *testing.T) { // 1678/AC1 // And a group's check by the package registry seats' published of its tagged versions (design 51 *Tagged versions*). if want := []string{"go-package-registry.published", "mesh-build-go.toolchain.reported", "mesh-build-node.toolchain.reported", "mesh-delivery-assigner.views.placement", "mesh-delivery-indexer.views.candidates", "mesh-delivery-indexer.views.manifests", "mesh-delivery-indexer.views.modules", - "mesh-delivery-indexer.views.packages", "mesh-delivery-indexer.views.predefined", "npm-package-registry.published"}; !reflect.DeepEqual(sorted(m.Consumes), want) { + "mesh-delivery-indexer.views.packages", "mesh-delivery-indexer.views.predefined", "mesh-delivery-indexer.views.seat-definitions", "npm-package-registry.published"}; !reflect.DeepEqual(sorted(m.Consumes), want) { t.Errorf("consumes = %v, want %v", m.Consumes, want) } if b, ok := m.OwnSecrets["broker"]; !ok || len(m.OwnSecrets) != 1 || b != "${dir:state}/broker" { diff --git a/modules/mesh-delivery-checker/module.json b/modules/mesh-delivery-checker/module.json index 759ea326..76129b4c 100644 --- a/modules/mesh-delivery-checker/module.json +++ b/modules/mesh-delivery-checker/module.json @@ -23,6 +23,7 @@ "mesh-delivery-indexer.views.modules", "mesh-delivery-indexer.views.packages", "mesh-delivery-indexer.views.predefined", + "mesh-delivery-indexer.views.seat-definitions", "npm-package-registry.published" ], "streams": [ @@ -79,6 +80,7 @@ "mesh-delivery-indexer.indexer_read_modules", "mesh-delivery-indexer.indexer_read_packages", "mesh-delivery-indexer.indexer_read_predefined", + "mesh-delivery-indexer.indexer_read_seat_definitions", "seat:mesh-config.renames", "seat:node-engine.compose-candidate" ],