From 4edc0ce90b4d389e278bfbd416c13de1f43da9b7 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 14:31:11 +0100 Subject: [PATCH] =?UTF-8?q?Indexer:=20the=20seat=20mesh-catalog=20serves?= =?UTF-8?q?=20seat-definitions=20and=20says=20seat-definition-changed=20an?= =?UTF-8?q?d=20-removed=20for=20trunks;=20a=20definition=20names=20its=20d?= =?UTF-8?q?omain=20and=20a=20seat=20of=20the=20mesh's=20own=20contracts=20?= =?UTF-8?q?is=20taken=20only=20from=20its=20domain's=20module,=20a=20move?= =?UTF-8?q?=20there=20a=20handover=20in=20either=20order;=20seats=20listed?= =?UTF-8?q?=20from=20the=20module's=20seats/=20directory,=20no=20index=20(?= =?UTF-8?q?D-29=20verb,=20D-I3,=20D-I5,=20ADR=200636=20=C2=A74,=20=C2=A75;?= =?UTF-8?q?=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mesh-delivery-indexer-tools/catalog.go | 7 +- .../cmd/mesh-delivery-indexer/main.go | 2 +- .../packages_integration_test.go | 100 +++++++++-- .../cmd/mesh-delivery-indexer/seatdefs.go | 25 ++- .../internal/index/candidates.go | 4 +- .../internal/index/catalog.go | 27 ++- .../internal/index/catalog_test.go | 15 +- .../internal/index/control_records_test.go | 135 +++++++++++++-- .../internal/index/domain.go | 75 +++++++++ .../internal/index/pending.go | 2 +- .../internal/index/pending_test.go | 2 +- .../internal/index/seatdefs.go | 159 +++++++++++++++--- .../mesh-delivery-indexer/manifest_test.go | 21 ++- modules/mesh-delivery-indexer/module.json | 6 +- 14 files changed, 503 insertions(+), 77 deletions(-) create mode 100644 modules/mesh-delivery-indexer/internal/index/domain.go diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/catalog.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/catalog.go index 7248b5ab..6942c539 100644 --- a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/catalog.go +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/catalog.go @@ -5,6 +5,7 @@ import ( stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/views" "git.novox.be/novox/mesh-delivery/sdk/graph" @@ -12,8 +13,9 @@ import ( ) // catalogRead reads the indexer's modules, manifests and graph states for the seat's verbs. -func catalogRead(modules, manifests, nodes stdio.KeptState) index.CatalogRead { +func catalogRead(modules, manifests, nodes stdio.KeptState, seatDefinitions, marks views.Source) index.CatalogRead { return index.CatalogRead{ + SeatDefinitions: seatDefinitions, Marks: marks, Modules: func() (map[string]delivery.Module, error) { keys, err := modules.Keys() if err != nil { @@ -74,5 +76,6 @@ func catalogRead(modules, manifests, nodes stdio.KeptState) index.CatalogRead { // catalogTools are the seat's verbs over the indexer's own states, served beside the indexer's own tools, since the indexer's manifest claims the seat (ADR 0604 §2). func catalogTools() []stdio.Tool { - return index.CatalogTools(catalogRead(stdio.State("modules"), stdio.State("manifests"), stdio.State(graph.State))) + return index.CatalogTools(catalogRead(stdio.State("modules"), stdio.State("manifests"), stdio.State(graph.State), + keptSource(index.SeatDefinitionsState), keptSource(views.MarksState))) } diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/main.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/main.go index 29cd9376..61ad6c36 100644 --- a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/main.go +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/main.go @@ -411,7 +411,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro Named: namedVia(calls.Call), Trunk: trunkVia(calls.Call), Contains: containsVia(calls.Call), Read: readVia(calls.Call), ReadOpen: openVia(calls.Call), Packages: awaitedStore{packages}, Files: filesVia(calls.Call), GoSum: goSumVia(calls.Call), GoMod: goModVia(calls.Call), OwnBuild: ownBuildVia(calls.Call, cred.Node), Graph: awaitedStore{graph}, - SeatDefinitions: awaitedStore{seatDefs}, ReadFile: readFileVia(calls.Call), SaySeatDefinitionChanged: saySeatDefinitionChanged(js, cred), + SeatDefinitions: awaitedStore{seatDefs}, ReadFile: readFileVia(calls.Call), SaySeatDefinition: saySeatDefinition(js, cred), Message: messageVia(calls.Call), // The take-in step (ADR 0633): its state pending and its stream take-in, both this delivery's, bound when they appear; // until then every merge is taken in at once, as before. diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/packages_integration_test.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/packages_integration_test.go index 4aa6aa26..4e32d014 100644 --- a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/packages_integration_test.go +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/packages_integration_test.go @@ -17,6 +17,7 @@ import ( "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg" "git.novox.be/novox/mesh-sdk/go/buscompose" "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/seatdef" "git.novox.be/novox/mesh-contracts/go/seats/meshdeliverychecker" @@ -80,8 +81,8 @@ func TestAMeshSDKMergeMovesOnlyTheModulesImportingAChangedPackage(t *testing.T) if seatDefs, err = js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: index.SeatDefinitionsBucket, History: 5}); err != nil { t.Fatal(err) } - if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "INDEXER_SEAT_DEFINITIONS", - Subjects: []string{"mesh.stream.mesh-delivery-indexer.event." + index.SeatDefinitionChangedEvent}}); err != nil { + if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "SEAT_MESH_CATALOG", + Subjects: []string{"mesh.seat." + index.CatalogSeat + ".event.>"}}); err != nil { t.Fatal(err) } // DLV-I19, I20: the indexer's findings and takeins states, and the checker's stream asks with the indexer's durable @@ -212,9 +213,15 @@ func TestAMeshSDKMergeMovesOnlyTheModulesImportingAChangedPackage(t *testing.T) // request's head whose definition does not parse is kept as its entry naming why while the trunk's stays; it closes // and its entry goes. const contractsMerge, badHead = "5555555555555555555555555555555555555555", "6666666666666666666666666666666666666666" - git := `{"format":1,"seat":"git","scope":"mesh","grain":"seat-list","decision":"novox/hq ADR 0111","delivers":"git"}` - seatFiles[contractsMerge] = map[string]string{"seats/index.json": `{"format":1,"seats":["git"]}`, "seats/git/seat.json": git} - seatFiles[badHead] = map[string]string{"seats/index.json": `{"format":1,"seats":["git"]}`, "seats/git/seat.json": `{"format":1,"seat":"git"}`} + git := `{"format":1,"seat":"git","scope":"mesh","grain":"seat-list","decision":"novox/hq ADR 0111","domain":"Core","delivers":"git"}` + // No index names the seats: the indexer lists the module's `seats/` directory (ADR 0635 §6, D-I5). + seatFiles[contractsMerge] = map[string]string{"seats/git/seat.json": git} + seatFiles[badHead] = map[string]string{"seats/git/seat.json": `{"format":1,"seat":"git"}`} + // The indexer starts with no definition kept (only the bootstrapped seats are the readers'): the verb seat-definitions + // answers an empty list at once (ADR 0636 §4, §8). + if got, err := index.SeatDefinitionsOf(jsSource{ctx, seatDefs}, nil, ""); err != nil || func() string { raw, _ := json.Marshal(got); return string(raw) }() != `{"revision":0,"seats":[]}` { + t.Errorf("seat-definitions of an indexer that kept none = %+v, %v", got, err) + } contracts := `{"module":"mesh-contracts","version":"1","build":{"artifacts":[{"name":"go","kind":"package","language":"go","paths":["go"],"series":"0.1","generated-from":"."}]}}` merged("novox/mesh-contracts", 80, contractsMerge, forgemsg.Forge{Root: "dddddddddddddddddddddddddddddddddddddddd", Trees: map[string]string{"": "dddddddddddddddddddddddddddddddddddddddd"}, Manifests: map[string]string{"": contracts}}) @@ -255,13 +262,17 @@ func TestAMeshSDKMergeMovesOnlyTheModulesImportingAChangedPackage(t *testing.T) t.Fatalf("%s is kept 20 s after its pull request closed", pk) } } - info, err := js.Stream(ctx, "INDEXER_SEAT_DEFINITIONS") + info, err := js.Stream(ctx, "SEAT_MESH_CATALOG") if err != nil { t.Fatal(err) } - // Said: the trunk's git, and the closed pull request's removal; the unparsed head says nothing. - if n := info.CachedInfo().State.Msgs; n != 2 { - t.Errorf("seat-definition-changed said %d times, want 2", n) + // Said on the seat's subject: the trunk's git alone; a pull request's head (unparsed, then closed) says nothing (ADR 0636 §4). + if n := info.CachedInfo().State.Msgs; n != 1 { + t.Errorf("seat-definition-changed said %d times, want 1", n) + } + if m, err := info.GetLastMsgForSubject(ctx, "mesh.seat.mesh-catalog.event.seat-definition-changed"); err != nil || + !strings.Contains(string(m.Data), `"seat":"git"`) || !strings.Contains(string(m.Data), `"source":"trunk"`) || !strings.Contains(string(m.Data), `"digest":"`+trunk.Digest+`"`) { + t.Errorf("seat-definition-changed = %v, %v; want {seat, source, digest} of the trunk's git", m, err) } // DLV-I20 [i]: a pull request's check verdict naming out-of-step files keeps them; a trunk build end of the builder's log @@ -311,7 +322,7 @@ func TestAMeshSDKMergeMovesOnlyTheModulesImportingAChangedPackage(t *testing.T) // DLV-I19 [i]: indexer_seat_definitions_again for a pull request, asked through the takeins state as the tool asks it, // reads the head with its own manifest; indexer_seat_definition then answers the pull request's entry beside the trunk's. const goodHead = "7777777777777777777777777777777777777777" - seatFiles[goodHead] = map[string]string{"module.json": contracts, "seats/index.json": `{"format":1,"seats":["git"]}`, + seatFiles[goodHead] = map[string]string{"module.json": contracts, "seats/git/seat.json": strings.Replace(git, "ADR 0111", "ADR 0200", 1)} key, value, err := index.NewSeatDefinitionsAgain("mesh-contracts", 83, goodHead, "the forge did not answer", "person.op", time.Now()) if err != nil { @@ -326,6 +337,65 @@ func TestAMeshSDKMergeMovesOnlyTheModulesImportingAChangedPackage(t *testing.T) a.Entry.Commit != goodHead || a.Trunk == nil || a.Trunk.Digest != trunk.Digest { t.Errorf("indexer_seat_definition of the pull request = %+v, %v", a, err) } + + // ADR 0636 §5, D-I3, D-I5: a seat of the mesh's own contracts is taken only from its domain's contracts module, and a move + // there is a handover in either merge order of the two trunks. `wants` (Placement) and `vault` (Identity and access) are + // held by mesh-contracts as an older indexer kept them. + seat := func(name, domain string) string { + return `{"format":1,"seat":"` + name + `","scope":"mesh","grain":"seat-list","decision":"d","domain":"` + domain + `"}` + } + for name, domain := range map[string]string{"wants": "Placement", "vault": "Identity and access"} { + var d seatdef.Definition + if err := json.Unmarshal([]byte(seat(name, domain)), &d); err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(d) + held, _ := json.Marshal(index.SeatDefinitionEntry{Seat: name, Source: index.SourceTrunk, Repository: "novox/mesh-contracts", Module: "mesh-contracts", + Directory: ".", Commit: "old", Digest: "old-" + name, Definition: raw}) + if _, err := seatDefs.Put(ctx, name+".trunk", held); err != nil { + t.Fatal(err) + } + } + const dropped, placement, identity = "8888888888888888888888888888888888888888", "9999999999999999999999999999999999999999", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + seatFiles[dropped] = map[string]string{"seats/git/seat.json": git} // mesh-contracts without wants and vault + seatFiles[placement] = map[string]string{"seats/wants/seat.json": seat("wants", "Placement")} + seatFiles[identity] = map[string]string{"seats/vault/seat.json": seat("vault", "Identity and access")} + domainModule := func(name string) string { + return strings.Replace(contracts, `"mesh-contracts"`, `"`+name+`"`, 1) + } + // Order 1: the domain's module merges first (its own repository) and takes wants over. + merged("novox/mesh-placement", 90, placement, forgemsg.Forge{Root: "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + Trees: map[string]string{"": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"}, Manifests: map[string]string{"": domainModule("mesh-placement-contracts")}}) + entry("wants.trunk", func(e index.SeatDefinitionEntry) bool { + return e.Module == "mesh-placement-contracts" && e.Commit == placement + }) + // mesh-contracts then drops both: wants stays the domain module's; vault, whose module has not merged yet, is kept. + merged("novox/mesh-contracts", 91, dropped, forgemsg.Forge{Root: "dddddddddddddddddddddddddddddddddddddddd", + Trees: map[string]string{"": "dddddddddddddddddddddddddddddddddddddddd"}, Manifests: map[string]string{"": contracts}}) + entry("git.trunk", func(e index.SeatDefinitionEntry) bool { return e.Commit == contractsMerge }) + for _, k := range []string{"wants.trunk", "vault.trunk"} { + if _, err := seatDefs.Get(ctx, k); err != nil { + t.Fatalf("%s is gone after mesh-contracts dropped it: %v", k, err) + } + } + // Order 2 completed: the identity module merges afterwards and takes vault over. + merged("novox/mesh-identity", 92, identity, forgemsg.Forge{Root: "ffffffffffffffffffffffffffffffffffffffff", + Trees: map[string]string{"": "ffffffffffffffffffffffffffffffffffffffff"}, Manifests: map[string]string{"": domainModule("mesh-identity-and-access-contracts")}}) + entry("vault.trunk", func(e index.SeatDefinitionEntry) bool { + return e.Module == "mesh-identity-and-access-contracts" && e.Commit == identity + }) + // The seat list the verb answers: every trunk seat with its digest, the handed-over ones included, never a pull request's. + list, err := index.SeatDefinitionsOf(jsSource{ctx, seatDefs}, nil, "") + raw, _ := json.Marshal(list) + for _, want := range []string{`"seat":"git"`, `"seat":"vault"`, `"seat":"wants"`} { + if err != nil || !strings.Contains(string(raw), want) || strings.Contains(string(raw), `"source":"pull"`) { + t.Errorf("seat-definitions = %s, %v; want %s", raw, err, want) + } + } + // Said: git, wants and vault changed once each beyond the first count; no removal for the handed-over seats. + if m, err := info.GetLastMsgForSubject(ctx, "mesh.seat.mesh-catalog.event.seat-definition-removed"); err == nil { + t.Errorf("a handover said seat-definition-removed: %s", m.Data) + } } // seatFiles are mesh-contracts' files by commit, as gitea_get_file answers them. @@ -354,6 +424,14 @@ func serveForge(t *testing.T, nc *nats.Conn, listing map[string]map[string]strin if a.Repo == "mesh-sdk" && d == "go" && listing[a.Sha] != nil { files[d] = listing[a.Sha] } + if fs, ok := seatFiles[a.Sha]; ok && strings.HasPrefix(d, "seats") { // a contracts module's seats/ directory (D-I5) + files[d] = map[string]string{} + for p := range fs { + if rel, ok := strings.CutPrefix(p, d+"/"); ok { + files[d][rel] = "blob" + } + } + } } out["files"] = files } @@ -365,7 +443,7 @@ func serveForge(t *testing.T, nc *nats.Conn, listing map[string]map[string]strin file, err := nc.Subscribe("mesh.mod.gitea.tool.gitea_get_file", func(m *nats.Msg) { var a struct{ Repo, Path, Ref string } _ = json.Unmarshal(m.Data, &a) - if a.Repo == "mesh-contracts" { // the seat definitions (DLV-I17): a path the commit does not hold is a 404 + if _, held := seatFiles[a.Ref]; held { // the seat definitions (DLV-I17): a path the commit does not hold is a 404 if v, ok := seatFiles[a.Ref][a.Path]; ok { reply(m, map[string]any{"file": map[string]any{"content": v}}) } else { diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/seatdefs.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/seatdefs.go index 1b856272..8ab8f700 100644 --- a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/seatdefs.go +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/seatdefs.go @@ -16,7 +16,7 @@ import ( ) // The seat definitions' wiring (DLV-I17-I20; design 26): the forge's get-file tool reads them, seat-definition-changed -// is said on the indexer's stream indexer-seat-definitions (kept seven days), and the checker's verdict event, heard on +// is said on the seat mesh-catalog's own subject (kept on the shared events stream, seven days), and the checker's verdict event, heard on // the indexer's durable consumer of the checker's stream asks, carries a pull request's out-of-step files. // checkerAsksStream is the checker's stream asks as the bus names it (its manifest's streams; StreamBusName). @@ -28,16 +28,27 @@ func readFileVia(call toolCaller) func(ctx context.Context, repository, sha, pat return goFileVia(call, "") } -// saySeatDefinitionChanged publishes seat-definition-changed on the indexer's own stream, its duplicate id the seat, -// source and digest, so a message delivered again says it once. -func saySeatDefinitionChanged(js jetstream.JetStream, cred process.Account) func(context.Context, index.SeatDefinitionChanged) error { +// seatEventSubject is where the seat mesh-catalog, which the indexer holds, says an event: mesh.seat..event. +// (ADR 0636 §4). The events are the seat's, not the indexer's own, so they are not among the manifest's `emits`. +func seatEventSubject(event string) string { + return "mesh.seat." + index.CatalogSeat + ".event." + event +} + +// saySeatDefinition publishes a trunk's definition moving as seat-definition-changed ({seat, source, digest}), or going as +// seat-definition-removed ({seat}), on the seat's subject; its duplicate id is the seat, event and digest, so a message +// delivered again says it once. Only a trunk's is said (the index package decides); a reader takes no pull request's. +func saySeatDefinition(js jetstream.JetStream, cred process.Account) func(context.Context, index.SeatDefinitionChanged) error { return func(ctx context.Context, c index.SeatDefinitionChanged) error { - data, err := json.Marshal(c) + event, body := index.SeatDefinitionChangedEvent, any(map[string]string{"seat": c.Seat, "source": c.Source, "digest": c.Digest}) + if c.Digest == "" { + event, body = index.SeatDefinitionRemovedEvent, map[string]string{"seat": c.Seat} + } + data, err := json.Marshal(body) if err != nil { return err } - id := index.SeatDefinitionKey(c.Seat, c.Source, c.Repository, c.Pull) + "." + c.Digest - msg := nats.NewMsg(cred.StreamSubject(index.SeatDefinitionChangedEvent)) + id := c.Seat + "." + event + "." + c.Digest + msg := nats.NewMsg(seatEventSubject(event)) msg.Header.Set("x-event-id", id) msg.Header.Set("x-source", cred.Module) msg.Header.Set("x-node", cred.Node) diff --git a/modules/mesh-delivery-indexer/internal/index/candidates.go b/modules/mesh-delivery-indexer/internal/index/candidates.go index 09c15484..2579da30 100644 --- a/modules/mesh-delivery-indexer/internal/index/candidates.go +++ b/modules/mesh-delivery-indexer/internal/index/candidates.go @@ -83,8 +83,8 @@ type Indexer struct { // ReadFile reads one file of a repository at a commit (gitea.gitea_get_file); a file the commit does not hold is // nil with no error. Nil reads no seat definition. ReadFile func(ctx context.Context, repository, sha, path string) ([]byte, error) - // SaySeatDefinitionChanged says seat-definition-changed (seatdefs.go); nil says nothing. - SaySeatDefinitionChanged func(ctx context.Context, c SeatDefinitionChanged) error + // SaySeatDefinition says seat-definition-changed (seatdefs.go); nil says nothing. + SaySeatDefinition func(ctx context.Context, c SeatDefinitionChanged) error // Graph is the module graph's state (graph.Bucket, graph.go; novox/hq ADR 0603 §2); nil writes no graph. Graph Store // Files asks the forge for every blob under each directory at a commit, by directory then path relative to it, each diff --git a/modules/mesh-delivery-indexer/internal/index/catalog.go b/modules/mesh-delivery-indexer/internal/index/catalog.go index c9da450b..adefa91d 100644 --- a/modules/mesh-delivery-indexer/internal/index/catalog.go +++ b/modules/mesh-delivery-indexer/internal/index/catalog.go @@ -10,6 +10,7 @@ import ( stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/views" "git.novox.be/novox/mesh-delivery/sdk/graph" ) @@ -28,6 +29,9 @@ type CatalogRead struct { Manifest func(digest string) (string, error) // Graph is the module graph, by module. Graph func() (graph.Graph, error) + // SeatDefinitions and Marks are the indexer's state of the seat definitions and of the revisions its views said it + // up to, which the verb seat-definitions answers from (seatdefs.go); nil answers the verb nothing. + SeatDefinitions, Marks views.Source } func entryOf(name string, m delivery.Module) catalogue.CatalogEntry { @@ -153,10 +157,7 @@ func (c CatalogRead) graphAndModules() (graph.Graph, map[string]delivery.Module, // seat's contract gives them (novox/hq ADR 0604 §1, §2), answered from the indexer's own states. They are served once the // indexer's manifest claims the seat, in the release after L3's (the lead's decision): until then main serves them not. func CatalogTools(c CatalogRead) []stdio.Tool { - seat, ok := catalogue.SeatNamed(catalogue.CatalogSeat) - if !ok { - return nil - } + seat, _ := catalogue.SeatNamed(CatalogSeat) str := func(args map[string]any, k string) string { s, _ := args[k].(string); return s } runs := map[string]func(map[string]any) (any, error){ "modules": func(map[string]any) (any, error) { return c.CatalogModulesAnswer() }, @@ -167,8 +168,24 @@ func CatalogTools(c CatalogRead) []stdio.Tool { var out []stdio.Tool for _, v := range seat.Serves { if run, ok := runs[v.Name]; ok { - out = append(out, stdio.Tool{Name: catalogue.CatalogSeat + "." + v.Name, Description: v.Description, Input: v.Input, Run: run}) + out = append(out, stdio.Tool{Name: CatalogSeat + "." + v.Name, Description: v.Description, Input: v.Input, Run: run}) } } + if c.SeatDefinitions != nil { + out = append(out, stdio.Tool{Name: CatalogSeat + "." + SeatDefinitionsVerb, Description: SeatDefinitionsDescription, Input: SeatDefinitionsInput, + Run: func(a map[string]any) (any, error) { + return SeatDefinitionsOf(c.SeatDefinitions, c.Marks, str(a, "seat")) + }}) + } return out } + +// SeatDefinitionsDescription and SeatDefinitionsInput are the verb seat-definitions of the seat mesh-catalog (ADR 0636 §4). +const SeatDefinitionsDescription = "The seat list: the definitions of the seats the trunks hold. With no seat, every trunk seat with the digest of " + + "its definition and the revision of the state it is read from; with a seat, that seat's trunk definition, resolved. Only the " + + "merged definitions, never a pull request's (indexer_seat_definition answers those). Every reader of the seat list reads it " + + "here, and follows seat-definition-changed and seat-definition-removed." + +// SeatDefinitionsInput is the verb's input. +var SeatDefinitionsInput = map[string]any{"type": "object", "properties": map[string]any{ + "seat": map[string]any{"type": "string", "description": "one seat's name; absent for the list of every trunk seat"}}} diff --git a/modules/mesh-delivery-indexer/internal/index/catalog_test.go b/modules/mesh-delivery-indexer/internal/index/catalog_test.go index dead2776..f3c2a6e1 100644 --- a/modules/mesh-delivery-indexer/internal/index/catalog_test.go +++ b/modules/mesh-delivery-indexer/internal/index/catalog_test.go @@ -76,6 +76,11 @@ func catalogVerbsAnswerFromTheStates(t *testing.T) { } return g, nil }, + // The verb seat-definitions answers from the seat definitions state: the trunk's git, a pull request's git beside it. + SeatDefinitions: memSource{ + "git.trunk": {Value: []byte(`{"seat":"git","source":"trunk","digest":"d1","definition":{"seat":"git"}}`)}, + SeatDefinitionKey("git", SourcePull, "r", 1): {Value: []byte(`{"seat":"git","source":"pull","digest":"d2"}`)}, + }, } answer := func(v any, err error) []byte { t.Helper() @@ -97,13 +102,21 @@ func catalogVerbsAnswerFromTheStates(t *testing.T) { var names []string for _, tool := range CatalogTools(c) { names = append(names, tool.Name) + if tool.Name == "mesh-catalog.seat-definitions" { + if got, err := tool.Run(nil); err != nil || string(answer(got, nil)) != `{"revision":0,"seats":[{"seat":"git","source":"trunk","digest":"d1"}]}` { + t.Errorf("mesh-catalog.seat-definitions answered %s, %v", answer(got, nil), err) + } + if got, err := tool.Run(map[string]any{"seat": "git"}); err != nil || !strings.Contains(string(answer(got, nil)), `"found":true`) { + t.Errorf("mesh-catalog.seat-definitions of git answered %s, %v", answer(got, nil), err) + } + } if tool.Name == "mesh-catalog.modules" { if got, err := tool.Run(nil); err != nil || !strings.Contains(string(answer(got, nil)), `"module":"avahi"`) { t.Errorf("mesh-catalog.modules answered %v, %v", got, err) } } } - if !slices.Equal(names, []string{"mesh-catalog.modules", "mesh-catalog.module", "mesh-catalog.provides", "mesh-catalog.dependents"}) { + if !slices.Equal(names, []string{"mesh-catalog.modules", "mesh-catalog.module", "mesh-catalog.provides", "mesh-catalog.dependents", "mesh-catalog.seat-definitions"}) { t.Errorf("the tools bundle serves %v", names) } raw := answer(c.CatalogModulesAnswer()) diff --git a/modules/mesh-delivery-indexer/internal/index/control_records_test.go b/modules/mesh-delivery-indexer/internal/index/control_records_test.go index fdb84082..85ab5b3c 100644 --- a/modules/mesh-delivery-indexer/internal/index/control_records_test.go +++ b/modules/mesh-delivery-indexer/internal/index/control_records_test.go @@ -13,6 +13,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/seatdef" "git.novox.be/novox/mesh-sdk/go/views" ) @@ -122,23 +123,36 @@ func TestTheControlRecords(t *testing.T) { t.Run("seat definitions", func(t *testing.T) { const repo, m1, m2, m3, head = "novox/mesh-contracts", "m1", "m2", "m3", "h1" manifest := `{"module":"mesh-contracts","version":"1","build":{"artifacts":[{"name":"go","kind":"package","language":"go","paths":["go"],"series":"0.1","generated-from":"."}]}}` - git := `{"format":1,"seat":"git","scope":"mesh","grain":"seat-list","decision":"novox/hq ADR 0111","delivers":"git"}` + git := `{"format":1,"seat":"git","scope":"mesh","grain":"seat-list","decision":"novox/hq ADR 0111","domain":"Core","delivers":"git"}` files := map[string]map[string]string{ - m1: {"seats/index.json": `{"format":1,"seats":["git","gone"]}`, "seats/git/seat.json": git, "seats/gone/seat.json": strings.Replace(git, `"git"`, `"gone"`, 1)}, - m2: {"seats/index.json": `{"format":1,"seats":["git"]}`, "seats/git/seat.json": `{"format":1,"seat":"git","scope":"mesh","grain":"seat-list","decision":"x","oops":1}`}, - m3: {"seats/index.json": `{"format":1,"seats":["git"]}`, "seats/git/seat.json": strings.Replace(git, "ADR 0111", "ADR 0112", 1)}, - head: {"seats/index.json": `{"format":1,"seats":["git"]}`, "seats/git/seat.json": strings.Replace(git, "ADR 0111", "ADR 0200", 1)}, + m1: {"seats/git/seat.json": git, "seats/gone/seat.json": strings.Replace(git, `"git"`, `"gone"`, 1)}, + m2: {"seats/git/seat.json": `{"format":1,"seat":"git","scope":"mesh","grain":"seat-list","decision":"x","domain":"Core","oops":1}`}, + m3: {"seats/git/seat.json": strings.Replace(git, "ADR 0111", "ADR 0112", 1)}, + head: {"seats/git/seat.json": strings.Replace(git, "ADR 0111", "ADR 0200", 1)}, } defs, findings := newMem(), newMem() var said []SeatDefinitionChanged ix := &Indexer{SeatDefinitions: defs, Findings: findings, Logf: t.Logf, + // The forge's listing of a contracts module's `seats/` directory: every blob under it at the commit (ADR 0635 §6). + Files: func(_ context.Context, _, sha string, dirs []string) (map[string]map[string]string, error) { + out := map[string]map[string]string{} + for _, d := range dirs { + out[d] = map[string]string{} + for p := range files[sha] { + if rel, ok := strings.CutPrefix(p, d+"/"); ok { + out[d][rel] = "blob" + } + } + } + return out, nil + }, ReadFile: func(_ context.Context, r, sha, p string) ([]byte, error) { if v, ok := files[sha][p]; ok && r == repo { return []byte(v), nil } return nil, nil }, - SaySeatDefinitionChanged: func(_ context.Context, c SeatDefinitionChanged) error { said = append(said, c); return nil }} + SaySeatDefinition: func(_ context.Context, c SeatDefinitionChanged) error { said = append(said, c); return nil }} ctx := context.Background() merged := func(merge string) { t.Helper() @@ -186,8 +200,8 @@ func TestTheControlRecords(t *testing.T) { // Ruling (lead, on A2i's finding 4): another repository's trunk defining git is refused, the trunk's entry stands, // nothing is said, and its finding names both repositories; its next merge without git clears the finding. const other = "novox/other-contracts" - files["o1"] = map[string]string{"seats/index.json": `{"format":1,"seats":["git"]}`, "seats/git/seat.json": strings.Replace(git, "ADR 0111", "ADR 0300", 1)} - files["o2"] = map[string]string{"seats/index.json": `{"format":1,"seats":[]}`} + files["o1"] = map[string]string{"seats/git/seat.json": strings.Replace(git, "ADR 0111", "ADR 0300", 1)} + files["o2"] = map[string]string{} read := ix.ReadFile ix.ReadFile = func(ctx context.Context, r, sha, p string) ([]byte, error) { if r == other { @@ -271,7 +285,7 @@ func TestTheControlRecords(t *testing.T) { ix.Manifests.(*memStore).vals[mkey] = kept mod, _ := delivery.EncodeModule(delivery.Module{FP2: "src2:" + strings.Repeat("a", 64), Manifest: delivery.ManifestDigest([]byte(plain)), Repository: repo, Commit: m3, Path: delivery.RootPath}) ix.Modules.(*memStore).vals["mesh-contracts"] = mod - files["h2"] = map[string]string{"module.json": manifest, "seats/index.json": `{"format":1,"seats":["git"]}`, "seats/git/seat.json": strings.Replace(git, "ADR 0111", "ADR 0400", 1)} + files["h2"] = map[string]string{"module.json": manifest, "seats/git/seat.json": strings.Replace(git, "ADR 0111", "ADR 0400", 1)} if r := ix.SeatDefinitionsAgain(ctx, "mesh-contracts", 9, "h2"); !r.Stored { t.Errorf("again for the pull request's head = %+v, want it read with the head's generated-from", r) } @@ -291,8 +305,107 @@ func TestTheControlRecords(t *testing.T) { if _, ok := defs.vals[OutOfStepKey(repo, 9)]; ok { t.Error("a closed pull request's out-of-step files are kept") } - if last := said[len(said)-1]; last != (SeatDefinitionChanged{Seat: "git", Source: SourcePull, Repository: repo, Pull: 9}) { - t.Errorf("last said %+v, want the pull request's removal", last) + for _, c := range said { + if c.Source != SourceTrunk { + t.Errorf("said %+v: only a trunk's definition is said (ADR 0636 §4)", c) + } + } + + // ADR 0636 §5, D-I3, D-I5: a seat of the mesh's own contracts is taken only from its domain's contracts module; a + // move there is a handover in either order of the two trunks; a second definer is refused with a finding naming both. + // The seats `wants` (Placement) and `vault` (Identity and access) are held by mesh-contracts as an older indexer + // kept them; their definitions are listed from the module's `seats/` directory with no index. + seat := func(name, domain string) string { + return `{"format":1,"seat":"` + name + `","scope":"mesh","grain":"seat-list","decision":"d","domain":"` + domain + `"}` + } + for _, name := range []string{"wants", "vault"} { + dom := map[string]string{"wants": "Placement", "vault": "Identity and access"}[name] + var d seatdef.Definition + if err := json.Unmarshal([]byte(seat(name, dom)), &d); err != nil { + t.Fatal(err) + } + raw, digest, _ := digestOf(d) + data, _ := json.Marshal(SeatDefinitionEntry{Seat: name, Source: SourceTrunk, Repository: repo, Module: "mesh-contracts", Directory: ".", + Commit: "old", Digest: digest, Definition: raw}) + defs.vals[name+".trunk"] = data + } + files["c1"] = map[string]string{"seats/wants/seat.json": seat("wants", "Placement"), "seats/vault/seat.json": seat("vault", "Identity and access"), + "seats/lone/seat.json": seat("lone", "Core")} + files["c2"] = map[string]string{"seats/lone/seat.json": seat("lone", "Core")} + files["p1"] = map[string]string{"seats/wants/seat.json": seat("wants", "Placement")} + files["i1"] = map[string]string{"seats/vault/seat.json": seat("vault", "Identity and access")} + domainManifest := func(module string) map[string]string { + return map[string]string{delivery.RootPath: strings.Replace(manifest, `"mesh-contracts"`, `"`+module+`"`, 1)} + } + mergeAt := func(repository, merge string, manifests map[string]string) { + t.Helper() + ix.ReadFile = func(_ context.Context, _, sha, p string) ([]byte, error) { return []byte(files[sha][p]), nil } + if err := ix.ApplySeatDefinitions(ctx, forgemsg.Forge{Kind: forgemsg.ForgeMerged, Repository: repository, Number: 3, Merge: merge, Manifests: manifests}); err != nil { + t.Fatal(err) + } + } + outside := health.FindingKey(health.ScopeModule, findingID("seat-outside-its-domain", "mesh-contracts"), SeatOutsideItsDomainKind) + before = len(said) + // mesh-contracts defines wants and vault, seats of other domains: refused with a finding naming both modules, the + // held entries stand, and lone (Core) is taken. + mergeAt(repo, "c1", map[string]string{delivery.RootPath: manifest}) + if v, ok := findings.vals[outside]; !ok || !strings.Contains(string(v), "mesh-placement-contracts") || !strings.Contains(string(v), "mesh-identity-and-access-contracts") || + !strings.Contains(string(v), "mesh-contracts defines it") { + t.Errorf("finding %s = %s, want it naming both modules", outside, v) + } + if w, _ := entry("wants.trunk"); w.Commit != "old" { + t.Errorf("a refused definition replaced the held one: %+v", w) + } + if l, ok := entry("lone.trunk"); !ok || l.Commit != "c1" { + t.Errorf("a Core seat from mesh-contracts is not taken: %+v (%v)", l, ok) + } + // Order 1: the domain's module merges first (another repository) and takes wants over; mesh-contracts then drops it and + // the entry stays the domain module's, nothing removed. + mergeAt("novox/mesh-placement", "p1", domainManifest("mesh-placement-contracts")) + if w, _ := entry("wants.trunk"); w.Module != "mesh-placement-contracts" || w.Commit != "p1" { + t.Errorf("the domain's module did not take wants over: %+v", w) + } + mergeAt(repo, "c2", map[string]string{delivery.RootPath: manifest}) + if w, _ := entry("wants.trunk"); w.Module != "mesh-placement-contracts" { + t.Errorf("mesh-contracts dropping wants removed or replaced the domain module's entry: %+v", w) + } + if _, ok := findings.vals[outside]; ok { + t.Error("the outside-its-domain finding stands once mesh-contracts defines only its own domain's seats") + } + // Order 2: mesh-contracts drops vault first, and the last trunk definition is kept (no removal said) until the + // domain's module defines it. + if v, _ := entry("vault.trunk"); v.Module != "mesh-contracts" { + t.Fatalf("vault was not kept by mesh-contracts after it dropped it: %+v", v) + } + mergeAt("novox/mesh-identity", "i1", domainManifest("mesh-identity-and-access-contracts")) + if v, _ := entry("vault.trunk"); v.Module != "mesh-identity-and-access-contracts" || v.Commit != "i1" { + t.Errorf("the domain's module did not take vault over: %+v", v) + } + for _, c := range said[before:] { + if c.Digest == "" && (c.Seat == "wants" || c.Seat == "vault") { + t.Errorf("said %+v: a handover removes nothing", c) + } + } + // A pull request of mesh-contracts defining a Placement seat fails its check as a definition that does not parse. + files["h2"] = map[string]string{"seats/wants/seat.json": seat("wants", "Placement")} + ix.ReadFile = func(_ context.Context, _, sha, p string) ([]byte, error) { return []byte(files[sha][p]), nil } + if err := ix.ApplySeatDefinitions(ctx, forgemsg.Forge{Kind: forgemsg.ForgePull, Action: forgemsg.PullUpdated, Repository: repo, Number: 11, Head: "h2", + Dirs: []string{delivery.RootPath}, Manifests: map[string]string{delivery.RootPath: manifest}}); err != nil { + t.Fatal(err) + } + if e, _ := entry(SeatDefinitionKey("wants", SourcePull, repo, 11)); !strings.Contains(e.Unparsed, "mesh-placement-contracts") || e.Definition != nil { + t.Errorf("a pull request's wants from mesh-contracts = %+v, want it refused naming the domain's module", e) + } + // The verb seat-definitions: no seat lists every trunk seat with its digest (never a pull request's); a seat answers it whole. + listed, err := SeatDefinitionsOf(src(), nil, "") + raw, _ := json.Marshal(listed) + if err != nil || !strings.Contains(string(raw), `"seat":"wants","source":"trunk","digest":"`) || strings.Contains(string(raw), "pull") || strings.Contains(string(raw), "_out-of-step") { + t.Errorf("the verb with no seat = %s, %v", raw, err) + } + one, err := SeatDefinitionsOf(src(), nil, "vault") + raw, _ = json.Marshal(one) + if err != nil || !strings.Contains(string(raw), `"found":true`) || !strings.Contains(string(raw), `"definition"`) { + t.Errorf("the verb with a seat = %s, %v", raw, err) } }) t.Run("packages per series", func(t *testing.T) { diff --git a/modules/mesh-delivery-indexer/internal/index/domain.go b/modules/mesh-delivery-indexer/internal/index/domain.go new file mode 100644 index 00000000..25f73fa8 --- /dev/null +++ b/modules/mesh-delivery-indexer/internal/index/domain.go @@ -0,0 +1,75 @@ +package index + +// Where a seat of the mesh's own contracts may be defined (novox/hq ADR 0636 §5, ADR 0634 §7): a definition names its +// glossary domain; the indexer accepts a seat of the mesh's own contracts only from that domain's contracts module +// (mesh--contracts), or from mesh-contracts for a domain with none. A second definer is refused with a finding +// naming both. A definition moving from mesh-contracts to its domain's contracts module is a handover: the new +// definer takes the seat over whichever trunk merges first, and a removal from the module that is not the seat's +// domain's keeps the last trunk definition until the domain's module defines it. + +import ( + "encoding/json" + "fmt" + "slices" + "strings" + + "git.novox.be/novox/mesh-sdk/go/seatdef" +) + +// fallbackContracts is the mesh's contracts module for a domain with no contracts module of its own. +const fallbackContracts = "mesh-contracts" + +// domainsWithContracts are the domains whose seats have a contracts module of their own (ADR 0634 §7): the seven +// the milestone creates. Every other domain's seats are defined in fallbackContracts. +var domainsWithContracts = []string{"Delivery", "Placement", "Provisioning", "Identity and access", "Health and repair", + "Connectivity", "Operator and conversation"} + +// domainContracts is the module that defines a domain's seats: mesh--contracts, the heading lowercased with +// its spaces as dashes, or fallbackContracts for a domain with none. +func domainContracts(domain string) string { + if !slices.Contains(domainsWithContracts, domain) { + return fallbackContracts + } + return "mesh-" + strings.ReplaceAll(strings.ToLower(domain), " ", "-") + "-contracts" +} + +// ownContracts says a contracts module is one of the mesh's own: fallbackContracts or a domain's. +func ownContracts(module string) bool { + return module == fallbackContracts || slices.ContainsFunc(seatdef.Domains, func(d string) bool { + return module == domainContracts(d) && module != fallbackContracts + }) +} + +// domainRefusal is, for a definition a contracts module of the mesh's own holds, why it may not: the words naming the +// module and the domain's. Empty when the module may define the seat (a module outside the mesh's own contracts defines +// what it likes, ADR 0625 §7). +func domainRefusal(module string, d seatdef.Definition) string { + if !ownContracts(module) { + return "" + } + if want := domainContracts(d.Domain); want != module { + return fmt.Sprintf("it is a seat of the domain %q, whose seats %s defines, and %s defines it", d.Domain, want, module) + } + return "" +} + +// domainOf is the domain a kept definition names ("" when it is none or does not decode). +func domainOf(raw json.RawMessage) string { + var d struct { + Domain string `json:"domain"` + } + _ = json.Unmarshal(raw, &d) + return d.Domain +} + +// handsOver says a kept trunk entry is handed over to a definer: the entry is held by a module of the mesh's own +// that is not the seat's domain's, and the definer is the seat's domain's contracts module. +func handsOver(cur SeatDefinitionEntry, module string, d seatdef.Definition) bool { + return cur.Module != module && ownContracts(cur.Module) && domainContracts(d.Domain) == module +} + +// handoverPending says a trunk entry its holder no longer defines waits for its domain's module: the holder is a +// module of the mesh's own and not the seat's domain's, so the last trunk definition stays until that module defines it. +func handoverPending(cur SeatDefinitionEntry) bool { + return ownContracts(cur.Module) && domainContracts(domainOf(cur.Definition)) != cur.Module +} diff --git a/modules/mesh-delivery-indexer/internal/index/pending.go b/modules/mesh-delivery-indexer/internal/index/pending.go index db5c95c2..610957bd 100644 --- a/modules/mesh-delivery-indexer/internal/index/pending.go +++ b/modules/mesh-delivery-indexer/internal/index/pending.go @@ -692,7 +692,7 @@ func (ix *Indexer) dry() *dryRun { // The registry milestone's fields: what a take-in would write on the bus stays out of a dry run, so the states of the // requirements, findings, seat definitions, holds and their bounds are not overlaid, nothing is said, and no hold is // weighed (Holds and Counted nil hold nothing); ReadFile only reads. - Requirements: nil, Findings: nil, SeatDefinitions: nil, ReadFile: ix.ReadFile, SaySeatDefinitionChanged: nil, + Requirements: nil, Findings: nil, SeatDefinitions: nil, ReadFile: ix.ReadFile, SaySeatDefinition: nil, Holds: nil, Counted: nil, AskEngines: nil, HoldBounds: nil} x.Logf = func(format string, args ...any) { ix.logf("take-in, computed and not written: "+format, args...) } x.Store, d.candidates = over(ix.Store) diff --git a/modules/mesh-delivery-indexer/internal/index/pending_test.go b/modules/mesh-delivery-indexer/internal/index/pending_test.go index 277cd4e2..9c664c20 100644 --- a/modules/mesh-delivery-indexer/internal/index/pending_test.go +++ b/modules/mesh-delivery-indexer/internal/index/pending_test.go @@ -521,7 +521,7 @@ func TestTakeIn(t *testing.T) { // 1585/T3-T13 (ADR 0633) for _, f := range []string{"Store", "Modules", "Manifests", "Predefined", "ConfigCandidates", "Taken", "Verdicts", "OldSections", "JudgeID", "SayRefused", "SayAccepted", "Packages", "Graph", "Files", "GoSum", "GoMod", "Now", "Builds", "Named", "Trunk", "Read", "Contains", "Logf", "ReadOpen", "OwnBuild", "Message", "Pending", "SayTakeIn", - "Requirements", "Findings", "SeatDefinitions", "ReadFile", "SaySeatDefinitionChanged", "Holds", "Counted", "AskEngines", "HoldBounds"} { + "Requirements", "Findings", "SeatDefinitions", "ReadFile", "SaySeatDefinition", "Holds", "Counted", "AskEngines", "HoldBounds"} { named[f] = true } ty := reflect.TypeOf(Indexer{}) diff --git a/modules/mesh-delivery-indexer/internal/index/seatdefs.go b/modules/mesh-delivery-indexer/internal/index/seatdefs.go index 537b9695..39dd0aee 100644 --- a/modules/mesh-delivery-indexer/internal/index/seatdefs.go +++ b/modules/mesh-delivery-indexer/internal/index/seatdefs.go @@ -7,7 +7,8 @@ package index // and source, as its only writer. Only the trunk's entry is the seat's contract; a pull request's entries go when it // closes or merges. A trunk definition that does not parse keeps the trunk's entry and writes the finding // seat-definition-unparsed; a trunk defining a seat another repository's trunk already defines is refused with the -// finding seat-defined-twice naming both (a seat has one definition); generated types out of step on a trunk, said by +// finding seat-defined-twice naming both (a seat has one definition); a seat of the mesh's own contracts is taken only +// from its domain's contracts module, and a move there is a handover (ADR 0636 §5, domain.go); generated types out of step on a trunk, said by // the builder's build end, write the finding generated-types-out-of-step, cleared only by a clean build end of the same // contracts module. @@ -36,15 +37,20 @@ const ( // SeatDefinitionsState is the indexer's state of the seat definitions (design 26), one key per seat and source. SeatDefinitionsState = "seat-definitions" SeatDefinitionsBucket = delivery.IndexerModule + "_" + SeatDefinitionsState - // SeatDefinitionChangedEvent is said, kept seven days on the indexer's stream SeatDefinitionsStream, only when a - // definition's digest changed (ADR 0608 §3). + // The seat mesh-catalog's verb and events (ADR 0636 §4): the indexer, which holds the seat, serves the verb and says + // the events on the seat's own subject, for the trunks' definitions only, changed when a digest moved and removed when + // a trunk's definition went. + CatalogSeat = "mesh-catalog" + SeatDefinitionsVerb = "seat-definitions" SeatDefinitionChangedEvent = "seat-definition-changed" - SeatDefinitionsStream = "indexer-seat-definitions" + SeatDefinitionRemovedEvent = "seat-definition-removed" // The findings (ADR 0608 §4, ADR 0564: the indexer writes findings, the condition keeper raises them). SeatDefinitionUnparsedKind = "seat-definition-unparsed" OutOfStepKind = "generated-types-out-of-step" // SeatDefinedTwiceKind: a contracts module's trunk defines a seat another repository's trunk already defines. SeatDefinedTwiceKind = "seat-defined-twice" + // SeatOutsideItsDomainKind: a contracts module of the mesh's own defines a seat of another domain (ADR 0636 §5). + SeatOutsideItsDomainKind = "seat-outside-its-domain" // The three tools (design 26). SeatDefinitionTool = "indexer_seat_definition" SeatDefinitionsAgainTool = "indexer_seat_definitions_again" @@ -75,8 +81,8 @@ type SeatDefinitionEntry struct { At time.Time `json:"at"` } -// SeatDefinitionChanged is the event's body: the seat, its source and the new digest ("" when the source holds the -// seat no more). +// SeatDefinitionChanged is what the indexer says of a definition: the seat, its source and the new digest ("" when the +// source holds the seat no more, which is the event seat-definition-removed). Only a trunk's is said. type SeatDefinitionChanged struct { Seat string `json:"seat"` Source string `json:"source"` @@ -143,9 +149,33 @@ func contractsModules(manifests map[string]string) []contractsModule { return out } -// readDefinitions reads a contracts module's definitions at a commit: its seats' index, each seat's definition and the -// parts it includes, each resolved. A seat that does not parse is answered by its words; a definitions directory -// with no index, or an index that does not parse, is an error naming it. +// listSeats names the seats a contracts module defines at a commit: every directory of its definitions' `seats/` that +// holds a `seat.json`, whatever the seat's grain (ADR 0635 §2, §6), from the forge's one listing of that directory. No +// index file names them. A listing the forge did not answer is a read it did not answer (the message is delivered again). +func (ix *Indexer) listSeats(ctx context.Context, repository, commit string, c contractsModule) ([]string, error) { + if ix.Files == nil { + return nil, fmt.Errorf("%w: the indexer has no listing of %s's seats", errForgeRead, c.module) + } + dir := path.Join(c.defs, "seats") + if c.defs == "." || c.defs == "" { + dir = "seats" + } + got, err := ix.Files(ctx, repository, commit, []string{dir}) + if err != nil { + return nil, fmt.Errorf("%w: the seats of %s at %.12s: %w", errForgeRead, c.module, commit, err) + } + var seats []string + for p := range got[dir] { + if seat, ok := strings.CutSuffix(p, "/seat.json"); ok && seat != "" && !strings.Contains(seat, "/") { + seats = append(seats, seat) + } + } + slices.Sort(seats) + return seats, nil +} + +// readDefinitions reads a contracts module's definitions at a commit: each seat's definition (listSeats) and the +// parts it includes, each resolved. A seat that does not parse is answered by its words. func (ix *Indexer) readDefinitions(ctx context.Context, repository, commit string, c contractsModule) (map[string]seatdef.Definition, map[string]string, error) { file := func(p string) ([]byte, error) { if c.defs != "." && c.defs != "" { @@ -160,17 +190,13 @@ func (ix *Indexer) readDefinitions(ctx context.Context, repository, commit strin } return raw, nil } - raw, err := file("seats/index.json") + seats, err := ix.listSeats(ctx, repository, commit, c) if err != nil { return nil, nil, err } - idx, err := seatdef.ParseIndex(raw) - if err != nil { - return nil, nil, fmt.Errorf("seats/index.json does not parse: %w", err) - } defs, unparsed := map[string]seatdef.Definition{}, map[string]string{} parts := map[string]seatdef.Part{} - for _, seat := range idx.Seats { + for _, seat := range seats { raw, err := file("seats/" + seat + "/seat.json") if err != nil { if errors.Is(err, errForgeRead) { @@ -372,8 +398,13 @@ func (ix *Indexer) keepTrunk(ctx context.Context, repository, commit string, c c } ix.unparsedFinding(ctx, c.module, unparsed) var errs []error - twice := map[string]string{} // seat -> the repository and module whose trunk holds it + twice := map[string]string{} // seat -> the repository and module whose trunk holds it + outside := map[string]string{} // seat -> why its module may not define it (ADR 0636 §5) for seat, d := range defs { + if why := domainRefusal(c.module, d); why != "" { + outside[seat] = why + continue + } holder, err := ix.putDefinition(ctx, SeatDefinitionEntry{Seat: seat, Source: SourceTrunk, Repository: repository, Module: c.module, Directory: c.dir, Commit: commit}, d) if holder != "" { @@ -382,7 +413,9 @@ func (ix *Indexer) keepTrunk(ctx context.Context, repository, commit string, c c errs = append(errs, err) } ix.twiceFinding(ctx, repository, c.module, twice) - // A seat the module's trunk no longer defines leaves the state. + ix.outsideFinding(ctx, c.module, outside) + // A seat the module's trunk no longer defines leaves the state, unless it is handed over: a seat that is not its + // holder's domain's waits for the domain's module (domain.go). keys, err := ix.SeatDefinitions.Keys(ctx) if err != nil { return errors.Join(append(errs, err)...) @@ -395,7 +428,7 @@ func (ix *Indexer) keepTrunk(ctx context.Context, repository, commit string, c c gone := false err := write(ctx, ix.SeatDefinitions, key, func(value []byte, found bool) ([]byte, bool, error) { var e SeatDefinitionEntry - gone = found && json.Unmarshal(value, &e) == nil && e.Repository == repository && e.Module == c.module + gone = found && json.Unmarshal(value, &e) == nil && e.Repository == repository && e.Module == c.module && !handoverPending(e) return nil, gone, nil }) errs = append(errs, err) @@ -419,6 +452,10 @@ func (ix *Indexer) keepPull(ctx context.Context, repository string, pull int, he base := SeatDefinitionEntry{Source: SourcePull, Repository: repository, Pull: pull, Module: c.module, Directory: c.dir, Commit: head} var errs []error for seat, d := range defs { + if why := domainRefusal(c.module, d); why != "" { + unparsed[seat] = why // its check fails on it, as on a definition that does not parse + continue + } e := base e.Seat = seat _, err := ix.putDefinition(ctx, e, d) @@ -442,8 +479,9 @@ func (ix *Indexer) keepPull(ctx context.Context, repository string, pull int, he } // putDefinition writes one parsed definition at its key, said when its digest moved. A trunk's definition of a seat -// another repository's (or module's) trunk already defines is refused, written nowhere: a seat has one definition. The -// refused one answers who holds it (" ()"). +// another repository's (or module's) trunk already defines is refused, written nowhere: a seat has one definition, unless +// the definer is the seat's domain's module taking it over from another module of the mesh's own (a handover, domain.go). +// The refused one answers who holds it (" ()"). func (ix *Indexer) putDefinition(ctx context.Context, e SeatDefinitionEntry, d seatdef.Definition) (string, error) { raw, digest, err := digestOf(d) if err != nil { @@ -455,11 +493,11 @@ func (ix *Indexer) putDefinition(ctx context.Context, e SeatDefinitionEntry, d s var cur SeatDefinitionEntry known := found && json.Unmarshal(value, &cur) == nil moved, holder = false, "" - if known && e.Source == SourceTrunk && (cur.Repository != e.Repository || cur.Module != e.Module) { + if known && e.Source == SourceTrunk && (cur.Repository != e.Repository || cur.Module != e.Module) && !handsOver(cur, e.Module, d) { holder = cur.Repository + " (" + cur.Module + ")" return nil, false, nil } - if known && cur.Digest == digest && cur.Unparsed == "" { + if known && cur.Digest == digest && cur.Unparsed == "" && cur.Repository == e.Repository && cur.Module == e.Module { return nil, false, nil } moved = true @@ -488,6 +526,22 @@ func (ix *Indexer) twiceFinding(ctx context.Context, repository, module string, "No seat on "+module+"'s trunk is defined by another repository.") } +// outsideFinding writes the contracts module's seat-outside-its-domain finding for the seats it defines that belong to +// another domain's module, naming both, or clears it when there are none. +func (ix *Indexer) outsideFinding(ctx context.Context, module string, outside map[string]string) { + var lines []string + for seat, why := range outside { + lines = append(lines, seat+": "+why) + } + slices.Sort(lines) + ix.finding(ctx, findingID("seat-outside-its-domain", module), SeatOutsideItsDomainKind, len(lines) > 0, + fmt.Sprintf("%s's trunk defines seats of another domain, refused: %s", module, strings.Join(lines, "; ")), + "A seat on "+module+"'s trunk belongs to another domain's contracts module", + "A seat of the mesh's own contracts is taken only from its domain's contracts module (mesh-contracts for a domain with none). "+ + "Move the definition there; the next merge of "+module+" without it clears this.", + "Every seat on "+module+"'s trunk is of its domain.") +} + // dropPull removes a pull request's entries and its out-of-step files. func (ix *Indexer) dropPull(ctx context.Context, repository string, pull int) error { keys, err := ix.SeatDefinitions.Keys(ctx) @@ -509,10 +563,10 @@ func (ix *Indexer) dropPull(ctx context.Context, repository string, pull int) er } func (ix *Indexer) sayChanged(ctx context.Context, c SeatDefinitionChanged) { - if ix.SaySeatDefinitionChanged == nil { + if ix.SaySeatDefinition == nil || c.Source != SourceTrunk { return } - if err := ix.SaySeatDefinitionChanged(ctx, c); err != nil { + if err := ix.SaySeatDefinition(ctx, c); err != nil { ix.logf("seat definitions: %s of %s (%s) was not said: %v", SeatDefinitionChangedEvent, c.Seat, c.Source, err) } } @@ -731,3 +785,60 @@ func OutOfStepOf(defs, findings views.Source) ([]OutOfStepRow, error) { } return out, nil } + +// SeatDefinitionList is the verb seat-definitions with no seat (ADR 0636 §4): every trunk seat with the digest of its +// definition, and the revision the views mark of the state names. +type SeatDefinitionList struct { + Revision uint64 `json:"revision"` + Seats []SeatDefinition `json:"seats"` +} + +// SeatDefinition is one seat of the list: the seat, its source (always the trunk) and its digest. +type SeatDefinition struct { + Seat string `json:"seat"` + Source string `json:"source"` + Digest string `json:"digest"` +} + +// SeatDefinitionsOf answers the verb seat-definitions of the seat mesh-catalog (ADR 0636 §4) from the state: with no +// seat, every trunk seat with its digest, by seat; with a seat, that seat's trunk definition (found false when the trunk +// holds none). Never a pull request's: a running part acts on what is merged. +func SeatDefinitionsOf(defs, marks views.Source, seat string) (any, error) { + if seat != "" { + a, err := SeatDefinitionOf(defs, marks, seat, "", 0) + if err != nil { + return nil, err + } + return struct { + Seat string `json:"seat"` + Found bool `json:"found"` + Entry *SeatDefinitionEntry `json:"entry,omitempty"` + Revision uint64 `json:"revision"` + }{a.Seat, a.Found, a.Entry, a.Revision}, nil + } + keys, err := defs.Keys() + if err != nil { + return nil, fmt.Errorf("the %s state cannot be listed: %w", SeatDefinitionsState, err) + } + slices.Sort(keys) + out := SeatDefinitionList{Revision: markOf(marks), Seats: []SeatDefinition{}} + for _, key := range keys { + name, ok := strings.CutSuffix(key, "."+SourceTrunk) + if !ok || strings.HasPrefix(key, outOfStepPrefix) { + continue + } + v, ok, err := defs.Get(key) + if err != nil { + return nil, fmt.Errorf("%s.%s cannot be read: %w", SeatDefinitionsState, key, err) + } + if !ok || v == nil { + continue + } + var e SeatDefinitionEntry + if err := json.Unmarshal(v.Value, &e); err != nil || e.Digest == "" { + continue // an entry that holds no definition is no seat of the list + } + out.Seats = append(out.Seats, SeatDefinition{Seat: name, Source: SourceTrunk, Digest: e.Digest}) + } + return out, nil +} diff --git a/modules/mesh-delivery-indexer/manifest_test.go b/modules/mesh-delivery-indexer/manifest_test.go index 5df53b3f..fbd7d48c 100644 --- a/modules/mesh-delivery-indexer/manifest_test.go +++ b/modules/mesh-delivery-indexer/manifest_test.go @@ -214,16 +214,21 @@ func TestManifestDeclarations(t *testing.T) { // 1238/AC1, 1444 (reads the build } } // ADR 0615 §5, ADR 0619 §2: its views family on its old streams section, 7 days and 256 MiB, dropping its oldest. - // Design 26: seat-definition-changed is kept seven days (DLV-I17). - if want := []stream{{Name: index.ViewsStream, Events: []string{views.EventPattern}, MaxAgeDays: views.StreamDays, MaxMiB: views.StreamMiB}, - {Name: index.SeatDefinitionsStream, Events: []string{index.SeatDefinitionChangedEvent}, MaxAgeDays: 7}}; !reflect.DeepEqual(m.Streams, want) { + // ADR 0636 §4: seat-definition-changed and -removed are the seat mesh-catalog's events, said on the seat's subject and kept + // on the shared events stream: no stream of the indexer's, and not among its own emits. + if want := []stream{{Name: index.ViewsStream, Events: []string{views.EventPattern}, MaxAgeDays: views.StreamDays, MaxMiB: views.StreamMiB}}; !reflect.DeepEqual(m.Streams, want) { t.Errorf("streams = %+v, want %+v", m.Streams, want) } - // ADR 0604 §2: it holds the seat mesh-catalog, serving its four verbs from its tools bundle (which stays without a - // request subject). - if len(m.Claims) != 1 || m.Claims[0].Name != catalogue.CatalogSeat || m.Claims[0].Scope != catalogue.ScopeMesh || - !slices.Equal(m.Claims[0].Serves, []string{"modules", "module", "provides", "dependents"}) { - t.Errorf("claims = %+v, want mesh-catalog with its four verbs", m.Claims) + // ADR 0604 §2, ADR 0636 §4: it holds the seat mesh-catalog, serving its four verbs and seat-definitions from its tools + // bundle (which stays without a request subject). + if len(m.Claims) != 1 || m.Claims[0].Name != index.CatalogSeat || m.Claims[0].Scope != catalogue.ScopeMesh || + !slices.Equal(m.Claims[0].Serves, []string{"modules", "module", "provides", "dependents", index.SeatDefinitionsVerb}) { + t.Errorf("claims = %+v, want mesh-catalog with its five verbs", m.Claims) + } + for _, e := range []string{index.SeatDefinitionChangedEvent, index.SeatDefinitionRemovedEvent} { + if slices.Contains(m.Emits, e) { + t.Errorf("emits lists %s, which the seat says (the validator refuses a seat's event among a holder's emits)", e) + } } // 1585/T11 (ADR 0633 §1, §5): the take-in events are its ordinary events, with no stream of its own. if !slices.Contains(m.Emits, takein.TakeInPendingEvent) || !slices.Contains(m.Emits, takein.TakeInTakenEvent) { diff --git a/modules/mesh-delivery-indexer/module.json b/modules/mesh-delivery-indexer/module.json index ab8b7e4d..db96fd24 100644 --- a/modules/mesh-delivery-indexer/module.json +++ b/modules/mesh-delivery-indexer/module.json @@ -11,7 +11,8 @@ "modules", "module", "provides", - "dependents" + "dependents", + "seat-definitions" ] } ], @@ -88,8 +89,7 @@ { "name": "pending", "history": 1 } ], "streams": [ - { "name": "indexer-views", "events": ["views.**"], "max-age-days": 7, "max-mib": 256 }, - { "name": "indexer-seat-definitions", "events": ["seat-definition-changed"], "max-age-days": 7 } + { "name": "indexer-views", "events": ["views.**"], "max-age-days": 7, "max-mib": 256 } ], "emits": [ "manifest-refused",