diff --git a/modules/mesh-delivery-contracts/go/seats/meshcatalog/contract.go b/modules/mesh-delivery-contracts/go/seats/meshcatalog/contract.go index 0c49a33c..61f1966b 100644 --- a/modules/mesh-delivery-contracts/go/seats/meshcatalog/contract.go +++ b/modules/mesh-delivery-contracts/go/seats/meshcatalog/contract.go @@ -676,7 +676,7 @@ type ActingRule struct { // which entries it acts on: every, or a language On string `json:"on,omitempty"` Seat string `json:"seat"` - // take-in, validate, check, build, compose, grant or place + // take-in, validate, check, build, compose, grant, place or pipeline Step string `json:"step"` } diff --git a/modules/mesh-delivery-contracts/seats/mesh-catalog/seat.json b/modules/mesh-delivery-contracts/seats/mesh-catalog/seat.json index 04f26cd3..cd1d0387 100644 --- a/modules/mesh-delivery-contracts/seats/mesh-catalog/seat.json +++ b/modules/mesh-delivery-contracts/seats/mesh-catalog/seat.json @@ -1850,7 +1850,7 @@ }, "step": { "type": "string", - "description": "take-in, validate, check, build, compose, grant or place" + "description": "take-in, validate, check, build, compose, grant, place or pipeline" }, "on": { "type": "string", diff --git a/modules/mesh-delivery-contracts/ts/seats/meshcatalog/index.d.ts b/modules/mesh-delivery-contracts/ts/seats/meshcatalog/index.d.ts index 739a2dfd..d26823b6 100644 --- a/modules/mesh-delivery-contracts/ts/seats/meshcatalog/index.d.ts +++ b/modules/mesh-delivery-contracts/ts/seats/meshcatalog/index.d.ts @@ -187,7 +187,7 @@ export interface ActingRule { /** which entries it acts on: every, or a language */ "on"?: string; "seat": string; - /** take-in, validate, check, build, compose, grant or place */ + /** take-in, validate, check, build, compose, grant, place or pipeline */ "step": string; } diff --git a/modules/mesh-delivery-indexer/internal/index/catalog_edges.go b/modules/mesh-delivery-indexer/internal/index/catalog_edges.go new file mode 100644 index 00000000..720745b0 --- /dev/null +++ b/modules/mesh-delivery-indexer/internal/index/catalog_edges.go @@ -0,0 +1,373 @@ +package index + +import ( + "cmp" + "context" + "encoding/json" + "maps" + "slices" + "strings" + + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/seatdef" +) + +// The edges of a catalog graph entry (design 47 G §47 to §50, §121; A-208): the indexer writes, in each entry of a revision, +// the edges between artifact references of kinds 1, 2, 3, 4 and 6 that the entry's own words and the other entries of the same +// revision say (the builder and the assigner read them from the entry, never derive their own). Kind 5 is the acting rules' (§711, below). An edge runs from B, the artifact that comes first, to A, the artifact that waits for it. + +// WithEdges gives each entry of a revision its edges. The entries given are not changed. The edges of kinds 1 to 4 and 6 are +// each entry's own; those of kind 5 (§711) need the others' edges of kinds 1 to 4, so they come after. +func WithEdges(entries map[string]meshcatalog.CatalogEntry) map[string]meshcatalog.CatalogEntry { + out := make(map[string]meshcatalog.CatalogEntry, len(entries)) + for module, e := range entries { + e.Edges = edgesOf(e, entries) + out[module] = e + } + built := pathsTo(out) + for module, e := range out { + if acting := actingEdges(e, out, built); len(acting) > 0 { + e.Edges = slices.Clone(e.Edges) + for _, x := range acting { + if !slices.Contains(e.Edges, x) { + e.Edges = append(e.Edges, x) + } + } + sortEdges(e.Edges) + out[module] = e + } + } + return out +} + +// kind5Steps are the steps of an acting rule that give an edge of kind 5 (§711): a grant or a credential, a composition, a +// placement and a verdict, each made for the dependent's new version. `build` gives kind 3, `take-in` and `validate` none. +var kind5Steps = []string{seatdef.StepGrant, seatdef.StepCompose, seatdef.StepPlace, seatdef.StepCheck, seatdef.StepPipeline} + +// WithActs gives each entry its `acts` (§711): for each seat its manifest claims, each acting rule the seat's definition in +// defs carries, as one item {seat, step, on, bound} sorted by seat then step; an entry claiming no seat with a rule has none. +// The entries given are not changed. +func WithActs(entries map[string]meshcatalog.CatalogEntry, defs map[string]seatdef.Definition) map[string]meshcatalog.CatalogEntry { + out := make(map[string]meshcatalog.CatalogEntry, len(entries)) + for module, e := range entries { + var acts []meshcatalog.ActingRule + for _, c := range manifestOf(e).Claims { + for _, r := range defs[c.Name].Acts { + acts = append(acts, meshcatalog.ActingRule{Seat: c.Name, Step: r.Step, On: r.On, Bound: r.Bound}) + } + } + slices.SortStableFunc(acts, func(x, y meshcatalog.ActingRule) int { + return cmp.Or(strings.Compare(x.Seat, y.Seat), strings.Compare(x.Step, y.Step)) + }) + e.Acts = slices.Compact(acts) + out[module] = e + } + return out +} + +// pathsTo says, for each module, the modules it is built from or needs: those with a path of edges of kinds 1 to 4 to it +// (a package, a fetched artifact, a build module or base, a provider), read from the entries' edges. +func pathsTo(entries map[string]meshcatalog.CatalogEntry) map[string]map[string]bool { + direct := map[string][]string{} // module -> the modules with an edge of kinds 1 to 4 into it + for module, e := range entries { + for _, x := range e.Edges { + if x.Kind >= 1 && x.Kind <= 4 { + direct[module] = append(direct[module], strings.SplitN(x.From, ":", 2)[0]) + } + } + } + out := map[string]map[string]bool{} + for module := range entries { + seen := map[string]bool{} + stack := []string{module} + for len(stack) > 0 { + m := stack[len(stack)-1] + stack = stack[:len(stack)-1] + for _, from := range direct[m] { + if !seen[from] { + seen[from] = true + stack = append(stack, from) + } + } + } + out[module] = seen + } + return out +} + +// actingEdges are the edges of kind 5 into entry a (§711): from the delivered artifact of each other entry B whose acting rule +// in a step of kind5Steps matches a, unless a has no artifact or a has a path of edges +// of kinds 1 to 4 to B (built[b][a]): a is then what B's new version is built from or needs, which B's running version acts on (§270). +func actingEdges(a meshcatalog.CatalogEntry, all map[string]meshcatalog.CatalogEntry, built map[string]map[string]bool) []meshcatalog.Edge { + to := primaryOf(a) + if to == "" { + return nil + } + var out []meshcatalog.Edge + for _, m := range slices.Sorted(maps.Keys(all)) { + b := all[m] + from := primaryOf(b) + if m == a.Module || from == "" || built[m][a.Module] { + continue + } + for _, r := range b.Acts { + if slices.Contains(kind5Steps, r.Step) && ruleActsOn(r.On, a) { + out = append(out, meshcatalog.Edge{Kind: 5, From: from, To: to, Word: "acts", Why: r.Seat + " " + r.Step}) + } + } + } + return out +} + +// ruleActsOn says whether an acting rule's `on` matches entry a: every (or none), a language a has an artifact of, or +// kind:image with an artifact of kind image (§324). +func ruleActsOn(on string, a meshcatalog.CatalogEntry) bool { + switch on { + case "", seatdef.OnEvery: + return true + case seatdef.OnKindImage: + return slices.ContainsFunc(a.Artifacts, func(x meshcatalog.Artifact) bool { return x.Kind == "image" }) + } + return slices.ContainsFunc(a.Artifacts, func(x meshcatalog.Artifact) bool { return seatdef.ActingRule{On: on}.ActsOn(x.Language) }) +} + +func sortEdges(out []meshcatalog.Edge) { + slices.SortFunc(out, func(x, y meshcatalog.Edge) int { + return cmp.Or(cmp.Compare(x.Kind, y.Kind), strings.Compare(x.From, y.From), strings.Compare(x.To, y.To), strings.Compare(x.Word, y.Word), strings.Compare(x.Why, y.Why)) + }) +} + +// primaryOf is the artifact an entry delivers: the one its first process resource runs, else its first artifact; empty for a +// module with no artifact. +func primaryOf(e meshcatalog.CatalogEntry) meshcatalog.ArtifactRef { + if resources, ok := e.Manifest["resources"].([]any); ok { + for _, r := range resources { + res, _ := r.(map[string]any) + if res["type"] != "process" { + continue + } + if name, _ := res["artifact"].(string); name != "" && slices.ContainsFunc(e.Artifacts, func(a meshcatalog.Artifact) bool { return a.Ref == ArtifactRefOf(e.Module, name) }) { + return ArtifactRefOf(e.Module, name) + } + } + } + if len(e.Artifacts) > 0 { + return e.Artifacts[0].Ref + } + return "" +} + +// ofLanguage are the artifacts of e a build of language (go or another) produces: its packages' and programs' of that language. +func ofLanguage(e meshcatalog.CatalogEntry, language string) []meshcatalog.ArtifactRef { + var out []meshcatalog.ArtifactRef + for _, a := range e.Artifacts { + if a.Language == language { + out = append(out, a.Ref) + } + } + return out +} + +func edgesOf(a meshcatalog.CatalogEntry, all map[string]meshcatalog.CatalogEntry) []meshcatalog.Edge { + var out []meshcatalog.Edge + add := func(e meshcatalog.Edge) { + if e.From != "" && e.To != "" && e.From != e.To && !slices.Contains(out, e) { + out = append(out, e) + } + } + modules := slices.Sorted(maps.Keys(all)) + delivers := primaryOf(a) + // Kind 1: a package its build imports, from the artifact that publishes it, to each artifact built from that ecosystem (§47). + for _, name := range slices.Sorted(maps.Keys(a.Packages)) { + for _, m := range modules { + b := all[m] + if m == a.Module { + continue + } + for _, art := range b.Artifacts { + if art.Package == nil || art.Package.Name != name { + continue + } + for _, to := range importersOf(a, art.Package.Ecosystem) { + add(meshcatalog.Edge{Kind: 1, From: art.Ref, To: to, Stale: true, Word: "packages", Why: name}) + } + } + } + } + // Kind 2: an artifact its manifest's `needs` references in another module, published before the artifact that names it + // delivers (A-212, §432). + for _, n := range needsOf(a) { + if owner := all[strings.SplitN(n.ref, ":", 2)[0]]; owner.Module != "" && owner.Module != a.Module && + slices.ContainsFunc(owner.Artifacts, func(x meshcatalog.Artifact) bool { return x.Ref == n.ref }) { + add(meshcatalog.Edge{Kind: 2, From: n.ref, To: n.by, Declared: true, Word: "needs", Why: n.ref}) + } + } + // Kind 3: the build module its language names and the modules it is built on (§47, §378). + for _, language := range slices.Sorted(maps.Keys(a.Toolchains)) { + builder, ok := all[a.Toolchains[language].Module] + if !ok || builder.Module == a.Module { + continue + } + for _, to := range ofLanguage(a, language) { + add(meshcatalog.Edge{Kind: 3, From: primaryOf(builder), To: to, Stale: true, Word: "toolchains", Why: language}) + } + } + for _, base := range a.Bases { + b, ok := all[base] + if !ok || b.Module == a.Module { + continue + } + for _, art := range a.Artifacts { + add(meshcatalog.Edge{Kind: 3, From: primaryOf(b), To: art.Ref, Stale: true, Word: "build.on", Why: base}) + } + } + // Kind 4: a provision it requires and a provider offers (§47): the provisions a want of it waits for. + for _, provision := range a.Awaits { + for _, m := range modules { + if b := all[m]; m != a.Module && (slices.Contains(b.Provides, provision) || m == provision) { + add(meshcatalog.Edge{Kind: 4, From: primaryOf(b), To: delivers, Word: "requires", Why: provision}) + } + } + } + // Kind 6: a verb or a tool it invokes and another entry serves (§47), unless its edge lies on a cycle and is left out (ADR 0614 §3). + // A verb whose answer's fields it relies on (the long form of `invokes`, §558) names them in the edge's `why`. + fields := manifestOf(a).InvokeFields + for _, verb := range a.Invokes { + if slices.Contains(a.LeftOut, verb) { + continue + } + why := verb + if named := fields[verb]; len(named) > 0 { + why += " fields " + strings.Join(named, ", ") + } + for _, m := range modules { + if b := all[m]; m != a.Module && slices.Contains(b.Serves, verb) { + add(meshcatalog.Edge{Kind: 6, From: primaryOf(b), To: delivers, Word: verb, Why: why}) + } + } + } + // Kind 6 again: a package it builds against whose artifact takes `calls` (the capability words its code calls of the tool + // runner) orders it after the artifact that `states` each word, so no manifest names the tool runner's module (§605, §545). + for _, name := range slices.Sorted(maps.Keys(a.Packages)) { + for _, m := range modules { + for _, art := range all[m].Artifacts { + if art.Package == nil || art.Package.Name != name { + continue + } + for _, word := range wordsOf(all[m], art.Ref, "calls") { + for _, other := range modules { + for _, stating := range all[other].Artifacts { + if other != a.Module && slices.Contains(wordsOf(all[other], stating.Ref, "states"), word) { + add(meshcatalog.Edge{Kind: 6, From: stating.Ref, To: delivers, Word: word, Why: "calls of " + name}) + } + } + } + } + } + } + } + sortEdges(out) + return out +} + +// manifestOf is the manifest the entry carries whole, read by the manifest reader; none where it cannot be read. +func manifestOf(e meshcatalog.CatalogEntry) catalogue.Manifest { + raw, err := json.Marshal(e.Manifest) + if err != nil { + return catalogue.Manifest{} + } + m, err := catalogue.Decode(raw) + if err != nil { + return catalogue.Manifest{} + } + return m +} + +// wordsOf are the words an artifact's entry in the entry's manifest lists under key (`calls` or `states`). +func wordsOf(e meshcatalog.CatalogEntry, ref meshcatalog.ArtifactRef, key string) []string { + build, _ := e.Manifest["build"].(map[string]any) + arts, _ := build["artifacts"].([]any) + var out []string + for _, x := range arts { + art, _ := x.(map[string]any) + if name, _ := art["name"].(string); ArtifactRefOf(e.Module, name) != ref { + continue + } + list, _ := art[key].([]any) + for _, w := range list { + if word, _ := w.(string); word != "" { + out = append(out, word) + } + } + } + return out +} + +type need struct{ by, ref string } + +// needsOf are the artifact references each artifact of the entry's manifest names in its `needs`, with the artifact that names them. +func needsOf(e meshcatalog.CatalogEntry) []need { + build, _ := e.Manifest["build"].(map[string]any) + arts, _ := build["artifacts"].([]any) + var out []need + for _, x := range arts { + art, _ := x.(map[string]any) + name, _ := art["name"].(string) + list, _ := art["needs"].([]any) + for _, r := range list { + if ref, _ := r.(string); ref != "" && name != "" { + out = append(out, need{by: ArtifactRefOf(e.Module, name), ref: ref}) + } + } + } + return out +} + +// importersOf are the artifacts of a built from a package of ecosystem: a Go package by the Go artifacts, an npm package by +// the others that are no image. +func importersOf(a meshcatalog.CatalogEntry, ecosystem string) []meshcatalog.ArtifactRef { + var out []meshcatalog.ArtifactRef + for _, art := range a.Artifacts { + if art.Kind == "image" { + continue + } + if (ecosystem == EcosystemGo) == (art.Language == "go") && ecosystem != EcosystemImage { + out = append(out, art.Ref) + } + } + return out +} + +// withActs gives each entry its `acts` from the trunk definitions of the seats the indexer keeps (§711; the state the verb +// seat-definitions answers from); where it keeps none, the entries are left as they are. +func (ix *Indexer) withActs(ctx context.Context, entries map[string]meshcatalog.CatalogEntry) map[string]meshcatalog.CatalogEntry { + if ix.SeatDefinitions == nil { + return entries + } + keys, err := ix.SeatDefinitions.Keys(ctx) + if err != nil { + ix.logf("catalog graph: the acting rules are not read: the %s state cannot be listed: %v", SeatDefinitionsState, err) + return entries + } + defs := map[string]seatdef.Definition{} + for _, key := range keys { + seat, ok := strings.CutSuffix(key, "."+SourceTrunk) + if !ok || strings.HasPrefix(key, outOfStepPrefix) { + continue + } + value, _, found, err := ix.SeatDefinitions.Get(ctx, key) + if err != nil || !found { + continue + } + var e SeatDefinitionEntry + if json.Unmarshal(value, &e) != nil || len(e.Definition) == 0 { + continue + } + var d seatdef.Definition // kept after the format's reader parsed it, so read as it was kept + if json.Unmarshal(e.Definition, &d) == nil { + defs[seat] = d + } + } + return WithActs(entries, defs) +} diff --git a/modules/mesh-delivery-indexer/internal/index/catalog_edges_test.go b/modules/mesh-delivery-indexer/internal/index/catalog_edges_test.go new file mode 100644 index 00000000..8821d0a8 --- /dev/null +++ b/modules/mesh-delivery-indexer/internal/index/catalog_edges_test.go @@ -0,0 +1,226 @@ +package index + +import ( + "context" + "slices" + "testing" + "time" + + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-sdk/go/seatdef" +) + +// Design 47 G §47, §121, A-208, A-212: an entry's edges between artifact references are written from its own words and +// the other entries of the revision, kinds 1 (an imported package), 2 (a `needs`), 3 (build module and bases), 4 (an +// awaited provision) and 6 (an invoked verb, none that lies on a cycle), each from B to A. +func TestAnEntryCarriesItsEdgesOfKindsOneToFourAndSix(t *testing.T) { + entries := map[string]meshcatalog.CatalogEntry{ + "lib": {Module: "lib", Artifacts: []meshcatalog.Artifact{{Ref: "lib:go", Language: "go", Kind: "package", Package: &meshcatalog.ArtifactPackage{Ecosystem: EcosystemGo, Name: "git.example/lib/go"}}}}, + "build-go": {Module: "build-go", Artifacts: []meshcatalog.Artifact{{Ref: "build-go:program", Language: "go"}}, + Provides: []string{"builder"}, Serves: []string{"seat:mesh-builds.build"}}, + "db": {Module: "db", Artifacts: []meshcatalog.Artifact{{Ref: "db:image", Kind: "image"}}, Provides: []string{"sql"}}, + "cfg": {Module: "cfg", Artifacts: []meshcatalog.Artifact{{Ref: "cfg:archive", Kind: "archive"}}}, + "app": {Module: "app", Packages: map[string]string{"git.example/lib/go": "1.2.3"}, Bases: []string{"db"}, + Toolchains: map[string]meshcatalog.BuildEdge{"go": {Module: "build-go"}}, + Awaits: []string{"sql"}, Invokes: []string{"seat:mesh-builds.build", "seat:mesh-builds.cycle"}, LeftOut: []string{"seat:mesh-builds.cycle"}, + Manifest: map[string]any{"build": map[string]any{"artifacts": []any{map[string]any{"name": "program", "needs": []any{"cfg:archive", "nothing:here"}}}}}, + Artifacts: []meshcatalog.Artifact{{Ref: "app:program", Language: "go"}}}, + } + before := entries["app"] + got := WithEdges(entries)["app"].Edges + want := []meshcatalog.Edge{ + {Kind: 1, From: "lib:go", To: "app:program", Stale: true, Word: "packages", Why: "git.example/lib/go"}, + {Kind: 2, From: "cfg:archive", To: "app:program", Declared: true, Word: "needs", Why: "cfg:archive"}, + {Kind: 3, From: "build-go:program", To: "app:program", Stale: true, Word: "toolchains", Why: "go"}, + {Kind: 3, From: "db:image", To: "app:program", Stale: true, Word: "build.on", Why: "db"}, + {Kind: 4, From: "db:image", To: "app:program", Word: "requires", Why: "sql"}, + {Kind: 6, From: "build-go:program", To: "app:program", Word: "seat:mesh-builds.build", Why: "seat:mesh-builds.build"}, + } + if !slices.Equal(got, want) { + t.Errorf("edges\n got %+v\nwant %+v", got, want) + } + if len(before.Edges) != 0 || len(entries["app"].Edges) != 0 { + t.Errorf("the entries given were changed") + } +} + +// Design 47 G A-208: a revision kept holds each entry with its edges, so the builder and the assigner read them from the entry. +func TestAKeptRevisionHoldsTheEdgesOfItsEntries(t *testing.T) { + ctx := context.Background() + ix := &Indexer{Revisions: newMemObjects(1 << 20), Now: func() time.Time { return time.Date(2026, 11, 1, 3, 0, 0, 0, time.UTC) }} + err := ix.KeepBranchEntries(ctx, "main", 1, map[string]meshcatalog.CatalogEntry{ + "b": {Module: "b", Manifest: map[string]any{}, Artifacts: []meshcatalog.Artifact{{Ref: "b:p"}}, Serves: []string{"seat:s.v"}}, + "a": {Module: "a", Manifest: map[string]any{}, Artifacts: []meshcatalog.Artifact{{Ref: "a:p"}}, Invokes: []string{"seat:s.v"}}, + }) + if err != nil { + t.Fatal(err) + } + _, entries, err := ReadRevision(ctx, ix.Revisions, "trunk.1") + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + if e.Module == "a" && (len(e.Edges) != 1 || e.Edges[0].Kind != 6 || e.Edges[0].From != "b:p" || e.Edges[0].To != "a:p") { + t.Errorf("the kept entry of a holds %+v, want one edge of kind 6 from b:p", e.Edges) + } + } +} + +// Design 47 G §558, A-208: a verb invoked in the long form {verb, fields} gives its kind-6 edge the fields it relies on, +// read by the second reader release; the short form names none. +func TestAKindSixEdgeNamesTheFieldsALongFormInvokeRelies(t *testing.T) { + entries := map[string]meshcatalog.CatalogEntry{ + "holder": {Module: "holder", Artifacts: []meshcatalog.Artifact{{Ref: "holder:p"}}, Serves: []string{"seat:s.list", "seat:s.get"}}, + "user": {Module: "user", Artifacts: []meshcatalog.Artifact{{Ref: "user:p"}}, Invokes: []string{"seat:s.get", "seat:s.list"}, + Manifest: map[string]any{"module": "user", "version": "1.0", "invokes": []any{"seat:s.get", map[string]any{"verb": "seat:s.list", "fields": []any{"binding", "node"}}}}}, + } + got := WithEdges(entries)["user"].Edges + if len(got) != 2 || got[0].Why != "seat:s.get" || got[1].Why != "seat:s.list fields binding, node" || got[1].Word != "seat:s.list" { + t.Errorf("edges %+v, want the short verb as it is and the long form with its fields", got) + } +} + +// Design 47 G §605, §545, A-208: a module built against a package whose artifact takes `calls` is ordered after the artifact +// that `states` each word, by an edge of kind 6 naming the word; a module built against no such package has none. +func TestAModuleBuiltOnAPackageThatCallsTheToolRunnerIsOrderedAfterTheArtifactThatStatesTheWords(t *testing.T) { + artifactsOf := func(module string, art map[string]any) map[string]any { + return map[string]any{"module": module, "build": map[string]any{"artifacts": []any{art}}} + } + entries := map[string]meshcatalog.CatalogEntry{ + "sdk": {Module: "sdk", Artifacts: []meshcatalog.Artifact{{Ref: "sdk:go", Kind: "package", Language: "go", Package: &meshcatalog.ArtifactPackage{Ecosystem: EcosystemGo, Name: "git.example/sdk/go"}}}, + Manifest: artifactsOf("sdk", map[string]any{"name": "go", "calls": []any{"put-sessions", "finding-calls"}})}, + "runner": {Module: "runner", Artifacts: []meshcatalog.Artifact{{Ref: "runner:runtime"}}, + Manifest: artifactsOf("runner", map[string]any{"name": "runtime", "states": []any{"put-sessions"}})}, + "app": {Module: "app", Artifacts: []meshcatalog.Artifact{{Ref: "app:program", Language: "go"}}, Packages: map[string]string{"git.example/sdk/go": "1.0.4"}}, + "other": {Module: "other", Artifacts: []meshcatalog.Artifact{{Ref: "other:program", Language: "go"}}}, + } + got := WithEdges(entries) + var six []meshcatalog.Edge + for _, e := range got["app"].Edges { + if e.Kind == 6 { + six = append(six, e) + } + } + want := []meshcatalog.Edge{{Kind: 6, From: "runner:runtime", To: "app:program", Word: "put-sessions", Why: "calls of git.example/sdk/go"}} + if !slices.Equal(six, want) { + t.Errorf("app's edges of kind 6 are %+v, want %+v", six, want) + } + if len(got["other"].Edges) != 0 { + t.Errorf("other builds on no such package, its edges %+v", got["other"].Edges) + } +} + +func actingEntries() map[string]meshcatalog.CatalogEntry { + rule := func(seat, step, on string) meshcatalog.ActingRule { + return meshcatalog.ActingRule{Seat: seat, Step: step, On: on} + } + return map[string]meshcatalog.CatalogEntry{ + "grants": {Module: "grants", Artifacts: []meshcatalog.Artifact{{Ref: "grants:program", Language: "go"}}, + Packages: map[string]string{"git.example/lib/go": "1.0.0"}, Toolchains: map[string]meshcatalog.BuildEdge{"go": {Module: "build-go"}}, + Acts: []meshcatalog.ActingRule{rule("mesh-grants", "grant", "every"), rule("mesh-grants", "take-in", "every"), rule("mesh-grants", "validate", "")}}, + "lib": {Module: "lib", Artifacts: []meshcatalog.Artifact{{Ref: "lib:go", Language: "go", Kind: "package", Package: &meshcatalog.ArtifactPackage{Ecosystem: EcosystemGo, Name: "git.example/lib/go"}}}}, + "build-go": {Module: "build-go", Artifacts: []meshcatalog.Artifact{{Ref: "build-go:program", Language: "go"}}, Acts: []meshcatalog.ActingRule{rule("node-build-go", "build", "go")}}, + "composer": {Module: "composer", Artifacts: []meshcatalog.Artifact{{Ref: "composer:program", Language: "go"}}, Acts: []meshcatalog.ActingRule{rule("mesh-compose", "compose", "kind:image")}}, + "db": {Module: "db", Artifacts: []meshcatalog.Artifact{{Ref: "db:image", Kind: "image"}}}, + "web": {Module: "web", Artifacts: []meshcatalog.Artifact{{Ref: "web:program", Language: "typescript"}}}, + "bare": {Module: "bare"}, + } +} + +// Design 47 G §711: an acting rule of a step that acts on a delivered version gives kind 5 from the acting entry's delivered +// artifact to each other entry the rule's `on` matches, with the word `acts` and the why ` `; none to the entry +// itself, to one it is built from (a package, a build module), to one with no artifact; take-in, validate and build give none. +func TestAnActingRuleGivesKindFiveFromTheActingEntryToTheEntriesItActsOn(t *testing.T) { + got := WithEdges(actingEntries()) + five := func(m string) []meshcatalog.Edge { + var out []meshcatalog.Edge + for _, e := range got[m].Edges { + if e.Kind == 5 { + out = append(out, e) + } + } + return out + } + grant := func(to string) meshcatalog.Edge { + return meshcatalog.Edge{Kind: 5, From: "grants:program", To: to, Word: "acts", Why: "mesh-grants grant"} + } + for module, want := range map[string][]meshcatalog.Edge{ + "web": {grant("web:program")}, + "composer": {grant("composer:program")}, + "db": {{Kind: 5, From: "composer:program", To: "db:image", Word: "acts", Why: "mesh-compose compose"}, grant("db:image")}, + "build-go": nil, // grants is built with it + "lib": nil, // and from it + "grants": nil, // never to itself + "bare": nil, // no artifact + } { + if g := five(module); !slices.Equal(g, want) { + t.Errorf("kind 5 into %s: got %+v, want %+v", module, g, want) + } + } + for _, e := range got["web"].Edges { + if e.Stale || e.Declared { + t.Errorf("an edge of kind 5 only orders: %+v", e) + } + } +} + +// Design 47 G §711: `acts` is filled from the definitions of each seat the manifest claims, as {seat, step, on, bound}, +// sorted by seat then step; an entry claiming no seat with a rule has none. +func TestAnEntryCarriesTheActingRulesOfTheSeatsItClaims(t *testing.T) { + claims := func(seats ...string) map[string]any { + var list []any + for _, s := range seats { + list = append(list, map[string]any{"name": s}) + } + return map[string]any{"module": "m", "version": "1.0", "claims": list} + } + defs := map[string]seatdef.Definition{ + "mesh-grants": {Acts: []seatdef.ActingRule{{Step: "grant", On: "every", Bound: "5m"}, {Step: "build", On: "go"}}}, + "a-seat": {Acts: []seatdef.ActingRule{{Step: "place"}}}, + "unclaimed": {Acts: []seatdef.ActingRule{{Step: "compose"}}}, + "plain": {}, + } + got := WithActs(map[string]meshcatalog.CatalogEntry{ + "m": {Module: "m", Manifest: claims("mesh-grants", "a-seat", "plain")}, + "other": {Module: "other", Manifest: claims("plain")}, + }, defs) + want := []meshcatalog.ActingRule{{Seat: "a-seat", Step: "place"}, {Seat: "mesh-grants", Step: "build", On: "go"}, {Seat: "mesh-grants", Step: "grant", On: "every", Bound: "5m"}} + if !slices.Equal(got["m"].Acts, want) { + t.Errorf("acts %+v, want %+v", got["m"].Acts, want) + } + if got["other"].Acts != nil { + t.Errorf("a module claiming no seat with a rule has acts %+v", got["other"].Acts) + } +} + +// Design 47 G §711: the indexer reads the rules from the seat definitions it keeps when it keeps a revision, so the kept entry +// carries its acts and the entries it acts on carry the edge. +func TestAKeptRevisionHoldsTheActsAndTheKindFiveEdges(t *testing.T) { + ctx := context.Background() + defs := newMem() + _ = defs.Create(ctx, "mesh-grants.trunk", []byte(`{"seat":"mesh-grants","source":"trunk","digest":"d","definition":{"seat":"mesh-grants","acts":[{"step":"grant","on":"every"}]}}`)) + ix := &Indexer{Revisions: newMemObjects(1 << 20), SeatDefinitions: defs, Now: func() time.Time { return time.Date(2026, 11, 1, 3, 0, 0, 0, time.UTC) }} + err := ix.KeepBranchEntries(ctx, "main", 1, map[string]meshcatalog.CatalogEntry{ + "grants": {Module: "grants", Manifest: map[string]any{"module": "grants", "version": "1.0", "claims": []any{map[string]any{"name": "mesh-grants"}}}, Artifacts: []meshcatalog.Artifact{{Ref: "grants:p"}}}, + "app": {Module: "app", Manifest: map[string]any{}, Artifacts: []meshcatalog.Artifact{{Ref: "app:p"}}}, + }) + if err != nil { + t.Fatal(err) + } + _, entries, err := ReadRevision(ctx, ix.Revisions, "trunk.1") + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + switch e.Module { + case "grants": + if len(e.Acts) != 1 || e.Acts[0].Seat != "mesh-grants" || e.Acts[0].Step != "grant" { + t.Errorf("grants holds acts %+v", e.Acts) + } + case "app": + if len(e.Edges) != 1 || e.Edges[0].Kind != 5 || e.Edges[0].From != "grants:p" || e.Edges[0].Why != "mesh-grants grant" { + t.Errorf("app holds edges %+v, want one of kind 5 from grants:p", e.Edges) + } + } + } +} diff --git a/modules/mesh-delivery-indexer/internal/index/catalog_revisions.go b/modules/mesh-delivery-indexer/internal/index/catalog_revisions.go index 4134c2be..c65774f4 100644 --- a/modules/mesh-delivery-indexer/internal/index/catalog_revisions.go +++ b/modules/mesh-delivery-indexer/internal/index/catalog_revisions.go @@ -322,6 +322,7 @@ func (ix *Indexer) keepBranchEntries(ctx context.Context, branch string, src int ix.logf("catalog graph: no revision is kept: the newest is not read: %v", err) return fmt.Errorf("the newest catalog graph revision: %w", err) } + entries = WithEdges(ix.withActs(ctx, entries)) // the acting rules and the edges between artifact references are written here, once, for every reader (A-208) at := ix.now().UTC() rev, objects, err := CatalogRevisionOf(meshcatalog.CatalogRevision{Revision: newest.Revision + 1, Kind: "trunk", Branch: branch, Source: src, At: &at}, entries) if err != nil {