diff --git a/modules/mesh-delivery-checker/internal/check/examiner_test.go b/modules/mesh-delivery-checker/internal/check/examiner_test.go index 8b64dee4..836d2a6e 100644 --- a/modules/mesh-delivery-checker/internal/check/examiner_test.go +++ b/modules/mesh-delivery-checker/internal/check/examiner_test.go @@ -1071,12 +1071,17 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) { // try judges the same change as a whole group try, whose manifests come from the indexer's candidate of the try's // group (issue 1921): the same judge, the same verdicts. try bool - // heads has the change touch web-contracts too, a contracts module whose head defines mesh-catalog with a verb of a - // part it includes: the judges get that definition over the list's (ADR 0636 §4, ADR 0633 §6). - heads bool + // heads has the change touch a contracts module too, whose head defines the Delivery seat mesh-catalog with a verb of + // a part it includes: "domain" is the domain's contracts module, so the judges get that definition over the list's + // (ADR 0636 §4, ADR 0633 §6); "outside" is mesh-contracts, whose definition of another domain's seat is not taken + // (ADR 0636 §5); "misnamed" is the domain's module whose seats/mesh-catalog/seat.json names another seat. + heads string }{ {name: "the seat list read is the request's Seats", seats: "read"}, - {name: "the members' heads' definitions over the list's, for the trunk's manifest validator too", seats: "read", indexer: "ok", heads: true}, + {name: "the members' heads' definitions over the list's, for the trunk's manifest validator too", seats: "read", indexer: "ok", heads: "domain"}, + {name: "a head's definition of a seat outside its module's domain is not taken", seats: "read", indexer: "ok", heads: "outside"}, + {name: "a head's definition naming another seat than its directory's fails the judging", seats: "read", heads: "misnamed", + fails: `names the seat "other", not "mesh-catalog"`}, {name: "the seat list not read judges nothing", seats: "unread", fails: "the change's manifests cannot be judged: the seat list is not read (the seat list is held from the bootstrap"}, {name: "a seat definition that does not parse", unparsed: "seats/web-api/seat.json: unexpected end of JSON input", @@ -1218,15 +1223,23 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) { b.fetch.Changed = []string{JudgeDir + "/judge/judge.go"} } p.Modules = []string{"web"} - if row.heads { - p.Modules = append(p.Modules, "web-contracts") - b.ex.Forge = headFiles{ - repoA + " modules/web-contracts/module.json " + head1: `{"module": "web-contracts", "build": {"artifacts": [{"name": "go", "kind": "package", "language": "go", "generated-from": "."}]}}`, - repoA + " modules/web-contracts/seats/mesh-catalog/seat.json " + head1: `{"format":1,"seat":"mesh-catalog","scope":"mesh","grain":"whole","domain":"Delivery","decision":"ADR 1","includes":["lists"],` + - `"verbs":[{"name":"modules","input":{"type":"object"},"answer":{"type":"object"}}]}`, - repoA + " modules/web-contracts/parts/lists.json " + head1: `{"format":1,"part":"lists","verbs":[{"name":"seat-definitions","input":{"type":"object"},"answer":{"type":"object"}}]}`, + if row.heads != "" { + contracts, seat := "mesh-delivery-contracts", "mesh-catalog" + switch row.heads { + case "outside": + contracts = "mesh-contracts" + case "misnamed": + seat = "other" } - b.ex.Trees = headTrees{repoA + " modules/web-contracts/seats " + head1: {"mesh-catalog/seat.json": "1", "mesh-catalog/fixtures/a.json": "2"}} + dir := "modules/" + contracts + p.Modules = append(p.Modules, contracts) + b.ex.Forge = headFiles{ + repoA + " " + dir + "/module.json " + head1: `{"module": "` + contracts + `", "build": {"artifacts": [{"name": "go", "kind": "package", "language": "go", "generated-from": "."}]}}`, + repoA + " " + dir + "/seats/mesh-catalog/seat.json " + head1: `{"format":1,"seat":"` + seat + `","scope":"mesh","grain":"whole","domain":"Delivery","decision":"ADR 1","includes":["lists"],` + + `"verbs":[{"name":"modules","input":{"type":"object"},"answer":{"type":"object"}}]}`, + repoA + " " + dir + "/parts/lists.json " + head1: `{"format":1,"part":"lists","verbs":[{"name":"seat-definitions","input":{"type":"object"},"answer":{"type":"object"}}]}`, + } + b.ex.Trees = headTrees{repoA + " " + dir + "/seats " + head1: {"mesh-catalog/seat.json": "1", "mesh-catalog/fixtures/a.json": "2"}} } p.Fps = map[string]meshcatalog.CandidatesByGroupValuePullsItemFpsValue{"web": {Fp2: "src2:" + strings.Repeat("f", 64), Manifest: web}} // Issue 1921: the judge is built at the group's heads too, also a member that publishes no package yet. @@ -1252,7 +1265,7 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) { }) { t.Errorf("%s: the case says %v, want the new indexer's manifest said accepted by the trunk's manifest validator", row.name, cs.notes) } - if row.seats == "unread" { + if row.seats == "unread" || row.heads == "misnamed" { if v.State != meshdeliverycheckerclient.VerdictFail || !strings.Contains(v.Failed.Words, row.fails) || len(judged) != 0 { t.Errorf("%s: %+v, judged %d, want a fail saying %q before any judge", row.name, v.Failed, len(judged), row.fails) } @@ -1298,7 +1311,7 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) { req.Cases[0].Manifests["mail"].Digest != strings.Repeat("a", 64) { t.Fatalf("%s: the request %+v: %v", row.name, req, err) } - if row.heads { + if row.heads != "" { var got []string for _, raw := range req.Seats { var d struct { @@ -1313,7 +1326,11 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) { } got = append(got, d.Seat) } - if want := []string{"a", "b", "mesh-catalog.seat-definitions.modules"}; !slices.Equal(got, want) { + want := []string{"a", "b", "mesh-catalog.seat-definitions.modules"} + if row.heads == "outside" { + want = want[:2] + } + if !slices.Equal(got, want) { t.Errorf("%s: the trunk's manifest validator's seats %v, want %v: the list's with the head's definition, its part resolved", row.name, got, want) } } else if row.seats == "read" && (len(req.Seats) != 2 || string(req.Seats[0]) != `{"seat":"a"}` || string(req.Seats[1]) != `{"seat":"b"}`) { diff --git a/modules/mesh-delivery-checker/internal/check/headseats.go b/modules/mesh-delivery-checker/internal/check/headseats.go index 0b00ed1c..28d8c980 100644 --- a/modules/mesh-delivery-checker/internal/check/headseats.go +++ b/modules/mesh-delivery-checker/internal/check/headseats.go @@ -5,8 +5,8 @@ package check // group's contracts modules add to a seat (a verb, an event), which the seat list the checker follows (the trunks') does not // hold yet; judged without them, by the trunk's manifest validator above all, the group's new indexer would be refused for // serving its own seat's new verb, and so would every module that calls it. The definitions are read from each member's head -// as the indexer reads a revision's (the contracts module's `seats//seat.json` with the parts it includes), through the -// forge, and put over the list's in the request's Seats. +// with the indexer's own reading (the domain SDK's seatdefs: the contracts module's `seats//seat.json` with the parts it +// includes, and the rule which module may define a seat), through the forge, and put over the list's in the request's Seats. import ( "context" @@ -14,13 +14,10 @@ import ( "errors" "fmt" "maps" - "path" "slices" - "strings" - "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + "git.novox.be/novox/mesh-delivery/sdk/seatdefs" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/seatdef" ) // Trees lists the files of a repository under directories at a commit (the forge's commit trees): {directory: {path @@ -30,35 +27,87 @@ type Trees interface { } // headSeats are the definitions, resolved, that the contracts modules the case's members touch hold at their heads, by -// seat; none where the checker lists no trees. A head's definition that cannot be read or does not parse is an error: the -// manifests cannot be judged by the group's seats then. +// seat; none where the checker lists no trees. They are read and ruled on as the indexer reads and rules on a revision's, +// with the domain SDK's one reading (mesh-delivery sdk/seatdefs): a seat of the mesh's own contracts is taken only from +// its domain's contracts module (ADR 0636 §5), so an outside definer's is left out and said, and a seat two touched modules +// define is refused unless one hands it over to its domain's module. A head's definition that cannot be read, does not +// parse or names another seat than its directory's is an error: the manifests cannot be judged by the group's seats then. func (st *state) headSeats(ctx context.Context) (map[string]json.RawMessage, error) { if st.j.Trees == nil || st.j.Forge == nil { return nil, nil } out := map[string]json.RawMessage{} + by := map[string]string{} // seat -> the module whose head's definition is taken for _, p := range st.c.Pulls { for _, module := range p.Modules { - defs, ok, err := st.contractsDir(ctx, p, module) + c, ok, err := st.contractsModule(ctx, p, module) if err != nil { return nil, err } if !ok { continue } - got, err := st.definitionsAt(ctx, p, module, defs) + defs, unparsed, err := st.headForge().Read(ctx, p.Repository, p.Head, c) if err != nil { - return nil, err + return nil, fmt.Errorf("the seat definitions of %s at the head of %s cannot be read%s: %w", module, pullKey(p), unheardWords(err), err) + } + if len(unparsed) > 0 { + seat := slices.Sorted(maps.Keys(unparsed))[0] + return nil, fmt.Errorf("the seat definition of %s (module %s) at the head of %s is not taken: %s", seat, module, pullKey(p), unparsed[seat]) + } + for _, seat := range slices.Sorted(maps.Keys(defs)) { + d := defs[seat] + if why := seatdefs.Refusal(c.Module, d); why != "" { + st.c.note(fmt.Sprintf("the head of %s's definition of %s is not taken: %s", pullKey(p), seat, why)) + continue + } + if prev, twice := by[seat]; twice && prev != c.Module { + switch { + case seatdefs.HandsOver(prev, c.Module, d): + case seatdefs.HandsOver(c.Module, prev, d): + continue + default: + return nil, fmt.Errorf("the seat %s is defined at the heads by both %s and %s: a seat has one definition", seat, prev, c.Module) + } + } + raw, err := json.Marshal(d) + if err != nil { + return nil, fmt.Errorf("the definition of %s cannot be encoded: %w", seat, err) + } + out[seat], by[seat] = raw, c.Module } - maps.Copy(out, got) } } return out, nil } -// contractsDir is the directory a touched module's definitions live in at its member's head (its package artifact's -// generated-from, under the module's directory), and whether the module is a contracts module at all. -func (st *state) contractsDir(ctx context.Context, p Pull, module string) (string, bool, error) { +// headForge is the domain SDK's reading bound to the checker's forge listing and get-file: a directory or a file the head +// does not hold is none, every other failure the forge's. +func (st *state) headForge() seatdefs.Forge { + return seatdefs.Forge{ + Files: func(ctx context.Context, repository, commit string, dirs []string) (map[string]map[string]string, error) { + got, err := st.j.Trees.Files(ctx, repository, commit, dirs) + if errors.Is(err, ErrNotFound) { + return nil, nil + } + return got, err + }, + File: func(ctx context.Context, repository, commit, file string) ([]byte, error) { + text, err := st.j.Forge.File(ctx, repository, file, commit) + if errors.Is(err, ErrNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return []byte(text), nil + }, + } +} + +// contractsModule is a touched module as a contracts module at its member's head (its package artifact's generated-from, +// under the module's directory), and whether the module is a contracts module at all. +func (st *state) contractsModule(ctx context.Context, p Pull, module string) (seatdefs.Module, bool, error) { var m catalogue.Manifest dir, known := "", false if st.j.Dirs != nil { @@ -67,91 +116,17 @@ func (st *state) contractsDir(ctx context.Context, p Pull, module string) (strin if digest := p.Fps[module].Manifest; known && digest != "" && st.j.Read != nil { var err error if m, err = st.j.Read.Manifest(ctx, digest); err != nil { - return "", false, fmt.Errorf("the manifest of %s at the head of %s cannot be read: %w", module, pullKey(p), err) + return seatdefs.Module{}, false, fmt.Errorf("the manifest of %s at the head of %s cannot be read: %w", module, pullKey(p), err) } } else { var found bool var err error if m, dir, found, err = st.manifestAtHead(ctx, p, module); err != nil || !found { - return "", false, err + return seatdefs.Module{}, false, err } } - if m.Build == nil { - return "", false, nil - } - for _, a := range m.Build.Artifacts { - if a.GeneratedFrom == "" { - continue - } - base := strings.Trim(dir, "/") - if base == meshcatalogclient.RootPath { - base = "" - } - return path.Clean(path.Join(base, a.GeneratedFrom)), true, nil - } - return "", false, nil -} - -// definitionsAt reads every seat a contracts module defines at the member's head, each resolved with its parts. -func (st *state) definitionsAt(ctx context.Context, p Pull, module, defs string) (map[string]json.RawMessage, error) { - under := func(rel string) string { - if defs == "." || defs == "" { - return rel - } - return defs + "/" + rel - } - seatsDir := under("seats") - listed, err := st.j.Trees.Files(ctx, p.Repository, p.Head, []string{seatsDir}) - if errors.Is(err, ErrNotFound) { - return nil, nil - } - if err != nil { - return nil, fmt.Errorf("the seats of %s at the head of %s cannot be listed%s: %w", module, pullKey(p), unheardWords(err), err) - } - read := func(rel string) ([]byte, error) { - text, err := st.j.Forge.File(ctx, p.Repository, under(rel), p.Head) - if err != nil { - return nil, fmt.Errorf("%s of %s at the head of %s cannot be read%s: %w", under(rel), module, pullKey(p), unheardWords(err), err) - } - return []byte(text), nil - } - var seats []string - for file := range listed[seatsDir] { - if seat, ok := strings.CutSuffix(file, "/seat.json"); ok && seat != "" && !strings.Contains(seat, "/") { - seats = append(seats, seat) - } - } - out := map[string]json.RawMessage{} - parts := map[string]seatdef.Part{} - for _, seat := range slices.Sorted(slices.Values(seats)) { - raw, err := read("seats/" + seat + "/seat.json") - if err != nil { - return nil, err - } - d, err := seatdef.Parse(raw) - if err != nil { - return nil, fmt.Errorf("the seat definition of %s (module %s) at the head of %s does not parse: %w", seat, module, pullKey(p), err) - } - for _, name := range d.Includes { - if _, ok := parts[name]; ok { - continue - } - praw, err := read("parts/" + name + ".json") - if err != nil { - return nil, err - } - if parts[name], err = seatdef.ParsePart(praw); err != nil { - return nil, fmt.Errorf("the part %s of %s at the head of %s does not parse: %w", name, module, pullKey(p), err) - } - } - if d, err = seatdef.Resolve(d, parts); err != nil { - return nil, fmt.Errorf("the seat definition of %s (module %s) at the head of %s does not resolve: %w", seat, module, pullKey(p), err) - } - if out[d.Seat], err = json.Marshal(d); err != nil { - return nil, fmt.Errorf("the definition of %s cannot be encoded: %w", d.Seat, err) - } - } - return out, nil + c, ok := seatdefs.Of(dir, m) + return c, ok, nil } // overSeats is the list's definitions with each of the heads' put in place of the one of its seat, or added, sorted by seat. diff --git a/modules/mesh-delivery-indexer/internal/index/domain.go b/modules/mesh-delivery-indexer/internal/index/domain.go index 6b2eef81..6d91f79b 100644 --- a/modules/mesh-delivery-indexer/internal/index/domain.go +++ b/modules/mesh-delivery-indexer/internal/index/domain.go @@ -1,58 +1,16 @@ 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. +// Where a seat of the mesh's own contracts may be defined (novox/hq ADR 0636 §5, ADR 0634 §7): the rule is the domain +// SDK's (mesh-delivery sdk/seatdefs), shared with the checker; here only how the indexer's kept entries meet it. A second +// definer is refused with a finding naming both, unless it is a handover to the seat's domain's module. import ( "encoding/json" - "fmt" - "slices" - "strings" + "git.novox.be/novox/mesh-delivery/sdk/seatdefs" "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 six -// the milestone creates. Every other domain's seats are defined in fallbackContracts. -var domainsWithContracts = []string{"Delivery", "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 { @@ -62,14 +20,12 @@ func domainOf(raw json.RawMessage) string { 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. +// handsOver says a kept trunk entry is handed over to a definer (seatdefs.HandsOver). func handsOver(cur SeatDefinitionEntry, module string, d seatdef.Definition) bool { - return cur.Module != module && ownContracts(cur.Module) && domainContracts(d.Domain) == module + return seatdefs.HandsOver(cur.Module, module, d) } -// 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. +// handoverPending says a trunk entry its holder no longer defines waits for its domain's module (seatdefs.HandoverPending). func handoverPending(cur SeatDefinitionEntry) bool { - return ownContracts(cur.Module) && domainContracts(domainOf(cur.Definition)) != cur.Module + return seatdefs.HandoverPending(cur.Module, domainOf(cur.Definition)) } diff --git a/modules/mesh-delivery-indexer/internal/index/pending_test.go b/modules/mesh-delivery-indexer/internal/index/pending_test.go index 7bbbfa60..9e1870a3 100644 --- a/modules/mesh-delivery-indexer/internal/index/pending_test.go +++ b/modules/mesh-delivery-indexer/internal/index/pending_test.go @@ -211,11 +211,14 @@ func TestTakeIn(t *testing.T) { // 1585/T3-T13 (ADR 0633) // definitions add (ADR 0633 §1, §6): refused without them, with its reason said on the wait and in indexer_pending; // accepted with them, and the revision waits for the build it names. const verb, contracts = "revision-verb", "modules/seat-contracts" + // The third row's definer is the Delivery domain's contracts module defining a Core seat: not taken (ADR 0636 §5). for _, row := range []struct { name string defined bool refused bool - }{{"definitions the revision does not carry", false, true}, {"definitions the revision carries", true, false}} { + module string + }{{"definitions the revision does not carry", false, true, ""}, {"definitions the revision carries", true, false, "seat-contracts"}, + {"definitions of a seat outside the carrier's domain", true, true, "mesh-delivery-contracts"}} { b := newTakeInBed(t) f := b.merge(21, sha('c'), "tree-avahi-3", "tree-indexer-2") f.Manifests = map[string]string{} @@ -232,7 +235,7 @@ func TestTakeIn(t *testing.T) { // 1585/T3-T13 (ADR 0633) f.Locks[d] = l } f.Locks[contracts] = map[string]string{} - f.Manifests[contracts] = `{"module":"seat-contracts","version":"1","build":{"artifacts":[{"name":"go-seats","kind":"package","language":"go","paths":["go"],"series":"0.1","generated-from":"."}]}}` + f.Manifests[contracts] = `{"module":"` + row.module + `","version":"1","build":{"artifacts":[{"name":"go-seats","kind":"package","language":"go","paths":["go"],"series":"0.1","generated-from":"."}]}}` b.ix.Files = func(_ context.Context, repository, commit string, dirs []string) (map[string]map[string]string, error) { if repository != f.Repository || commit != f.Merge || !slices.Equal(dirs, []string{contracts + "/seats"}) { t.Errorf("listed %v of %s at %s", dirs, repository, commit) diff --git a/modules/mesh-delivery-indexer/internal/index/seatdefs.go b/modules/mesh-delivery-indexer/internal/index/seatdefs.go index fb96c29b..87d3125e 100644 --- a/modules/mesh-delivery-indexer/internal/index/seatdefs.go +++ b/modules/mesh-delivery-indexer/internal/index/seatdefs.go @@ -29,7 +29,7 @@ import ( "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg" - "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-delivery/sdk/seatdefs" "git.novox.be/novox/mesh-sdk/go/keys" "git.novox.be/novox/mesh-sdk/go/seatdef" "git.novox.be/novox/mesh-sdk/go/views" @@ -120,128 +120,20 @@ func OutOfStepKey(repository string, pull int) string { } // errForgeRead is a read the forge did not answer (never a file it does not hold): the message is left for redelivery. -var errForgeRead = errors.New("the forge did not answer") +var errForgeRead = seatdefs.ErrUnanswered -// contractsModule is a contracts module of a message: its module, its directory and its definitions' directory. -type contractsModule struct{ module, dir, defs string } +// contractsModule is a contracts module of a message (the domain SDK's reading, seatdefs.Module). +type contractsModule = seatdefs.Module -// contractsModules are the message's module directories whose manifest marks a contracts module (a package artifact -// naming `generated-from`), in directory order. A manifest that does not decode is no contracts module here: the judge -// says why it is refused. +// contractsModules are the message's module directories whose manifest marks a contracts module, in directory order. func contractsModules(manifests map[string]string) []contractsModule { - var out []contractsModule - for dir, raw := range manifests { - m, err := catalogue.Decode([]byte(raw)) - if err != nil || m.Build == nil { - continue - } - for _, a := range m.Build.Artifacts { - if a.GeneratedFrom == "" { - continue - } - base := dir - if base == meshcatalogclient.RootPath { - base = "" - } - out = append(out, contractsModule{module: m.Module, dir: dir, defs: path.Clean(path.Join(base, a.GeneratedFrom))}) - break - } - } - slices.SortFunc(out, func(a, b contractsModule) int { return strings.Compare(a.dir, b.dir) }) - return out + return seatdefs.ContractsModules(manifests) } -// 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. +// readDefinitions reads a contracts module's definitions at a commit with the domain SDK's reading (seatdefs.Forge.Read), +// through the indexer's forge listing and get-file. 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 != "" { - p = c.defs + "/" + p - } - raw, err := ix.ReadFile(ctx, repository, commit, p) - if err != nil { - return nil, fmt.Errorf("%w: %s at %.12s: %w", errForgeRead, p, commit, err) - } - if raw == nil { - return nil, fmt.Errorf("%s at %.12s: the forge holds no such file", p, commit) - } - return raw, nil - } - seats, err := ix.listSeats(ctx, repository, commit, c) - if err != nil { - return nil, nil, err - } - defs, unparsed := map[string]seatdef.Definition{}, map[string]string{} - parts := map[string]seatdef.Part{} - for _, seat := range seats { - raw, err := file("seats/" + seat + "/seat.json") - if err != nil { - if errors.Is(err, errForgeRead) { - return nil, nil, err - } - unparsed[seat] = err.Error() - continue - } - d, err := seatdef.Parse(raw) - if err == nil && d.Seat != seat { - err = fmt.Errorf("it names the seat %q, not %q", d.Seat, seat) - } - if err == nil { - for _, name := range d.Includes { - if _, ok := parts[name]; ok { - continue - } - praw, perr := file("parts/" + name + ".json") - if perr == nil { - var p seatdef.Part - if p, perr = seatdef.ParsePart(praw); perr == nil { - parts[name] = p - } - } - if errors.Is(perr, errForgeRead) { - return nil, nil, perr - } - if perr != nil { - err = fmt.Errorf("its part %s: %w", name, perr) - break - } - } - } - if err == nil { - d, err = seatdef.Resolve(d, parts) - } - if err != nil { - unparsed[seat] = err.Error() - continue - } - defs[seat] = d - } - return defs, unparsed, nil + return seatdefs.Forge{Files: ix.Files, File: ix.ReadFile}.Read(ctx, repository, commit, c) } // digestOf is a resolved definition's bytes and their sha256. @@ -395,27 +287,27 @@ func (ix *Indexer) keepTrunk(ctx context.Context, repository, commit string, c c return err // the forge did not answer: the message is delivered again } if err != nil { - ix.unparsedFinding(ctx, c.module, map[string]string{"seats": err.Error()}) + ix.unparsedFinding(ctx, c.Module, map[string]string{"seats": err.Error()}) return nil } - ix.unparsedFinding(ctx, c.module, unparsed) + ix.unparsedFinding(ctx, c.Module, unparsed) var errs []error 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 != "" { + if why := seatdefs.Refusal(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) + Module: c.Module, Directory: c.Dir, Commit: commit}, d) if holder != "" { twice[seat] = holder } errs = append(errs, err) } - ix.twiceFinding(ctx, repository, c.module, twice) - ix.outsideFinding(ctx, c.module, outside) + ix.twiceFinding(ctx, repository, c.Module, twice) + 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) @@ -430,7 +322,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 && !handoverPending(e) + gone = found && json.Unmarshal(value, &e) == nil && e.Repository == repository && e.Module == c.Module && !handoverPending(e) return nil, gone, nil }) errs = append(errs, err) @@ -451,10 +343,10 @@ func (ix *Indexer) keepPull(ctx context.Context, repository string, pull int, he if err != nil { unparsed = map[string]string{"": err.Error()} } - base := SeatDefinitionEntry{Source: SourcePull, Repository: repository, Pull: pull, Module: c.module, Directory: c.dir, Commit: head} + 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 != "" { + if why := seatdefs.Refusal(c.Module, d); why != "" { unparsed[seat] = why // its check fails on it, as on a definition that does not parse continue } @@ -467,7 +359,7 @@ func (ix *Indexer) keepPull(ctx context.Context, repository string, pull int, he e := base e.Seat, e.Unparsed, e.At = seat, why, ix.now().UTC() if seat == "" { - e.Seat = c.module // the module's index did not parse: no seat is known, the entry is named by the module + e.Seat = c.Module // the module's index did not parse: no seat is known, the entry is named by the module } data, err := json.Marshal(e) if err == nil { diff --git a/modules/mesh-delivery-indexer/internal/index/seatsof.go b/modules/mesh-delivery-indexer/internal/index/seatsof.go index c3c0bba6..1b64e474 100644 --- a/modules/mesh-delivery-indexer/internal/index/seatsof.go +++ b/modules/mesh-delivery-indexer/internal/index/seatsof.go @@ -5,13 +5,15 @@ package index // on top of the seat list the indexer holds, since the manifest the revision names may serve or invoke what the revision's // definitions add (a verb of a seat, an event). The revision's merges are not taken in yet, and a try has no pull request, // so their definitions are not on the bus: they are read from the merges' trees as the indexer reads a merge's (readDefinitions -// of seatdefs.go, the same resolution of the parts), never by a reader of their own. +// of seatdefs.go, the same resolution of the parts and the same rule of which module may define a seat), never by a reader +// of their own. import ( "context" "maps" "slices" + "git.novox.be/novox/mesh-delivery/sdk/seatdefs" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/seatdef" ) @@ -35,9 +37,15 @@ func (ix *Indexer) revisionSeats(ctx context.Context, ready []PendingEntry) ([]c return nil, err } for seat, why := range unparsed { - ix.logf("the revision's definition of %s (%s) does not parse and is left out: %s", seat, c.module, why) + ix.logf("the revision's definition of %s (%s) does not parse and is left out: %s", seat, c.Module, why) + } + for seat, d := range got { + if why := seatdefs.Refusal(c.Module, d); why != "" { + ix.logf("the revision's definition of %s is left out: %s", seat, why) // as keepTrunk refuses it (ADR 0636 §5) + continue + } + defs[seat] = d } - maps.Copy(defs, got) } } return catalogue.SeatsFromDefinitions(slices.Collect(maps.Values(defs))), nil diff --git a/sdk/seatdefs/domain.go b/sdk/seatdefs/domain.go new file mode 100644 index 00000000..d966dd2e --- /dev/null +++ b/sdk/seatdefs/domain.go @@ -0,0 +1,64 @@ +package seatdefs + +// 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; a seat of the mesh's own contracts is taken only from that domain's contracts module +// (mesh--contracts), or from mesh-contracts for a domain with none. A definition moving from mesh-contracts to +// its domain's contracts module is a handover: the new definer takes the seat over, 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 ( + "fmt" + "slices" + "strings" + + "git.novox.be/novox/mesh-sdk/go/seatdef" +) + +// Fallback is the mesh's contracts module for a domain with no contracts module of its own. +const Fallback = "mesh-contracts" + +// domainsWithContracts are the domains whose seats have a contracts module of their own (ADR 0634 §7). Every other +// domain's seats are defined in Fallback. +var domainsWithContracts = []string{"Delivery", "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 Fallback for a domain with none. +func DomainContracts(domain string) string { + if !slices.Contains(domainsWithContracts, domain) { + return Fallback + } + return "mesh-" + strings.ReplaceAll(strings.ToLower(domain), " ", "-") + "-contracts" +} + +// Own says a contracts module is one of the mesh's own: Fallback or a domain's. +func Own(module string) bool { + return module == Fallback || slices.ContainsFunc(seatdef.Domains, func(d string) bool { + return module == DomainContracts(d) && module != Fallback + }) +} + +// Refusal 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 Refusal(module string, d seatdef.Definition) string { + if !Own(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 "" +} + +// HandsOver says a seat its holder defines is handed over to a definer: the holder is a module of the mesh's own that is +// not the definer, and the definer is the seat's domain's contracts module. +func HandsOver(holder, definer string, d seatdef.Definition) bool { + return holder != definer && Own(holder) && DomainContracts(d.Domain) == definer +} + +// HandoverPending says a seat a holder of the mesh's own no longer defines waits for its domain's module: the holder is +// not the domain's (named by the definition it held), so its last definition stays until that module defines it. +func HandoverPending(holder, domain string) bool { + return Own(holder) && DomainContracts(domain) != holder +} diff --git a/sdk/seatdefs/seatdefs.go b/sdk/seatdefs/seatdefs.go new file mode 100644 index 00000000..ca186252 --- /dev/null +++ b/sdk/seatdefs/seatdefs.go @@ -0,0 +1,172 @@ +// Package seatdefs is how a seat definition is read from a contracts module at a commit, and which contracts module may +// define a seat (novox/hq ADR 0608 §2-§3, ADR 0635 §2, ADR 0636 §5, ADR 0634 §1-§2): the one reading the indexer keeps +// in its state seat-definitions and the checker lays over the seat list for a case's heads, so both take the same +// definitions from the same modules. It names no transport: the caller's Forge binds the forge's listing and its +// get-file to its own calls. +package seatdefs + +import ( + "context" + "errors" + "fmt" + "path" + "slices" + "strings" + + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/seatdef" +) + +// ErrUnanswered wraps a read the forge did not answer (never a file or a directory it does not hold): the caller tries +// again rather than taking the module's definitions as gone. +var ErrUnanswered = errors.New("the forge did not answer") + +// Forge is what the reading asks of the forge. +type Forge struct { + // Files lists every file under each directory at a commit, by directory then path relative to it; a directory the + // commit lacks is absent from the answer (no error). + Files func(ctx context.Context, repository, commit string, dirs []string) (map[string]map[string]string, error) + // File reads one file at a commit; a file the commit does not hold is nil with no error. + File func(ctx context.Context, repository, commit, path string) ([]byte, error) +} + +// Module is a contracts module: its name, its directory in the repository and its definitions' directory. +type Module struct{ Module, Dir, Defs string } + +// Of is the contracts module a manifest in a directory is (a package artifact naming `generated-from`), and whether it +// is one at all. +func Of(dir string, m catalogue.Manifest) (Module, bool) { + if m.Build == nil { + return Module{}, false + } + for _, a := range m.Build.Artifacts { + if a.GeneratedFrom == "" { + continue + } + base := strings.Trim(dir, "/") + if base == meshcatalogclient.RootPath { + base = "" + } + return Module{Module: m.Module, Dir: dir, Defs: path.Clean(path.Join(base, a.GeneratedFrom))}, true + } + return Module{}, false +} + +// ContractsModules are the contracts modules among raw manifests by directory, in directory order. A manifest that does +// not decode is no contracts module here: the manifest validator says why it is refused. +func ContractsModules(manifests map[string]string) []Module { + var out []Module + for dir, raw := range manifests { + m, err := catalogue.Decode([]byte(raw)) + if err != nil { + continue + } + if c, ok := Of(dir, m); ok { + out = append(out, c) + } + } + slices.SortFunc(out, func(a, b Module) int { return strings.Compare(a.Dir, b.Dir) }) + return out +} + +// under is a path of the module's definitions' directory. +func (c Module) under(rel string) string { + if c.Defs == "." || c.Defs == "" { + return rel + } + return c.Defs + "/" + rel +} + +// Seats 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. +func (f Forge) Seats(ctx context.Context, repository, commit string, c Module) ([]string, error) { + if f.Files == nil { + return nil, fmt.Errorf("%w: no listing of %s's seats", ErrUnanswered, c.Module) + } + dir := c.under("seats") + got, err := f.Files(ctx, repository, commit, []string{dir}) + if err != nil { + return nil, fmt.Errorf("%w: the seats of %s at %.12s: %w", ErrUnanswered, 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 +} + +// Read reads a contracts module's definitions at a commit: each seat's definition (Seats) and the parts it includes, +// each resolved. A seat whose definition is missing, does not parse, names another seat than its directory's, or whose +// part does not parse or resolve, is answered in unparsed by its words; a read the forge did not answer is an error +// wrapping ErrUnanswered. +func (f Forge) Read(ctx context.Context, repository, commit string, c Module) (defs map[string]seatdef.Definition, unparsed map[string]string, err error) { + if f.File == nil { + return nil, nil, fmt.Errorf("%w: no reader of %s's definitions", ErrUnanswered, c.Module) + } + file := func(rel string) ([]byte, error) { + p := c.under(rel) + raw, err := f.File(ctx, repository, commit, p) + if err != nil { + return nil, fmt.Errorf("%w: %s at %.12s: %w", ErrUnanswered, p, commit, err) + } + if raw == nil { + return nil, fmt.Errorf("%s at %.12s: the forge holds no such file", p, commit) + } + return raw, nil + } + seats, err := f.Seats(ctx, repository, commit, c) + if err != nil { + return nil, nil, err + } + defs, unparsed = map[string]seatdef.Definition{}, map[string]string{} + parts := map[string]seatdef.Part{} + for _, seat := range seats { + raw, err := file("seats/" + seat + "/seat.json") + if err != nil { + if errors.Is(err, ErrUnanswered) { + return nil, nil, err + } + unparsed[seat] = err.Error() + continue + } + d, err := seatdef.Parse(raw) + if err == nil && d.Seat != seat { + err = fmt.Errorf("it names the seat %q, not %q", d.Seat, seat) + } + if err == nil { + for _, name := range d.Includes { + if _, ok := parts[name]; ok { + continue + } + praw, perr := file("parts/" + name + ".json") + if perr == nil { + var p seatdef.Part + if p, perr = seatdef.ParsePart(praw); perr == nil { + parts[name] = p + } + } + if errors.Is(perr, ErrUnanswered) { + return nil, nil, perr + } + if perr != nil { + err = fmt.Errorf("its part %s: %w", name, perr) + break + } + } + } + if err == nil { + d, err = seatdef.Resolve(d, parts) + } + if err != nil { + unparsed[seat] = err.Error() + continue + } + defs[seat] = d + } + return defs, unparsed, nil +}