Merge branch 'feat/47-G-fix11-b1g' into feat/47-G-join11

# Conflicts:
#	modules/mesh-delivery-contracts/seats/mesh-pipeline-runner/seat.json
#	modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/main.go
#	modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/steps.go
#	modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/verbs.go
#	modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/verbs_test.go
#	modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/views.go
#	modules/mesh-delivery-pipeline/internal/pipeline/start.go
#	modules/mesh-delivery-pipeline/manifest_test.go
#	modules/mesh-delivery-pipeline/module.json
This commit is contained in:
2026-11-12 10:32:18 +01:00
64 changed files with 2786 additions and 248 deletions
@@ -0,0 +1,36 @@
package meshcatalogclient
import (
"context"
"encoding/json"
"fmt"
seat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-sdk/go/views"
)
// WireCandidatesProcess hands each candidate's `catalog-revision` to hook (design 47 G A-185, A-193, A-199): a candidate's
// revision no Catalog keeps, heard once on the event's one subscription of the process's reader, beside every watched
// branch's Catalog and whether or not the part follows any, so the runner places a run's pipelines on it. A trunk revision
// is passed over (each branch's Catalog keeps it); data that is no such event is an error. The part's manifest consumes the
// seat's event catalog-revision.
func WireCandidatesProcess(r *views.ProcessReader, hook func(ctx context.Context, ev seat.CatalogRevisionEvent) error) error {
if hook == nil {
return fmt.Errorf("meshcatalogclient: a candidates hook is a function")
}
return r.AdoptEvent(seat.Seat, seat.EventCatalogRevision, candidateHear(hook))
}
// candidateHear is the handler of catalog-revision that hands a candidate's revision to hook.
func candidateHear(hook func(ctx context.Context, ev seat.CatalogRevisionEvent) error) func(context.Context, []byte) error {
return func(ctx context.Context, data []byte) error {
var ev seat.CatalogRevisionEvent
if err := json.Unmarshal(data, &ev); err != nil || ev.Revision <= 0 {
return fmt.Errorf("meshcatalogclient: not a %s event: %s", seat.EventCatalogRevision, clipBytes(data))
}
if ev.Kind != KindCandidate {
return nil
}
return hook(ctx, ev)
}
}
@@ -162,3 +162,29 @@ func TestCatalogIsAViewPerWatchedBranch(t *testing.T) {
t.Error("a Catalog of no branch was made")
}
}
// A-185, A-193, A-199: a candidate's catalog-revision reaches the hook, adopted on the event's one subscription of the
// reader, also with no watched branch's Catalog; a trunk revision does not, and data that is no such event is said.
func TestCandidatesReachTheHook(t *testing.T) {
var hooked []seat.CatalogRevisionEvent
hook := func(_ context.Context, ev seat.CatalogRevisionEvent) error { hooked = append(hooked, ev); return nil }
r := &views.ProcessReader{Module: "runner"}
if err := WireCandidatesProcess(r, hook); err != nil {
t.Fatal(err)
}
if err := WireCandidatesProcess(r, nil); err == nil {
t.Error("a nil hook was taken")
}
hear := candidateHear(hook)
for _, data := range [][]byte{revisionEvent("main", KindTrunk, 3), revisionEvent("main", KindCandidate, 4)} {
if err := hear(context.Background(), data); err != nil {
t.Fatal(err)
}
}
if err := hear(context.Background(), []byte(`not json`)); err == nil {
t.Error("data that is no catalog-revision was taken")
}
if len(hooked) != 1 || hooked[0].Revision != 4 || hooked[0].Kind != KindCandidate {
t.Errorf("the hook was handed %+v, want the candidate's revision 4 alone", hooked)
}
}
@@ -1281,6 +1281,8 @@ type RevisionInput struct {
Heads string `json:"heads,omitempty"`
// a kept revision; absent: the newest of trunk, or of the candidate named
Revision int64 `json:"revision,omitempty"`
// a candidate's source revision: the revision of the branch's series taken in from it, with its entries (design 47 G section 441)
Source int64 `json:"source,omitempty"`
}
// RevisionAnswer is what revision answers.
@@ -458,8 +458,10 @@ type Pipeline struct {
// yes when the module is over its test budget (design 47 G section 419)
OverBudget bool `json:"over-budget,omitempty"`
// the pipeline's key, <branch>.<revision>.<module>, a branch escaped as a key's name is
Pipeline string `json:"pipeline,omitempty"`
Repository string `json:"repository,omitempty"`
Pipeline string `json:"pipeline,omitempty"`
// for a pipeline a package's publish started, that publish (A-202)
Published *Published `json:"published,omitempty"`
Repository string `json:"repository,omitempty"`
// the catalog graph revision of the branch the pipeline runs for
Revision int64 `json:"revision,omitempty"`
// the run on request's id, for a pipeline of a run; such a pipeline delivers nothing
@@ -573,6 +575,8 @@ type PipelineStarted struct {
Module string `json:"module"`
// the pipeline's key, <branch>.<revision>.<module>, a branch escaped as a key's name is
Pipeline string `json:"pipeline"`
// for a pipeline a package's publish started, that publish (A-202)
Published *Published `json:"published,omitempty"`
// the catalog graph revision of the branch the pipeline runs for
Revision int64 `json:"revision,omitempty"`
// the run on request's id, for a pipeline of a run; such a pipeline delivers nothing
@@ -584,6 +588,14 @@ type PipelineStarted struct {
V int64 `json:"v"`
}
// Published is the publish of a package that started a pipeline: a newer publish of the same artifact on the same branch supersedes it (design 47 G section 31, A-202).
type Published struct {
// the package whose publish started the pipeline
Artifact string `json:"artifact"`
// the version that publish named
Version string `json:"version"`
}
// RunStarted is a run on request's start, with its members at their commits (ADR 0666 section 18).
type RunStarted struct {
At *time.Time `json:"at,omitempty"`
@@ -266,6 +266,10 @@
"type": "string",
"description": "a candidate's heads"
},
"source": {
"type": "integer",
"description": "a candidate's source revision: the revision of the branch's series taken in from it, with its entries (design 47 G section 441)"
},
"after": {
"type": "string",
"description": "the last module of the page before; absent: the first page"
@@ -978,6 +978,24 @@
"name"
]
},
"Published": {
"type": "object",
"properties": {
"artifact": {
"type": "string",
"description": "the package whose publish started the pipeline"
},
"version": {
"type": "string",
"description": "the version that publish named"
}
},
"description": "the publish of a package that started a pipeline: a newer publish of the same artifact on the same branch supersedes it (design 47 G section 31, A-202)",
"required": [
"artifact",
"version"
]
},
"Pipeline": {
"type": "object",
"properties": {
@@ -1083,6 +1101,10 @@
"$ref": "#/defs/LogEntry"
},
"description": "the step-log entries of the steps that ended and are not appended to the step log yet, in order; written with the step's end in one write and cleared when appended (design 47 G sections 486, 606)"
},
"published": {
"$ref": "#/defs/Published",
"description": "for a pipeline a package's publish started, that publish (A-202)"
}
},
"description": "one pipeline as the runner keeps it in its state pipelines, by compare-and-set (design 47 *The runner's kept data*)",
@@ -1272,6 +1294,10 @@
"at": {
"type": "string",
"format": "date-time"
},
"published": {
"$ref": "#/defs/Published",
"description": "for a pipeline a package's publish started, that publish (A-202)"
}
},
"description": "a pipeline's start",
@@ -731,6 +731,8 @@ export interface RevisionInput {
"heads"?: string;
/** a kept revision; absent: the newest of trunk, or of the candidate named */
"revision"?: number;
/** a candidate's source revision: the revision of the branch's series taken in from it, with its entries (design 47 G section 441) */
"source"?: number;
}
/** is what revision answers. */
@@ -218,6 +218,8 @@ export interface Pipeline {
"over-budget"?: boolean;
/** the pipeline's key, <branch>.<revision>.<module>, a branch escaped as a key's name is */
"pipeline"?: string;
/** for a pipeline a package's publish started, that publish (A-202) */
"published"?: Published;
"repository"?: string;
/** the catalog graph revision of the branch the pipeline runs for */
"revision"?: number;
@@ -293,6 +295,8 @@ export interface PipelineStarted {
"module": string;
/** the pipeline's key, <branch>.<revision>.<module>, a branch escaped as a key's name is */
"pipeline": string;
/** for a pipeline a package's publish started, that publish (A-202) */
"published"?: Published;
/** the catalog graph revision of the branch the pipeline runs for */
"revision"?: number;
/** the run on request's id, for a pipeline of a run; such a pipeline delivers nothing */
@@ -304,6 +308,14 @@ export interface PipelineStarted {
"v": number;
}
/** the publish of a package that started a pipeline: a newer publish of the same artifact on the same branch supersedes it (design 47 G section 31, A-202) */
export interface Published {
/** the package whose publish started the pipeline */
"artifact": string;
/** the version that publish named */
"version": string;
}
/** a run on request's start, with its members at their commits (ADR 0666 section 18) */
export interface RunStarted {
"at"?: string;
@@ -63,4 +63,36 @@ func TestACatalogRevisionIsKeptPerWatchedBranch(t *testing.T) {
if a := answer("beta", 0); !a.NoneYet {
t.Errorf("a branch with no revision answered %+v, want none-yet", a)
}
// A-205: `source` answers the branch's revision taken in from that source revision; a candidate's group alone answers its
// newest revision, whatever its heads.
bySource := func(branch string, source int64) (meshcatalog.RevisionAnswer, error) {
return Revision(ctx, ix.Revisions, "indexer", "reader", meshcatalog.RevisionInput{Branch: branch, Source: source})
}
if a, err := bySource("main", 5); err != nil || a.Revision.Revision != 1 || a.Entries[0].Commit != "c1" {
t.Errorf("main's source 5 answered %+v (%v)", a.Revision, err)
}
if a, err := bySource("feature/x=2ey", 7); err != nil || a.Revision.Revision != 1 || a.Entries[0].Commit != "c2" {
t.Errorf("the branch's source 7 answered %+v (%v), want its revision 1 at c2", a.Revision, err)
}
if a, err := bySource("feature/x=2ey", 8); err != nil || a.Revision.Revision != 2 {
t.Errorf("the branch's source 8 answered %+v (%v)", a.Revision, err)
}
if _, err := bySource("feature/x=2ey", 9); err == nil {
t.Error("a source no revision was taken in from was answered")
}
for i, heads := range []string{"h1", "h2"} {
rev, objects, err := CatalogRevisionOf(meshcatalog.CatalogRevision{Revision: int64(3 + i), Kind: "candidate", Group: "run-ab", Heads: heads}, tip("c9"))
if err != nil {
t.Fatal(err)
}
if err := KeepRevision(ctx, ix.Revisions, rev, objects); err != nil {
t.Fatal(err)
}
}
if a, err := Revision(ctx, ix.Revisions, "indexer", "reader", meshcatalog.RevisionInput{Group: "run-ab"}); err != nil || a.Revision.Revision != 4 || a.Revision.Heads != "h2" || a.Entries[0].Commit != "c9" {
t.Errorf("a group alone answered %+v (%v), want its newest revision 4", a.Revision, err)
}
if _, err := Revision(ctx, ix.Revisions, "indexer", "reader", meshcatalog.RevisionInput{Group: "run-none"}); err == nil {
t.Error("a candidate never kept was answered")
}
}
@@ -71,11 +71,21 @@ func Revision(ctx context.Context, o Objects, holder, caller string, in meshcata
}
name := RevisionObject(newest)
switch {
case in.Group != "" && in.Heads == "":
// A candidate's group alone answers its newest revision, whatever its heads (A-205).
if name, err = newestCandidate(ctx, o, in.Group); err != nil {
return out, err
}
case in.Group != "" || in.Heads != "":
if in.Group == "" || in.Heads == "" {
return out, fmt.Errorf("a candidate's revision is named by its group and its heads, both")
}
name = RevisionObject(meshcatalog.CatalogRevision{Kind: "candidate", Group: in.Group, Heads: in.Heads})
case in.Source != 0:
// The branch's revision taken in from that source revision (A-205).
if name, err = revisionOfSource(ctx, o, branch, in.Source); err != nil {
return out, err
}
case in.Revision != 0 && in.Revision != newest.Revision:
name = RevisionObject(meshcatalog.CatalogRevision{Branch: branch, Revision: in.Revision})
}
@@ -121,3 +131,62 @@ func RevisionTool(o Objects, holder string) stdio.Tool {
return Revision(context.Background(), o, holder, stdio.CallerOf(args), in)
}}
}
// newestCandidate is the object of the newest revision a candidate group keeps, over whatever heads it was kept at (A-205).
func newestCandidate(ctx context.Context, o Objects, group string) (string, error) {
names, err := o.Names(ctx)
if err != nil {
return "", fmt.Errorf("the candidate revisions cannot be listed: %w", err)
}
best, newest := "", int64(-1)
for _, n := range names {
if !strings.HasPrefix(n, "candidate."+group+".") {
continue
}
rev, _, err := ReadRevision(ctx, o, n)
if err != nil {
return "", err
}
if rev.Group == group && rev.Revision > newest {
best, newest = n, rev.Revision
}
}
if best == "" {
return "", fmt.Errorf("the catalog keeps no candidate of the group %s", group)
}
return best, nil
}
// revisionOfSource is the object of the newest revision of branch taken in from the source revision source (A-205); a source
// none was taken in from is an error.
func revisionOfSource(ctx context.Context, o Objects, branch string, source int64) (string, error) {
names, err := o.Names(ctx)
if err != nil {
return "", fmt.Errorf("the catalog graph revisions cannot be listed: %w", err)
}
number := TrunkRevisionOf
if !isTrunkBranch(branch) {
number = func(n string) (int64, bool) { return BranchRevisionOf(branch, n) }
}
var found []int64
for _, n := range names {
if r, ok := number(n); ok {
found = append(found, r)
}
}
slices.SortFunc(found, func(a, b int64) int { return int(b - a) })
for _, r := range found {
name := RevisionObject(meshcatalog.CatalogRevision{Branch: branch, Revision: r})
rev, _, err := ReadRevision(ctx, o, name)
if err != nil {
return "", err
}
if rev.Source == source {
return name, nil
}
if rev.Source < source {
break
}
}
return "", fmt.Errorf("no revision of %q was taken in from the source revision %d", branch, source)
}
+90 -32
View File
@@ -2938,7 +2938,7 @@ func parseManifest(raw []byte, predefined catalogue.PredefinedList) (catalogue.M
}
}
problems = append(problems, invokeProblems(m)...)
problems = append(problems, waitProblems(m)...)
problems = append(problems, needsApprovalProblems(m)...)
problems = append(problems, replacesProblems(m)...)
problems = append(problems, UnitSettingProblems(m)...)
problems = append(problems, OwnerSettingProblems(m)...)
@@ -3390,43 +3390,103 @@ func replacesProblems(m catalogue.Manifest) []string {
return problems
}
// waitProblems judges the event families a module says a session may wait on (novox/hq ADR 0367 §2, §6):
// the tool runner's alone, each named whole — emitter and event, with no wildcard — and each once. Refused
// here, in the manifest's words, rather than at the composition of the bus's users.
func waitProblems(m catalogue.Manifest) []string {
if len(m.Waits) == 0 {
return nil
}
if !catalogue.IsToolRunner(m) {
return []string{fmt.Sprintf("%s says what a session waits on; only the tool runner's module (it says "+
"\"tool-runner\": true) declares waits (novox/hq ADR 0367 §2)", m.Module)}
}
// needsApprovalProblems refuses a mark the mesh cannot honour (design 46 *The approval mark* §4; design 34 every-event
// §32; ADR 0286 §4): a mark on a tool its tools do not list (where it lists them; a bundle's own tools are checked by
// the tool runner at load), on a verb its claims do not serve, or on an event neither its emits nor its streams' events
// carry; one name both one of its tools and one of its events; a tool or verb marked, or a held seat verb its seat
// marks, without operator-channel in uses (§7: a module marking only events needs none); and a tool or verb marked
// while every bundle it builds is run by one of its process resources, so its own program answers and no tool runner
// gates them. Each refusal names the entry and the rule.
func needsApprovalProblems(m catalogue.Manifest) []string {
var problems []string
seen := map[string]bool{}
for _, w := range m.Waits {
if strings.ContainsAny(w, "*>") {
problems = append(problems, fmt.Sprintf("%s waits on %q: a family is named one by one, never a "+
"wildcard (novox/hq ADR 0367 §2)", m.Module, w))
tools, events, served := map[string]bool{}, map[string]bool{}, map[string]map[string]bool{}
for _, t := range m.Tools {
tools[t] = true
}
for _, e := range m.Emits {
events[e] = true
}
for _, st := range m.Streams {
for _, e := range st.Events {
events[e] = true
}
}
for _, c := range m.Claims {
served[c.Name] = map[string]bool{}
for _, v := range c.ServesFor(m) {
served[c.Name][v] = true
}
}
var acts []string // the tools and verbs it marks, and the held seat verbs its seats mark
for _, e := range m.NeedsApproval {
seat, verb, dotted := strings.Cut(e, ".")
switch {
case tools[e] && events[e]:
problems = append(problems, fmt.Sprintf("%s marks %q in needs-approval, which names both one of its tools "+
"and one of its events: an entry names one thing (design 46 approval mark §4)", m.Module, e))
case events[e]:
case tools[e], dotted && served[seat][verb]:
acts = append(acts, e)
case dotted && served[seat] != nil:
problems = append(problems, fmt.Sprintf("%s marks the verb %q in needs-approval, which its claim of %s does "+
"not serve (ADR 0286 §4)", m.Module, e, seat))
case dotted:
problems = append(problems, fmt.Sprintf("%s marks %q in needs-approval, which is neither a verb its claims "+
"serve nor an event its emits or its streams' events carry (ADR 0286 §4; design 34 every-event §32)", m.Module, e))
case len(m.Tools) > 0:
problems = append(problems, fmt.Sprintf("%s marks the tool %q in needs-approval, which its tools do not list "+
"(ADR 0286 §4)", m.Module, e))
default: // a tool its bundle registers: the tool runner checks it at load (§4)
acts = append(acts, e)
}
}
marked := len(acts) > 0
for _, c := range m.Claims {
seat, ok := catalogue.SeatNamed(c.Name)
if !ok {
continue
}
tokens := strings.Split(w, ".")
whole := len(tokens) >= 2 && name.MatchString(tokens[0])
for _, t := range tokens[1:] {
whole = whole && waitToken.MatchString(t)
for _, v := range seat.Serves {
if v.NeedsApproval && served[c.Name][v.Name] {
acts = append(acts, c.Name+"."+v.Name)
}
}
if !whole {
problems = append(problems, fmt.Sprintf("%s waits on %q, which does not name an emitter and an "+
"event: a family is <emitter>.<event>, as consumes names one", m.Module, w))
continue
}
if seen[w] {
problems = append(problems, fmt.Sprintf("%s waits on %q twice", m.Module, w))
}
seen[w] = true
}
if len(acts) > 0 && !slices.Contains(m.Uses, "operator-channel") {
problems = append(problems, fmt.Sprintf("%s marks or holds a marked tool or verb (%s) without operator-channel in "+
"uses: its tool runner asks the operator through that seat (design 46 approval mark §4, §7)",
m.Module, strings.Join(acts, ", ")))
}
if marked && everyBundleRunByAProcess(m) {
problems = append(problems, fmt.Sprintf("%s marks a tool or verb while every bundle it builds is run by one of "+
"its process resources: its own program answers them and no tool runner gates them, so the mark would guard "+
"nothing (design 46 approval mark §4)", m.Module))
}
return problems
}
// everyBundleRunByAProcess says every artifact of kind bundle the module builds is the artifact of one of its process
// resources (none built is every one run so: no tool runner loads any).
func everyBundleRunByAProcess(m catalogue.Manifest) bool {
run := map[string]bool{}
for _, r := range m.Resources {
if r["type"] == "process" {
if a, ok := r["artifact"].(string); ok {
run[a] = true
}
}
}
if m.Build == nil {
return true
}
for _, a := range m.Build.Artifacts {
if a.Kind == "bundle" && !run[a.Name] {
return false
}
}
return true
}
// whileStoppedProblems judges one container's maintenance window against its own definition
// (novox/hq ADR 0189).
//
@@ -3646,8 +3706,6 @@ var renamed = map[string]string{
var toolName = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
var waitToken = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
var engineGeneric = map[string]string{
"database": "postgres-database, mssql-database, mariadb-database",
"db": "postgres-database, mssql-database, mariadb-database",
@@ -3156,28 +3156,56 @@ func caseAVerbWithAnUnknownKeyIsRefused(t *testing.T) {
}
}
// A family is named one by one, never by a wildcard, and only by the tool runner's module (ADR 0367 §2, §6):
// each refusal is said in the manifest's words, at parse, rather than at the composition of the bus's users.
func caseAWaitIsRefusedUnlessItNamesOneFamilyOfTheToolRunner(t *testing.T) {
for manifest, want := range map[string]string{
`{"module":"shop","version":"1.0","waits":["mesh-knowledge.reviewed"]}`: "only the tool runner's module",
`{"module":"node-tools","version":"1.0","waits":["mesh-knowledge.reviewed"]}`: "only the tool runner's module", // its name says nothing (issue 774)
`{"module":"node-tools","version":"1.0","tool-runner":true,"waits":["mesh-knowledge.*"]}`: "never a wildcard",
`{"module":"node-tools","version":"1.0","tool-runner":true,"waits":["**"]}`: "never a wildcard",
`{"module":"node-tools","version":"1.0","tool-runner":true,"waits":["mesh-knowledge.>"]}`: "never a wildcard",
`{"module":"node-tools","version":"1.0","tool-runner":true,"waits":["*.reviewed"]}`: "never a wildcard",
`{"module":"node-tools","version":"1.0","tool-runner":true,"waits":["reviewed"]}`: "does not name an emitter and an event",
`{"module":"node-tools","version":"1.0","tool-runner":true,"waits":["mesh-knowledge..reviewed"]}`: "does not name an emitter and an event",
`{"module":"node-tools","version":"1.0","tool-runner":true,"waits":["Mesh-Knowledge.reviewed x"]}`: "does not name an emitter and an event",
`{"module":"node-tools","version":"1.0","tool-runner":true,"waits":["a.b","a.b"]}`: "twice",
// EE-D4 (design 34 every-event §10; ADR 0670 §2, ADR 0578 §1): the manifest no longer names what a session waits on, so
// `waits` is a field the validator does not know: refused by the checker's Shelf naming it, set aside to wait by the
// indexer's ShelfWaiting; the tool runner's manifest without it is accepted.
func caseAManifestCarryingWaitsIsAFieldNotKnown(t *testing.T) {
raws := map[string][]byte{
"node-tools": []byte(`{"module":"node-tools","version":"1.0","tool-runner":true,"waits":["mesh-knowledge.reviewed"]}`),
"runner": []byte(`{"module":"runner","version":"1.0","tool-runner":true}`),
}
if strict, _ := Shelf(raws, nil, 0); len(strict) != 1 || !strings.Contains(strings.Join(strict["node-tools"], " "), "waits") {
t.Errorf("Shelf on a manifest carrying waits: %v", strict)
}
if refused, waits, _ := ShelfWaiting(raws, nil, 0, nil, nil); len(refused) != 0 ||
!reflect.DeepEqual(waits, map[string][]string{"node-tools": {"waits"}}) {
t.Errorf("ShelfWaiting on a manifest carrying waits: refused %v, waits %v", refused, waits)
}
}
// AM-D1 (design 46 approval mark §4, §21; design 34 every-event §32): a mark nothing enforces is refused, naming the
// entry and the rule; the login shell's and the forge's manifests of §21 are accepted, and a module marking only events
// needs no operator-channel.
func caseAMarkNothingEnforcesIsRefused(t *testing.T) {
const bundle = `"build":{"artifacts":[{"name":"tools","kind":"bundle","language":"typescript","entrypoints":["tools/index.js"]}]}`
const oc = `"uses":["operator-channel"]`
shell := `{"module":"zsh","claims":[{"name":"node-login-shell","scope":"node","serves":["execute"]}],"tools":["zsh_config"],` + bundle
for _, row := range []struct{ name, manifest, want string }{
{"a tool its tools do not list", `{"module":"shop","tools":["sell"],"needs-approval":["refund"],` + oc + `,` + bundle + `}`, "its tools do not list"},
{"a verb its claims do not serve", `{"module":"shop","claims":[{"name":"shop-till","serves":["price"]}],"tools":["price"],"needs-approval":["shop-till.refund"],` + oc + `,` + bundle + `}`, "does not serve"},
{"a marked tool without operator-channel", `{"module":"shop","tools":["sell"],"needs-approval":["sell"],` + bundle + `}`, "without operator-channel"},
{"a held seat verb its seat marks without operator-channel", shell + `}`, "without operator-channel"},
{"every bundle run by its own process", `{"module":"shop","tools":["sell"],"needs-approval":["sell"],` + oc + `,` + bundle +
`,"resources":[{"id":"serve","type":"process","artifact":"tools","user":"shop"}]}`, "every bundle it builds is run"},
{"one name a tool and an event", `{"module":"shop","tools":["sold"],"emits":["sold"],"needs-approval":["sold"],` + oc + `,` + bundle + `}`, "both one of its tools"},
{"an event it does not carry", `{"module":"shop","emits":["sold"],"needs-approval":["sold.again"]}`, "nor an event its emits"},
{"the login shell", shell + `,` + oc + `}`, ""},
{"the forge", `{"module":"forge","needs-approval":["gitea_branch_protection_loosen"],` + oc + `,"build":{"artifacts":[` +
`{"name":"code","kind":"bundle","language":"typescript","entrypoints":["tools/index.js"],"loads":["tools/index.js"]},` +
`{"name":"repositories","kind":"bundle","language":"typescript","entrypoints":["repositories/index.js"]}]},` +
`"resources":[{"id":"repositories","type":"process","artifact":"repositories","user":"forge"}]}`, ""},
{"only events, no operator-channel", `{"module":"shop","emits":["sold"],"streams":[{"name":"ledger","events":["booked"],"max-age-days":1,"max-mib":1}],"needs-approval":["sold","booked"]}`, ""},
} {
_, err := ParseManifest([]byte(manifest))
if err == nil {
t.Errorf("accepted: %s", manifest)
continue
}
if !strings.Contains(err.Error(), want) {
t.Errorf("%s refused for the wrong reason (want %q): %v", manifest, want, err)
var problems []string
WithSeats([]catalogue.Seat{{Name: "node-login-shell", Serves: []catalogue.Verb{{Name: "execute", NeedsApproval: true}}}}, func() {
_, problems = Manifest(versioned(t, []byte(row.manifest)), nil)
})
got := strings.Join(problems, "\n")
switch {
case row.want == "" && got != "":
t.Errorf("%s is refused: %s", row.name, got)
case row.want != "" && !strings.Contains(got, row.want):
t.Errorf("%s: problems %q, want one saying %q", row.name, got, row.want)
}
}
}
@@ -3983,7 +4011,7 @@ func caseNoModuleMayDeclareTheLoginShell(t *testing.T) {
t.Fatalf("declaring login-shell was not refused by name: %q", got)
}
// And a shell module claiming the mesh's seat, serving execute, is what the seat is for.
m, err := ParseManifest([]byte(`{"module":"zsh","tools":["execute"],` +
m, err := ParseManifest([]byte(`{"module":"zsh","tools":["execute"],"uses":["operator-channel"],` +
`"claims":[{"name":"node-login-shell","scope":"node"}]}`))
if err != nil {
t.Fatal(err)
@@ -6355,7 +6383,7 @@ func TestRulesBus(t *testing.T) {
t.Run("AServedValueNamingSomethingTheMeshDoesNotHaveIsRefused", caseAServedValueNamingSomethingTheMeshDoesNotHaveIsRefused)
t.Run("ASiteScopedProvisionIsRefused", caseASiteScopedProvisionIsRefused)
t.Run("AStateNameIsLocalAndOneToken", caseAStateNameIsLocalAndOneToken)
t.Run("AWaitIsRefusedUnlessItNamesOneFamilyOfTheToolRunner", caseAWaitIsRefusedUnlessItNamesOneFamilyOfTheToolRunner)
t.Run("AManifestCarryingWaitsIsAFieldNotKnown", caseAManifestCarryingWaitsIsAFieldNotKnown)
t.Run("AWorkQueueStreamIsReadByExactlyOneProcessOfItsModule", caseAWorkQueueStreamIsReadByExactlyOneProcessOfItsModule)
t.Run("AWorkQueueStreamsValuesAreBounded", caseAWorkQueueStreamsValuesAreBounded)
t.Run("AZoneMustNameOneOfTheModulesListens", caseAZoneMustNameOneOfTheModulesListens)
@@ -6391,6 +6419,7 @@ func TestRulesCatalogue(t *testing.T) {
// TestRulesManifests runs the tests of one kind as subtests, each by its former name.
func TestRulesManifests(t *testing.T) {
t.Run("AMalformedEnvironmentIsRefusedAtParse", caseAMalformedEnvironmentIsRefusedAtParse)
t.Run("AMarkNothingEnforcesIsRefused", caseAMarkNothingEnforcesIsRefused)
t.Run("AMalformedFamilyIsRefused", caseAMalformedFamilyIsRefused)
t.Run("AMalformedPromptIsRefused", caseAMalformedPromptIsRefused)
t.Run("AManifestIsJudgedAgainstTheListOfItsTree", caseAManifestIsJudgedAgainstTheListOfItsTree)
@@ -13,6 +13,8 @@ was mesh-controller's copy, which still held two modules that had left the catal
mesh-catalog b5dbfbb784507e211372a7c9640d91f7d6dadd84 modules/*/module.json, modules/*/files/**, modules/*/templates/**, predefined.json
mesh-host 233bb23bbcb1a829ce2a6164a31a5c5cc4f3d950 internal/apply/placement_guard.go
mesh-catalog 2d5358b8 (milestone 47/G's AM-K1) modules/zsh/module.json alone: the login shell uses operator-channel for
its seat's marked execute (design 46 approval mark §21), which the validator requires (§4)
To move them, from mesh-delivery's modules/mesh-delivery-indexer directory, with the two repositories checked out
beside mesh-delivery:
@@ -1,6 +1,6 @@
{
"module": "zsh",
"version": "1",
"version": "0.1",
"moved": true,
"capabilities": [
"package-manager"
@@ -14,6 +14,9 @@
]
}
],
"uses": [
"operator-channel"
],
"tools": [
"zsh_config"
],
@@ -0,0 +1,77 @@
package main
import (
"encoding/json"
"fmt"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
catalog "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-sdk/go/buscalls"
"git.novox.be/novox/mesh-sdk/go/keys"
"mesh-delivery-pipeline/internal/pipeline"
)
// readCandidate reads a candidate's revision whole through the catalog seat's verb revision, with the candidate's group and
// heads and the revision its event names, page by page (design 47 G A-180); an event naming no revision reads the candidate's
// newest, the one its answer names (A-205, A-222).
func readCandidate(call func(key string, body any) (json.RawMessage, error), ev catalog.CatalogRevisionEvent) (pipeline.Half, error) {
half := pipeline.Half{Revision: ev.Revision, Source: ev.Source, Entries: map[string]catalog.CatalogEntry{}}
after := ""
for {
raw, err := call(meshcatalogclient.RevisionVerb, catalog.RevisionInput{Branch: ev.Branch, Group: ev.Group, Heads: ev.Heads, Revision: ev.Revision, After: after})
if err != nil {
return half, err
}
answer, said, ok := buscalls.Unwrap(raw)
if !ok {
return half, fmt.Errorf("%s answered: %s", meshcatalogclient.RevisionVerb, said)
}
var a catalog.RevisionAnswer
if err := json.Unmarshal(answer, &a); err != nil {
return half, fmt.Errorf("%s answered no revision: %w", meshcatalogclient.RevisionVerb, err)
}
if a.Revision != nil && ev.Revision == 0 && after == "" {
half.Revision, half.Source = a.Revision.Revision, a.Revision.Source
ev.Revision = a.Revision.Revision
}
if a.Revision == nil || a.Revision.Revision != ev.Revision || a.Revision.Group != ev.Group {
return half, fmt.Errorf("%s answered %+v for the candidate %s's revision %d", meshcatalogclient.RevisionVerb, a.Revision, ev.Group, ev.Revision)
}
for _, e := range a.Entries {
half.Entries[e.Module] = e
}
if a.Next == "" {
return half, nil
}
after = a.Next
}
}
// placeLostRuns places, at the runner's start, each run whose pipelines have no steps yet (A-222): the candidate's
// newest revision is read through the seat's revision verb with the run's group and placed as its catalog-revision event
// would place it, so a placing lost while the runner was down is taken up. A run whose candidate cannot be read is said and
// the others go on; its first failure is returned, to be tried at the next start.
func placeLostRuns(unplaced func() ([]pipeline.UnplacedRun, error), call func(key string, body any) (json.RawMessage, error),
place func(ev catalog.CatalogRevisionEvent, half pipeline.Half) error, logf func(string, ...any)) error {
runs, err := unplaced()
if err != nil {
return err
}
var first error
for _, u := range runs {
ev := catalog.CatalogRevisionEvent{Kind: "candidate", Group: pipeline.RunGroupPrefix + u.Run, Branch: keys.EscapeName(u.Branch)}
half, err := readCandidate(call, ev)
if err == nil {
ev.Revision, ev.Source = half.Revision, half.Source
err = place(ev, half)
}
if err != nil {
logf("the run %s of %s is not placed at the start, its candidate's newest revision: %v", u.Run, u.Branch, err)
if first == nil {
first = err
}
}
}
return first
}
@@ -0,0 +1,73 @@
package main
import (
"encoding/json"
"errors"
"testing"
catalog "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
"mesh-delivery-pipeline/internal/pipeline"
)
// A-180: a run's candidate revision is read whole through the seat's revision verb with the candidate's group, heads and
// revision, page by page; an answer of another revision is refused.
func TestReadCandidateReadsTheRevisionWhole(t *testing.T) {
ev := catalog.CatalogRevisionEvent{Kind: "candidate", Group: "run-0a1b2c3d4e5f", Heads: "h", Revision: 12, Branch: "main"}
var asked []catalog.RevisionInput
answerRev := int64(12)
call := func(key string, body any) (json.RawMessage, error) {
in := body.(catalog.RevisionInput)
asked = append(asked, in)
a := catalog.RevisionAnswer{Revision: &catalog.CatalogRevision{Revision: answerRev, Kind: "candidate", Group: in.Group}}
if in.After == "" {
a.Entries, a.Next = []catalog.CatalogEntry{{Module: "app", Fp: "src2:a"}}, "app"
} else {
a.Entries = []catalog.CatalogEntry{{Module: "lib", Fp: "src2:l"}}
}
return json.Marshal(a)
}
half, err := readCandidate(call, ev)
if err != nil || len(half.Entries) != 2 || half.Revision != 12 || len(asked) != 2 || asked[1].After != "app" ||
asked[0].Group != ev.Group || asked[0].Heads != "h" || asked[0].Revision != 12 || asked[0].Branch != "main" {
t.Fatalf("read %+v (%v), asked %+v", half, err, asked)
}
answerRev = 13
if _, err := readCandidate(call, ev); err == nil {
t.Error("an answer of another revision was taken")
}
}
// A-222: at its start the runner reads the newest revision of each unplaced run's candidate through the seat's revision verb
// (its group, no heads, no revision) and places it; a run whose candidate cannot be read is said and the others go on.
func TestPlaceLostRunsReadsEachCandidateNewest(t *testing.T) {
var asked []catalog.RevisionInput
call := func(key string, body any) (json.RawMessage, error) {
in := body.(catalog.RevisionInput)
asked = append(asked, in)
if in.Group == "run-bad" {
return nil, errors.New("no responders")
}
return json.Marshal(catalog.RevisionAnswer{Revision: &catalog.CatalogRevision{Revision: 7, Source: 3, Kind: "candidate", Group: in.Group},
Entries: []catalog.CatalogEntry{{Module: "app", Fp: "src2:a"}}})
}
var placed []catalog.CatalogRevisionEvent
var said []string
err := placeLostRuns(func() ([]pipeline.UnplacedRun, error) {
return []pipeline.UnplacedRun{{Run: "bad", Branch: "fix/1"}, {Run: "good", Branch: "fix/1"}}, nil
}, call,
func(ev catalog.CatalogRevisionEvent, half pipeline.Half) error {
placed = append(placed, ev)
return nil
},
func(f string, a ...any) { said = append(said, f) })
if err == nil || len(said) == 0 {
t.Errorf("an unreadable candidate was not said: %v, %v", err, said)
}
if len(placed) != 1 || placed[0].Group != "run-good" || placed[0].Revision != 7 || placed[0].Source != 3 || placed[0].Branch != "fix/1" {
t.Fatalf("placed %+v", placed)
}
if asked[1].Heads != "" || asked[1].Revision != 0 || asked[1].Group != "run-good" {
t.Errorf("asked %+v, want the candidate's group alone", asked[1])
}
}
@@ -0,0 +1,41 @@
package main
import (
"encoding/json"
"fmt"
nodebuildgo "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/nodebuildgo"
"git.novox.be/novox/mesh-sdk/go/buscalls"
"mesh-delivery-pipeline/internal/pipeline"
)
// keptCaller is the runner's kept calls, to any holder of a seat or to one node's (process.Conn).
type keptCaller interface {
CallKept(key string, body any) (json.RawMessage, error)
CallKeptOn(node, key string, body any) (json.RawMessage, error)
}
// dropAsk asks the build seat's `drop` of a superseded step's ask as a kept call (design 47 G §31, §350; A-182): of the
// holder on the node that took it, or, never taken, of any holder, which records it in the seat's shared drop key. The
// three build seats' drop take one input (nodebuildgo.DropInput; the manifest invokes each).
func dropAsk(calls keptCaller, by string) pipeline.Drop {
return func(buildSeat, node, ask, why string) error {
key := "seat:" + buildSeat + "." + nodebuildgo.VerbDrop
in := nodebuildgo.DropInput{ID: ask, Why: why, By: by}
var raw json.RawMessage
var err error
if node != "" {
raw, err = calls.CallKeptOn(node, key, in)
} else {
raw, err = calls.CallKept(key, in)
}
if err != nil {
return err
}
if _, said, ok := buscalls.Unwrap(raw); !ok {
return fmt.Errorf("%s answered: %s", key, said)
}
return nil
}
}
@@ -0,0 +1,92 @@
package main
import (
"context"
"errors"
"fmt"
"time"
"github.com/nats-io/nats.go/jetstream"
mcc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient"
condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
"mesh-delivery-pipeline/internal/pipeline"
)
// callFindings is the runner's finding writer as its drops use it (design 47 G A-222, §350).
type callFindings interface {
Write(ctx context.Context, f condseat.Finding) error
Clear(ctx context.Context, scope, id, kind, why string) error
}
// dropFindingID is the id of the call-unanswered finding of buildSeat's drop of ask.
func dropFindingID(buildSeat, ask string) string { return buildSeat + ".drop." + ask }
// deleteBound deletes a key of the state bounds; a key already gone is no fault.
func deleteBound(ctx context.Context, bounds jetstream.KeyValue) func(key string) error {
return func(key string) error {
if err := bounds.Delete(ctx, key); err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) {
return err
}
return nil
}
}
// armDropBound arms the five-minute bound of a drop, a key of the state bounds with its own time to live (A-77, A-222).
func armDropBound(ctx context.Context, bounds jetstream.KeyValue) func(key string) error {
return func(key string) error {
_, err := bounds.Create(ctx, key, []byte(`{"drop":"`+key+`"}`), jetstream.KeyTTL(pipeline.AskBound))
if errors.Is(err, jetstream.ErrKeyExists) {
return nil
}
return err
}
}
// dropAnswered ends the wait of an answered drop: its bound is deleted and a call-unanswered finding it raised before is
// cleared.
func dropAnswered(ctx context.Context, unbound func(key string) error, w callFindings) func(buildSeat, ask string) error {
return func(buildSeat, ask string) error {
if err := unbound(pipeline.DropBoundKey(buildSeat, ask)); err != nil {
return err
}
return w.Clear(ctx, mcc.ScopeCall, dropFindingID(buildSeat, ask), mcc.KindCallUnanswered, "the drop is answered")
}
}
// dropUnanswered says the drop of ask on buildSeat unanswered, the finding call-unanswered in the runner's findings, on the
// expiry of its five-minute bound (A-222, §350, ADR 0668 §3).
func dropUnanswered(ctx context.Context, w callFindings, module string, now func() time.Time) func(buildSeat, ask string) error {
return func(buildSeat, ask string) error {
if w == nil {
return errors.New("the runner has no finding writer")
}
id := dropFindingID(buildSeat, ask)
call := buildSeat + ".drop"
return w.Write(ctx, condseat.Finding{Scope: mcc.ScopeCall, ID: id, Kind: mcc.KindCallUnanswered, Severity: condseat.FindingSeverityWarning,
Summary: fmt.Sprintf("the call %s of ask %s is unanswered", call, ask),
Headline: "A call whose answer is kept is not answered",
Explanation: fmt.Sprintf("The drop of the superseded step's ask %s was asked of the seat %s as a kept call and no holder answered it within %s, so the build it names may still run.", ask, buildSeat, pipeline.AskBound),
Source: module + "." + mcc.KindCallUnanswered, Observer: module, At: now().UTC(), Subject: condseat.Subject{Scope: mcc.ScopeCall, ID: id}})
}
}
// boundExpired takes the expiry of a key of the state bounds: a drop's says the drop unanswered, an ask's ends the step no
// holder took (§32, §535, A-77); another key is none of the runner's.
func boundExpired(h *pipeline.Hearer, unanswered func(buildSeat, ask string) error, logf func(string, ...any)) func(key string) {
return func(key string) {
if s, ask, ok := pipeline.ParseDropBound(key); ok {
if err := unanswered(s, ask); err != nil {
logf("the drop of ask %s on %s, unanswered, is not said: %v", ask, s, err)
}
return
}
if !pipeline.IsAskKey(key) {
return
}
if err := h.NotTaken(key, pipeline.AskBound); err != nil {
logf("the step of ask %s, which no holder took, is not ended: %v", key, err)
}
}
}
@@ -0,0 +1,52 @@
package main
import (
"context"
"testing"
"time"
mcc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient"
condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
"mesh-delivery-pipeline/internal/pipeline"
)
type fakeFindings struct {
written []condseat.Finding
cleared []string
}
func (f *fakeFindings) Write(_ context.Context, x condseat.Finding) error {
f.written = append(f.written, x)
return nil
}
func (f *fakeFindings) Clear(_ context.Context, scope, id, kind, _ string) error {
f.cleared = append(f.cleared, scope+"."+id+"."+kind)
return nil
}
// A-222, A-77: the expiry of a drop's five-minute bound says the drop call-unanswered in the runner's findings, naming the
// seat and the ask; an answered drop clears it; a key that is no drop's is not said.
func TestADropBoundsExpirySaysCallUnanswered(t *testing.T) {
w := &fakeFindings{}
var logged int
expired := boundExpired(nil, dropUnanswered(context.Background(), w, "the-runner", func() time.Time { return time.Unix(100, 0) }), func(string, ...any) { logged++ })
expired("not-a-bound")
if len(w.written) != 0 {
t.Fatalf("a key of no drop was said: %+v", w.written)
}
expired(pipeline.DropBoundKey("node-build-go", "step-ab"))
if len(w.written) != 1 {
t.Fatalf("written %+v, want one finding", w.written)
}
f := w.written[0]
if f.Scope != mcc.ScopeCall || f.Kind != mcc.KindCallUnanswered || f.ID != "node-build-go.drop.step-ab" || f.Observer != "the-runner" || f.Subject.ID != f.ID {
t.Errorf("the finding = %+v", f)
}
var unbound []string
if err := dropAnswered(context.Background(), func(k string) error { unbound = append(unbound, k); return nil }, w)("node-build-go", "step-ab"); err != nil ||
len(w.cleared) != 1 || w.cleared[0] != "call.node-build-go.drop.step-ab.call-unanswered" || len(unbound) != 1 || unbound[0] != "drop.node-build-go.step-ab" {
t.Errorf("an answered drop: %v, cleared %v, unbound %v", err, w.cleared, unbound)
}
}
@@ -11,12 +11,14 @@ import (
"log"
"os"
"os/signal"
"strings"
"sync/atomic"
"syscall"
"time"
"github.com/nats-io/nats.go"
catalog "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
seat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshpipelinerunner"
"git.novox.be/novox/mesh-sdk/go/process"
@@ -92,10 +94,16 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
// A branch's view holding its first half asks again the steps placed in its running pipelines that were never asked
// (§88); the starter is made after the views, so it is reached through a pointer set once it is.
var resumer atomic.Pointer[func(string)]
var placer atomic.Pointer[func(catalog.CatalogRevisionEvent) error]
rv, err := startViews(ctx, js, conn, cred, hub, calls.State("findings"), calls.Call, func(branch string) {
if f := resumer.Load(); f != nil {
(*f)(branch)
}
}, func(_ context.Context, ev catalog.CatalogRevisionEvent) error {
if f := placer.Load(); f != nil {
return (*f)(ev)
}
return fmt.Errorf("the candidate %s's revision %d is heard before the runner can place it", ev.Group, ev.Revision)
}, logger.Printf)
if err != nil {
return err
@@ -138,6 +146,11 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
}
},
Fills: fills, Recovered: kvRecoveries{ctx, recovered}.Has}
placeHalf := func(ev catalog.CatalogRevisionEvent, half pipeline.Half) error {
n, err := starter.PlaceRun(ev, half, rv.Catalog.View)
logger.Printf("the candidate %s's revision %d placed %d pipelines of its run", ev.Group, ev.Revision, n)
return err
}
resume := func(branch string) {
half, ok := rv.Catalog.View(branch)
if !ok {
@@ -155,17 +168,57 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
logger.Printf("the steps placed on %s and not yet asked: %d asked again", branch, n)
}
resumer.Store(&resume)
starter.Store = pipelineStore{ctx: ctx, kv: pipelines}
// A run's placing lost while the runner was down is taken up from its candidate's newest revision, once the registrations
// that place its steps are held (A-222).
go func() {
select {
case <-ctx.Done():
return
case <-rv.Registrations.Ready():
}
if err := placeLostRuns(starter.UnplacedRuns, calls.CallKept, placeHalf, logger.Printf); err != nil {
logger.Printf("the runs not placed at the start: %v", err)
}
}()
// A run's candidate revision places its pipelines' steps, its entries read through the seat's revision verb (A-180).
place := func(ev catalog.CatalogRevisionEvent) error {
if !strings.HasPrefix(ev.Group, pipeline.RunGroupPrefix) {
return nil
}
half, err := readCandidate(calls.CallKept, ev)
if err != nil {
return fmt.Errorf("the candidate %s's revision %d is not read, so its run is not placed: %w", ev.Group, ev.Revision, err)
}
return placeHalf(ev, half)
}
placer.Store(&place)
// A newer commit or publish supersedes the older running pipelines it replaces, their asks dropped as kept calls (§31,
// §350; A-182, A-202).
starter.Supersede = func(newer seat.Pipeline) {
n, err := hearer.Supersede(newer, pipeline.BoundDrop(dropAsk(calls, cred.Module), armDropBound(ctx, bounds), dropAnswered(ctx, deleteBound(ctx, bounds), rv.Findings)), dropBound(ctx, bounds))
if err != nil {
logger.Printf("the pipelines %s supersedes: %d ended, then %v", newer.Pipeline, n, err)
} else if n > 0 {
logger.Printf("the pipeline %s superseded %d older pipelines", newer.Pipeline, n)
}
}
if err := hearStepEnded(heard, hearer, logger.Printf); err != nil {
return err
}
if err := hearToolchain(ctx, heard, toolchains, logger.Printf); err != nil {
return err
}
if err := hearAskTaken(ctx, heard, bounds, logger.Printf); err != nil {
if err := hearAskTaken(ctx, heard, bounds, hearer.Taken, logger.Printf); err != nil {
return err
}
store := pipelineStore{ctx: ctx, kv: pipelines}
if err := watchBounds(ctx, bounds, hearer, func(key string) {
expired := boundExpired(hearer, dropUnanswered(ctx, rv.Findings, cred.Module, time.Now), logger.Printf)
if err := watchBounds(ctx, bounds, func(key string) {
if !pipeline.IsHeldKey(key) {
expired(key)
return
}
if n, err := starter.ReleaseExpired(store, key, rv.Catalog.View); err != nil {
logger.Printf("the pipelines held for the runner, whose %s passed: %d released, then %v", key, n, err)
} else {
@@ -135,9 +135,9 @@ func hearStepEnded(hub handlers, h *pipeline.Hearer, logf func(string, ...any))
return nil
}
// hearAskTaken deletes the bound of each ask a build seat's holder took (§602, §647): the ask is answered, so its five minutes
// end with no finding.
func hearAskTaken(ctx context.Context, hub handlers, bounds jetstream.KeyValue, logf func(string, ...any)) error {
// hearAskTaken deletes the bound of each ask a build seat's holder took (§602, §647) and keeps on its step the node that took
// it (A-182): the ask is answered, so its five minutes end with no finding.
func hearAskTaken(ctx context.Context, hub handlers, bounds jetstream.KeyValue, taken func(ask, node string) error, logf func(string, ...any)) error {
for _, ts := range pipeline.TakenSeats {
ts := ts
err := hub.Handle(ts.Subject, func(m jetstream.Msg) {
@@ -152,6 +152,12 @@ func hearAskTaken(ctx context.Context, hub handlers, bounds jetstream.KeyValue,
_ = m.Nak()
return
}
// The step keeps the node that took its ask, so a drop of it is asked of that node's holder (A-182).
if err := taken(ev.Ask, ev.Node); err != nil {
logf("the node %s that took ask %s is not kept on its step, heard again: %v", ev.Node, ev.Ask, err)
_ = m.Nak()
return
}
_ = m.Ack()
})
if err != nil {
@@ -218,8 +224,8 @@ func hearRunning(ctx context.Context, hub handlers, self string, st *pipeline.St
}
// watchBounds hears the end of each ask's bound, the bus's marker of the key's expiry, and ends the step no holder took (§32,
// §535, A-77): no timer in the process.
func watchBounds(ctx context.Context, bounds jetstream.KeyValue, h *pipeline.Hearer, release func(key string), logf func(string, ...any)) error {
// §535, A-77), and says a drop no holder answered unanswered (A-222): no timer in the process.
func watchBounds(ctx context.Context, bounds jetstream.KeyValue, expired func(key string), logf func(string, ...any)) error {
w, err := bounds.WatchAll(ctx, jetstream.UpdatesOnly())
if err != nil {
return err
@@ -234,16 +240,10 @@ func watchBounds(ctx context.Context, bounds jetstream.KeyValue, h *pipeline.Hea
if !ok {
return
}
if e != nil && e.Operation() == jetstream.KeyValuePurge && pipeline.IsHeldKey(e.Key()) {
release(e.Key())
if e == nil || e.Operation() != jetstream.KeyValuePurge {
continue
}
if e == nil || e.Operation() != jetstream.KeyValuePurge || !pipeline.IsAskKey(e.Key()) {
continue
}
if err := h.NotTaken(e.Key(), pipeline.AskBound); err != nil {
logf("the step of ask %s, which no holder took, is not ended: %v", e.Key(), err)
}
expired(e.Key())
}
}
}()
@@ -9,6 +9,7 @@ import (
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient"
catalog "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
seat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshpipelinerunner"
"git.novox.be/novox/mesh-sdk/go/process"
@@ -38,10 +39,11 @@ func answerVerbs(conn *process.Conn, v pipeline.Verbs) (func(), error) {
return conn.AnswerSeat(seat.Seat, writeHandlers(v))
}
// writeHandlers are the seat's write verbs the process answers: review, rerun, accept, cancel, start, ask-again and
// release (§306; RN14; A-206).
// writeHandlers are the seat's write verbs the process answers: run, review, rerun, accept, cancel, start, ask-again and
// release (§64, §306; RN12, RN14; A-206).
func writeHandlers(v pipeline.Verbs) map[string]process.SeatHandler {
return map[string]process.SeatHandler{
seat.VerbRun: verbOf(func(c string, in seat.RunInput) (any, error) { return v.Run(c, in) }),
seat.VerbReview: verbOf(func(c string, in seat.ReviewInput) (any, error) { return v.Review(c, in) }),
seat.VerbRerun: verbOf(func(c string, in seat.RerunInput) (any, error) { return v.Rerun(c, in) }),
seat.VerbAccept: verbOf(func(c string, in seat.AcceptInput) (any, error) { return v.Accept(c, in) }),
@@ -65,5 +67,8 @@ func dropBound(ctx context.Context, bounds jetstream.KeyValue) func(string) erro
// writeVerbs are the verbs over the hearer and the starter, their hand acts recorded through the seat mesh-hand-acts.
func writeVerbs(ctx context.Context, h *pipeline.Hearer, s *pipeline.Starter, view func(string) (pipeline.Half, bool),
calls *process.Conn, bounds jetstream.KeyValue) pipeline.Verbs {
return pipeline.Verbs{Hearer: h, Starter: s, View: view, Acts: meshhandactsclient.Recorder{Ask: calls.Call}, Drop: dropBound(ctx, bounds)}
candidate := func(group, branch string, revision int64) (pipeline.Half, error) {
return readCandidate(calls.CallKept, catalog.CatalogRevisionEvent{Kind: "candidate", Group: group, Branch: branch, Revision: revision})
}
return pipeline.Verbs{Hearer: h, Starter: s, View: view, Candidate: candidate, Acts: meshhandactsclient.Recorder{Ask: calls.Call}, Drop: dropBound(ctx, bounds)}
}
@@ -18,7 +18,7 @@ func TestEveryWriteVerbIsServed(t *testing.T) {
got = append(got, v)
}
slices.Sort(got)
want := []string{seat.VerbAccept, seat.VerbAskAgain, seat.VerbCancel, seat.VerbRerun, seat.VerbRelease, seat.VerbReview, seat.VerbStart}
want := []string{seat.VerbAccept, seat.VerbAskAgain, seat.VerbCancel, seat.VerbRerun, seat.VerbRelease, seat.VerbReview, seat.VerbStart, seat.VerbRun}
slices.Sort(want)
if !slices.Equal(got, want) {
t.Errorf("the process answers %v, want %v", got, want)
@@ -28,7 +28,8 @@ type runnerViews struct {
// startViews wires the runner's views on hub and asks each first view; register every other handler on hub before or
// after, then start hub once.
func startViews(ctx context.Context, js jetstream.JetStream, conn *nats.Conn, cred process.Account, hub *process.EventHub, findingsState process.State,
call meshcatalogclient.Call, onReady func(branch string), logf func(string, ...any)) (*runnerViews, error) {
call meshcatalogclient.Call, onReady func(branch string), candidate func(ctx context.Context, ev catalog.CatalogRevisionEvent) error,
logf func(string, ...any)) (*runnerViews, error) {
findings, err := findingWriter(ctx, cred, conn, js, findingsState, logf)
if err != nil {
return nil, err
@@ -55,6 +56,11 @@ func startViews(ctx context.Context, js jetstream.JetStream, conn *nats.Conn, cr
if err != nil {
return nil, err
}
// A candidate's revision, heard once on the event's one subscription whatever branches are followed, places a run's
// pipelines (A-185, A-193, A-199).
if err := meshcatalogclient.WireCandidatesProcess(reader, candidate); err != nil {
return nil, err
}
reader.Start(ctx, js, cred, status)
return &runnerViews{Registrations: regs, Catalog: cv, Findings: findings}, nil
}
@@ -97,3 +97,36 @@ func (h *Hearer) endNotTaken(key, ask string, bound time.Duration) error {
// IsAskKey says whether a key of the state `bounds` is an ask's bound.
func IsAskKey(key string) bool { return strings.HasPrefix(key, "step-") && !strings.Contains(key, ".") }
// DropBoundPrefix begins the key of a superseded step's drop in the state `bounds`, `drop.<build seat>.<ask>` (design 47 G
// A-222, A-77): its five-minute expiry says the drop unanswered.
const DropBoundPrefix = "drop."
// DropBoundKey is the key of the bound of buildSeat's drop of ask.
func DropBoundKey(buildSeat, ask string) string { return DropBoundPrefix + buildSeat + "." + ask }
// ParseDropBound reads a key of the state `bounds` as a drop's bound: the build seat and the ask it names.
func ParseDropBound(key string) (buildSeat, ask string, ok bool) {
rest, found := strings.CutPrefix(key, DropBoundPrefix)
if !found {
return "", "", false
}
buildSeat, ask, found = strings.Cut(rest, ".")
return buildSeat, ask, found && buildSeat != "" && ask != "" && !strings.Contains(ask, ".")
}
// BoundDrop is drop with a bound of five minutes (A-222, A-77): the bound key is armed, with its own time to live, before
// the drop is asked, and answered is told when the drop is answered, to delete the key, so an answer ends the wait with no
// finding; a drop never answered leaves its key to expire, and the expiry says it unanswered. A bound that cannot be armed
// is returned and the drop not asked.
func BoundDrop(drop Drop, arm func(key string) error, answered func(buildSeat, ask string) error) Drop {
return func(buildSeat, node, ask, why string) error {
if err := arm(DropBoundKey(buildSeat, ask)); err != nil {
return fmt.Errorf("the bound of the drop of ask %s is not armed: %w", ask, err)
}
if err := drop(buildSeat, node, ask, why); err != nil {
return err
}
return answered(buildSeat, ask)
}
}
@@ -0,0 +1,264 @@
package pipeline
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"maps"
"slices"
"strings"
"time"
runnerclient "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshpipelinerunnerclient"
catalog "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
seat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshpipelinerunner"
"git.novox.be/novox/mesh-sdk/go/keys"
)
// RunGroupPrefix is a run on request's candidate group before its id, `run-<id>` (design 47 G A-160).
const RunGroupPrefix = "run-"
// RunKey is the key of a run on request's pipeline, `<branch>.<id>.<module>`: the run's id under the member's named branch
// (design 47 G §420, A-193).
func RunKey(branch, run, module string) string {
return seat.PipelinesByBranchRevisionModuleKey(keys.EscapeName(branch), run, module)
}
// NewRunID is a run's id, a random 12-hex id, so two runs of one commit are two pipelines (A-180).
func NewRunID() (string, error) {
b := make([]byte, 6)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
// Run starts a run on request (design 47 G §64, §420; A-180, A-193): it writes each member's pipeline at once, running and
// with no steps, its `run` the run's new id, and says run-started, from which the indexer builds the run's candidate; the
// candidate's revision places the steps (PlaceRun). A member naming no module, commit or branch is refused, the run then
// writing nothing. caller is the one the bus proved. newID makes the run's id (NewRunID).
func (s *Starter) Run(caller string, in seat.RunInput, newID func() (string, error)) (seat.RunAnswer, error) {
if len(in.Members) == 0 {
return seat.RunAnswer{}, errors.New("a run names one or more members, each a module at a commit on a branch")
}
for _, m := range in.Members {
if m.Module == "" || m.Commit == "" || m.Branch == "" {
return seat.RunAnswer{}, fmt.Errorf("a run's member names its module, commit and branch: %+v", m)
}
}
id, err := newID()
if err != nil {
return seat.RunAnswer{}, fmt.Errorf("the run's id is not made: %w", err)
}
now := s.now()
var written []string
for _, m := range in.Members {
key := RunKey(m.Branch, id, m.Module)
p := seat.Pipeline{V: 1, Pipeline: key, Branch: m.Branch, Module: m.Module, Commit: m.Commit, Run: id,
Outcome: seat.PipelineOutcomeRunning, Started: &now, Steps: []seat.StepResult{}}
raw, err := json.Marshal(p)
if err == nil {
err = s.Pipelines.Create(key, raw)
}
if err != nil {
s.failRun(written, "the run's other pipelines were not written")
return seat.RunAnswer{}, fmt.Errorf("the pipeline %s of the run is not written: %w", key, err)
}
written = append(written, key)
}
first := in.Members[0]
last := first.Commit
if len(in.Members) > 1 {
last = RunGroupPrefix + id
}
ev := seat.RunStarted{V: 1, At: &now, By: caller, Members: in.Members, Run: id}
if err := s.Say(runnerclient.Subject(seat.EventRunStarted, first.Module, first.Branch, last), ev); err != nil {
s.failRun(written, "run-started was not said, so no candidate is built for it")
return seat.RunAnswer{}, fmt.Errorf("the run %s is not started: run-started is not said: %w", id, err)
}
return seat.RunAnswer{Run: id, Pipelines: written}, nil
}
// failRun ends the run's pipelines written before its start failed, failed and saying why, so none stands running.
func (s *Starter) failRun(written []string, why string) {
if s.Store == nil {
return
}
for _, key := range written {
for {
p, rev, err := s.Store.Get(key)
if err != nil || p.Outcome != seat.PipelineOutcomeRunning {
break
}
now := s.now()
p.Outcome, p.Why, p.Ended = seat.PipelineOutcomeFailed, why, &now
p.EndCount++
if _, err := s.Store.Update(key, p, rev); errors.Is(err, ErrChanged) {
continue
} else if err != nil {
s.logf("the pipeline %s of a run not started stays running: %v", key, err)
}
break
}
}
}
// PlaceRun places a run's pipelines from its candidate's revision (design 47 G §64, §373, A-180, A-193): ev is the
// catalog-revision of the candidate `run-<id>`, half its entries read whole through the seat's revision verb. Each member's
// pipeline of the run not yet placed takes its fingerprint, the candidate's revision and its steps, by compare-and-set, and
// says pipeline-started; each further entry whose fingerprint the members change (another than in base, the branch's catalog
// half, or absent there) gets a pipeline of the run of its own, placed the same way. Another candidate's revision places
// nothing; a revision heard again places nothing twice. It says how many pipelines it placed.
func (s *Starter) PlaceRun(ev catalog.CatalogRevisionEvent, half Half, base func(branch string) (Half, bool)) (int, error) {
id, ok := strings.CutPrefix(ev.Group, RunGroupPrefix)
if !ok || id == "" {
return 0, nil
}
if s.Store == nil {
return 0, errors.New("the runner holds no pipelines to place a run's in")
}
pkeys, err := s.Store.Keys("*." + id + ".*")
if err != nil {
return 0, fmt.Errorf("the pipelines of the run %s are not listed: %w", id, err)
}
slices.Sort(pkeys)
members := map[string]bool{}
branch := ""
for _, key := range pkeys {
if p, _, err := s.Store.Get(key); err == nil && p.Run == id {
members[p.Module] = true
if branch == "" {
branch = p.Branch
}
}
}
if branch == "" {
s.logf("the candidate %s's revision %d names a run with no pipeline here: nothing is placed", ev.Group, ev.Revision)
return 0, nil
}
placed := 0
var first error
keep := func(ok bool, err error) {
if err != nil && first == nil {
first = err
}
if ok {
placed++
}
}
for _, key := range pkeys {
keep(s.placeRunPipeline(key, id, ev.Revision, half))
}
// The dependents (§373): the candidate's entries the members change, beside the members, on the first member's branch.
trunk, ok := base(branch)
if b, err := keys.UnescapeName(ev.Branch); err == nil && b != "" && b != branch {
if h, held := base(b); held {
trunk, ok = h, true
}
}
if !ok {
s.logf("the run %s's dependents are not known: the catalog half of %s is not held, so its members run alone", id, branch)
return placed, first
}
for _, module := range slices.Sorted(maps.Keys(half.Entries)) {
e := half.Entries[module]
if members[module] {
continue
}
if was, held := trunk.Entries[module]; held && was.Fp == e.Fp {
continue
}
key := RunKey(branch, id, module)
now := s.now()
p := seat.Pipeline{V: 1, Pipeline: key, Branch: branch, Module: module, Commit: e.Commit, Run: id,
Outcome: seat.PipelineOutcomeRunning, Started: &now, Steps: []seat.StepResult{}}
raw, err := json.Marshal(p)
if err == nil {
err = s.Pipelines.Create(key, raw)
}
if err != nil && !errors.Is(err, ErrExists) {
keep(false, fmt.Errorf("the dependent %s of the run %s is not written: %w", module, id, err))
continue
}
keep(s.placeRunPipeline(key, id, ev.Revision, half))
}
return placed, first
}
// UnplacedRun is a run with a pipeline running and no steps placed yet, on the branch the run names.
type UnplacedRun struct{ Run, Branch string }
// UnplacedRuns are the runs with a pipeline running and no steps placed yet, by branch and id (design 47 G A-222): a placing
// lost while the runner was down is taken up from these at its start.
func (s *Starter) UnplacedRuns() ([]UnplacedRun, error) {
if s.Store == nil {
return nil, errors.New("the runner holds no pipelines to read a run's from")
}
pkeys, err := s.Store.Keys("*.*.*")
if err != nil {
return nil, fmt.Errorf("the pipelines are not listed: %w", err)
}
var out []UnplacedRun
for _, key := range pkeys {
if p, _, err := s.Store.Get(key); err == nil && p.Run != "" && p.Outcome == seat.PipelineOutcomeRunning && p.Revision == 0 {
if u := (UnplacedRun{Run: p.Run, Branch: p.Branch}); !slices.Contains(out, u) {
out = append(out, u)
}
}
}
slices.SortFunc(out, func(a, b UnplacedRun) int { return strings.Compare(a.Branch+"\n"+a.Run, b.Branch+"\n"+b.Run) })
return out, nil
}
// placeRunPipeline places one pipeline of the run, written and not yet placed (no revision), from the candidate's half.
func (s *Starter) placeRunPipeline(key, id string, revision int64, half Half) (bool, error) {
for {
p, rev, err := s.Store.Get(key)
if err != nil {
return false, fmt.Errorf("the pipeline %s is not read: %w", key, err)
}
if p.Run != id || p.Outcome != seat.PipelineOutcomeRunning || p.Revision != 0 {
return false, nil
}
now := s.now()
e, ok := half.Entries[p.Module]
if !ok {
s.logf("the candidate of the run %s holds no entry of %s: its pipeline is placed with no fingerprint", id, p.Module)
}
p.Fingerprint, p.Repository, p.Revision = e.Fp, e.Repository, revision
var asks []Asked
if s.Registration != nil {
if reg, ok := s.Registration(p.Module); ok {
p.Steps, asks = Place(p, reg, half.Entries, now, s.Name)
} else {
s.logf("%s has no registration yet: its pipeline is placed with no step", key)
}
}
if _, err := s.Store.Update(key, p, rev); errors.Is(err, ErrChanged) {
continue
} else if err != nil {
return false, fmt.Errorf("the pipeline %s cannot be written: %w", key, err)
}
ev := seat.PipelineStarted{V: 1, At: &now, Pipeline: key, Branch: p.Branch, Module: p.Module, Commit: p.Commit,
Fingerprint: p.Fingerprint, Revision: p.Revision, Run: id}
if err := s.Say(runnerclient.Subject(seat.EventPipelineStarted, p.Module, p.Branch, p.Commit), ev); err != nil {
s.logf("pipeline-started of %s is not said: %v", key, err)
}
if s.Steps != nil && len(asks) > 0 {
s.carry(key, asks)
if err := askAll(s.Steps, s.Arm, key, asks, s.recovered(key, p.Commit)); err != nil {
return true, err
}
}
return true, nil
}
}
func (s *Starter) now() time.Time {
if s.Now != nil {
return s.Now().UTC()
}
return time.Now().UTC()
}
@@ -0,0 +1,112 @@
package pipeline
import (
"errors"
"strings"
"testing"
catalog "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
seat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshpipelinerunner"
)
// RN12 (design 47 G §64, §373, §420; A-180, A-193): `run` writes each member's pipeline at once under the run's id and its
// named branch and says run-started; the candidate run-<id>'s revision places each member's steps and a pipeline of each
// entry the members change, says pipeline-started once each, and places nothing twice; a member naming no branch is refused.
func TestRunOnRequest(t *testing.T) {
reg, entries := placeFixture()
store := memCreated{memStore{}}
var said []string
asks := &stepAsks{}
s := &Starter{Pipelines: store, Store: store, Steps: asks, Name: "the-runner",
Registration: func(m string) (catalog.Registration, bool) {
r := reg
r.Module = m
return r, m == "app" || m == "lib"
},
Say: func(subject string, body any) error {
said = append(said, subject[strings.Index(subject, ".event.")+7:])
return nil
}}
id := func() (string, error) { return "0a1b2c3d4e5f", nil }
if _, err := s.Run("sessions", seat.RunInput{Members: []seat.Member{{Module: "app", Commit: "c1"}}}, id); err == nil {
t.Error("a member naming no branch was taken")
}
if len(store.memStore) != 0 || len(said) != 0 {
t.Fatalf("a refused run wrote %v and said %v", store.memStore, said)
}
a, err := s.Run("sessions", seat.RunInput{Members: []seat.Member{{Module: "app", Commit: "c1", Branch: "fix/1"}}}, id)
if err != nil || a.Run != "0a1b2c3d4e5f" || len(a.Pipelines) != 1 {
t.Fatalf("run = %+v, %v", a, err)
}
key := a.Pipelines[0]
if key != RunKey("fix/1", "0a1b2c3d4e5f", "app") {
t.Errorf("the member's pipeline is %s, want the run's id under its named branch", key)
}
if p, _, _ := store.Get(key); p.Run != a.Run || p.Outcome != seat.PipelineOutcomeRunning || len(p.Steps) != 0 || p.Branch != "fix/1" || p.Source != 0 {
t.Errorf("the member's pipeline %s = %+v", key, p)
}
if len(said) != 1 || !strings.HasPrefix(said[0], seat.EventRunStarted+".app.") || !strings.HasSuffix(said[0], ".c1") {
t.Errorf("said %v, want run-started on the member's module, branch and commit", said)
}
// A-222: a restart finds the runs whose steps were never placed, by their branch.
if ids, err := s.UnplacedRuns(); err != nil || len(ids) != 1 || ids[0] != (UnplacedRun{Run: a.Run, Branch: "fix/1"}) {
t.Errorf("the unplaced runs = %v (%v), want %s on fix/1", ids, err, a.Run)
}
cand := entries
cand["app"] = catalog.CatalogEntry{Module: "app", Commit: "c1", Fp: "src2:new", Repository: "o/app", Artifacts: entries["app"].Artifacts}
cand["lib"] = catalog.CatalogEntry{Module: "lib", Commit: "c0", Fp: "src2:lib2", Artifacts: entries["app"].Artifacts}
cand["same"] = catalog.CatalogEntry{Module: "same", Fp: "src2:same"}
base := func(string) (Half, bool) {
return Half{Entries: map[string]catalog.CatalogEntry{"lib": {Fp: "src2:lib1"}, "same": {Fp: "src2:same"}, "mesh-build-go": entries["mesh-build-go"]}}, true
}
ev := catalog.CatalogRevisionEvent{Kind: "candidate", Group: "run-" + a.Run, Revision: 12}
if n, err := s.PlaceRun(catalog.CatalogRevisionEvent{Kind: "candidate", Group: "try-3", Revision: 11}, Half{Entries: cand}, base); n != 0 || err != nil {
t.Errorf("a try's candidate placed %d (%v)", n, err)
}
said = nil
n, err := s.PlaceRun(ev, Half{Revision: 12, Entries: cand}, base)
if err != nil || n != 2 {
t.Fatalf("the candidate placed %d (%v), want the member and the dependent lib", n, err)
}
p, _, _ := store.Get(key)
if p.Fingerprint != "src2:new" || p.Revision != 12 || len(p.Steps) == 0 {
t.Errorf("the member's pipeline placed = %+v", p)
}
dep, _, err := store.Get(RunKey("fix/1", a.Run, "lib"))
if err != nil || dep.Run != a.Run || dep.Fingerprint != "src2:lib2" || len(dep.Steps) == 0 {
t.Errorf("the dependent's pipeline = %+v (%v)", dep, err)
}
if _, _, err := store.Get(RunKey("fix/1", a.Run, "same")); err == nil {
t.Error("an entry the members do not change got a pipeline")
}
if len(said) != 2 || !strings.HasPrefix(said[0], seat.EventPipelineStarted+".") || len(asks.ids) == 0 {
t.Errorf("placing said %v and asked %d steps", said, len(asks.ids))
}
if ids, _ := s.UnplacedRuns(); len(ids) != 0 {
t.Errorf("a placed run is still unplaced: %v", ids)
}
if n, _ := s.PlaceRun(ev, Half{Revision: 12, Entries: cand}, base); n != 0 {
t.Errorf("the revision heard again placed %d", n)
}
// A restart asks a run's steps again from no branch's half: they are its candidate's.
if n, err := s.Resume(store, "fix/1", Half{Entries: entries}); n != 0 || err != nil {
t.Errorf("a restart asked %d steps of the run again (%v)", n, err)
}
// Several members say run-started on the first member's module and branch and run-<id>; a run-started not said fails the
// pipelines written, so none stands running.
said = nil
two, err := s.Run("operator", seat.RunInput{Members: []seat.Member{{Module: "app", Commit: "c2", Branch: "main"}, {Module: "lib", Commit: "c3", Branch: "main"}}},
func() (string, error) { return "ffffffffffff", nil })
if err != nil || len(two.Pipelines) != 2 || len(said) != 1 || said[0] != seat.EventRunStarted+".app.main.run-ffffffffffff" {
t.Errorf("a run of two: %+v (%v), said %v", two, err, said)
}
s.Say = func(string, any) error { return errors.New("bus away") }
three, err := s.Run("operator", seat.RunInput{Members: []seat.Member{{Module: "app", Commit: "c4", Branch: "main"}}}, func() (string, error) { return "eeeeeeeeeeee", nil })
if err == nil || three.Run != "" {
t.Errorf("a run whose run-started was not said answered %+v", three)
}
if p, _, _ := store.Get(RunKey("main", "eeeeeeeeeeee", "app")); p.Outcome != seat.PipelineOutcomeFailed {
t.Errorf("its pipeline is %s, want failed", p.Outcome)
}
}
@@ -39,6 +39,8 @@ type Begin struct {
Await string
// Entries are the catalog half's entries of the pipeline's branch: the steps' artifacts and build seats are read from them.
Entries map[string]catalog.CatalogEntry
// Published is the package's publish that started the pipeline, nil for any other start (A-202).
Published *seat.Published
}
// Key is a pipeline's key in the state pipelines, `<branch>.<source revision>.<module>`, the branch escaped as a key's
@@ -86,12 +88,16 @@ type Starter struct {
Fills *Fills
// Recovered says whether a commit is named a recovery (§81, §86): its steps are asked on the build seats' stream delivery.
Recovered func(commit string) (bool, error)
// Store is the state pipelines read and written by compare-and-set, where a run's pipelines are placed (PlaceRun).
Store Updater
// Supersede ends the older running pipelines the pipeline just written supersedes (§31; Hearer.Supersede); nil ends none.
Supersede func(newer seat.Pipeline)
}
func newPipeline(b Begin, key string, now time.Time) seat.Pipeline {
return seat.Pipeline{V: 1, Pipeline: key, Branch: b.Branch, Source: b.Source, Module: b.Module, Commit: b.Commit, Fingerprint: b.Fp,
Group: b.Group, Repository: b.Repository, Revision: b.Revision, Outcome: seat.PipelineOutcomeRunning, Started: &now,
Steps: []seat.StepResult{}}
Steps: []seat.StepResult{}, Published: b.Published}
}
func (s *Starter) create(key string, p seat.Pipeline) error {
@@ -140,7 +146,7 @@ func (s *Starter) Start(b Begin) (bool, error) {
return false, fmt.Errorf("the pipeline %s is not written: %w", key, err)
}
ev := seat.PipelineStarted{V: 1, At: &now, Pipeline: key, Branch: b.Branch, Source: b.Source, Module: b.Module,
Commit: b.Commit, Fingerprint: b.Fp, Group: b.Group, Revision: b.Revision}
Commit: b.Commit, Fingerprint: b.Fp, Group: b.Group, Revision: b.Revision, Published: b.Published}
commitOrGroup := b.Commit
if b.Group != "" {
commitOrGroup = b.Group
@@ -148,6 +154,9 @@ func (s *Starter) Start(b Begin) (bool, error) {
if err := s.Say(runnerclient.Subject(seat.EventPipelineStarted, b.Module, b.Branch, commitOrGroup), ev); err != nil {
s.logf("pipeline-started of %s is not said: %v", key, err)
}
if s.Supersede != nil {
s.Supersede(p)
}
if s.Steps != nil && len(asks) > 0 {
s.carry(key, asks)
if err := askAll(s.Steps, s.Arm, key, asks, s.recovered(key, b.Commit)); err != nil {
@@ -184,7 +193,8 @@ func (s *Starter) Resume(store Updater, branch string, half Half) (int, error) {
}
continue
}
if p.Branch != branch || p.Outcome != seat.PipelineOutcomeRunning {
// A run on request's steps are placed from its candidate, not from the branch's half (PlaceRun).
if p.Branch != branch || p.Outcome != seat.PipelineOutcomeRunning || p.Run != "" {
continue
}
reg, ok := s.Registration(p.Module)
@@ -300,7 +310,8 @@ func (s *Starter) Published(ev builds.PackagePublished, outdated func(catalog.Ou
if o.Kind != outdatedKind || o.Module == "" {
continue
}
b := Begin{Branch: ev.Branch, Source: ev.Source, Module: o.Module, Revision: half.Revision, Entries: half.Entries}
b := Begin{Branch: ev.Branch, Source: ev.Source, Module: o.Module, Revision: half.Revision, Entries: half.Entries,
Published: &seat.Published{Artifact: ev.Artifact, Version: ev.Version}}
if e, ok := half.Entries[o.Module]; ok {
b.Commit, b.Fp, b.Repository = e.Commit, e.Fp, e.Repository
} else {
@@ -27,11 +27,15 @@ func (c created) Create(key string, value []byte) error {
func TestStarts(t *testing.T) {
store := created{}
var said []string
var superseding []string
s := &Starter{Pipelines: store, Say: func(subject string, body any) error {
ev := body.(seat.PipelineStarted)
said = append(said, subject+" "+ev.Pipeline)
if ev.Published == nil || ev.Published.Artifact != "mesh-sdk:go" || ev.Published.Version != "0.1.16" {
t.Errorf("pipeline-started of %s says the publish %+v (A-202)", ev.Pipeline, ev.Published)
}
return nil
}}
}, Supersede: func(newer seat.Pipeline) { superseding = append(superseding, newer.Pipeline) }}
view := func(branch string) (Half, bool) {
return Half{Revision: 9, Entries: map[string]catalog.CatalogEntry{
"app": {Module: "app", Commit: "c1", Fp: "src2:aa", Repository: "o/app"}}}, true
@@ -51,9 +55,13 @@ func TestStarts(t *testing.T) {
}
var p seat.Pipeline
if err := json.Unmarshal(store["main.41.app"], &p); err != nil || p.Commit != "c1" || p.Fingerprint != "src2:aa" || p.Revision != 9 ||
p.Outcome != seat.PipelineOutcomeRunning || p.Source != 41 {
p.Outcome != seat.PipelineOutcomeRunning || p.Source != 41 || p.Published == nil || p.Published.Version != "0.1.16" {
t.Errorf("the pipeline main.41.app = %+v (%v)", p, err)
}
// Each pipeline written supersedes the older ones it replaces (§31).
if len(superseding) != 2 {
t.Errorf("the publish's pipelines superseding: %v, want both", superseding)
}
if _, ok := store["main.41.ordered"]; ok {
t.Error("an ordering edge starts no pipeline")
}
@@ -66,6 +74,7 @@ func TestStarts(t *testing.T) {
if !strings.HasPrefix(said[0], "mesh.seat.mesh-pipeline-runner.event.pipeline-started.app.main.c1 main.41.app") {
t.Errorf("pipeline-started said on %q", said[0])
}
s.Say = func(string, any) error { return nil }
// A branch holding a dot is escaped in the key, so the key reads back; a start naming no source revision is refused.
if _, err := s.Start(Begin{Branch: "release.1", Source: 3, Module: "app"}); err != nil {
t.Fatal(err)
@@ -0,0 +1,158 @@
package pipeline
import (
"errors"
"fmt"
"time"
seat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshpipelinerunner"
)
// Drop asks a build seat's `drop` of a superseded step's ask as a kept call (design 47 G §31, §350; A-182): of node's holder
// when a holder took it (node the one its ask-taken named), else of any holder, which records it in the seat's shared drop
// key so no holder takes it. why names the newer commit.
type Drop func(buildSeat, node, ask, why string) error
// Taken notes on the running step asked under ask the node whose holder said ask-taken for it (A-182), so a drop of it is
// asked of that node's holder. A step no pipeline keeps running under ask takes nothing.
func (h *Hearer) Taken(ask, node string) error {
if ask == "" || node == "" {
return nil
}
pkeys, err := h.Store.Keys("*.*.*")
if err != nil {
return fmt.Errorf("the pipelines are not listed: %w", err)
}
for _, key := range pkeys {
for {
p, rev, err := h.Store.Get(key)
if err != nil {
break
}
i := stepOfAsk(p, ask)
if i < 0 || p.Steps[i].Outcome != seat.StepResultOutcomeRunning || p.Steps[i].Node == node {
break
}
p.Steps[i].Node = node
if _, err := h.Store.Update(key, p, rev); errors.Is(err, ErrChanged) {
continue
} else if err != nil {
return fmt.Errorf("pipeline %s cannot be written: %w", key, err)
}
return nil
}
}
return nil
}
// Supersede ends, for the pipeline newer just written, each older running pipeline of the same watched branch and module
// whose fingerprint newer's commit changed, and for a publish's pipeline each an older publish of the package started
// (design 47 G §31, §119; ADR 0666 §9; A-202): the pipeline and each step not ended
// `superseded`, naming the newer commit, and it says pipeline-ended. A module the newer commit left as it was finishes its
// pipeline. The ask of each superseded step on a build seat that serves `drop` is dropped (drop; its bound deleted through
// unbound); a step on mesh-builds or mesh-wants, whose seats serve no drop, is only ended superseded (A-182). A run on
// request's pipeline neither supersedes nor is superseded. It says how many pipelines it ended.
func (h *Hearer) Supersede(newer seat.Pipeline, drop Drop, unbound func(ask string) error) (int, error) {
if newer.Run != "" || newer.Source <= 0 {
return 0, nil
}
pkeys, err := h.Store.Keys("*.*.*")
if err != nil {
return 0, fmt.Errorf("the pipelines are not listed: %w", err)
}
why := supersededWhy(newer)
ended := 0
var first error
for _, key := range pkeys {
if key == newer.Pipeline {
continue
}
done, err := h.supersedeOne(key, newer, why, drop, unbound)
if err != nil && first == nil {
first = err
}
if done {
ended++
}
}
return ended, first
}
// superseded says whether old is a running pipeline newer supersedes (§31, A-202): of the same watched branch and an older
// source revision, either of the same module at another fingerprint, or, for newer a package's publish started, one an older
// publish of that package started (its version another, made from an older source revision of the branch).
func superseded(old, newer seat.Pipeline) bool {
if old.Outcome != seat.PipelineOutcomeRunning || old.Run != "" || old.Branch != newer.Branch || old.Source >= newer.Source {
return false
}
if newer.Published != nil && old.Published != nil && old.Published.Artifact == newer.Published.Artifact {
return old.Published.Version != newer.Published.Version
}
return old.Module == newer.Module && old.Fingerprint != newer.Fingerprint
}
// supersededWhy names the newer commit, or the newer publish, that ended a pipeline.
func supersededWhy(newer seat.Pipeline) string {
if newer.Published != nil {
return fmt.Sprintf("the newer publish %s %s of %s supersedes it", newer.Published.Artifact, newer.Published.Version, newer.Branch)
}
return fmt.Sprintf("the newer commit %s of %s changed %s's fingerprint", newer.Commit, newer.Branch, newer.Module)
}
func (h *Hearer) supersedeOne(key string, newer seat.Pipeline, why string, drop Drop, unbound func(string) error) (bool, error) {
for {
p, rev, err := h.Store.Get(key)
if err != nil || !superseded(p, newer) {
return false, nil
}
now := h.now()
var drops []seat.StepResult
var changed []int
for i := range p.Steps {
s := &p.Steps[i]
if s.Outcome != seat.StepResultOutcomeRunning && s.Outcome != seat.StepResultOutcomeWaiting {
continue
}
if s.Ask != "" && BoundSeat(s.Seat) {
drops = append(drops, *s)
}
s.Outcome, s.Why, s.Ended = seat.StepResultOutcomeSuperseded, why, ptrTime(now)
p.Unlogged = append(p.Unlogged, endEntry(key, *s, now))
changed = append(changed, i)
}
p.Outcome, p.Why, p.Ended = seat.PipelineOutcomeSuperseded, why, ptrTime(now)
p.EndCount++
if _, err := h.Store.Update(key, p, rev); errors.Is(err, ErrChanged) {
continue
} else if err != nil {
return false, fmt.Errorf("pipeline %s cannot be written: %w", key, err)
}
var first error
for _, i := range changed {
if err := h.say(seat.EventStepEnded, p, &p.Steps[i]); err != nil && first == nil {
first = fmt.Errorf("pipeline %s's %s is written but not said: %w", key, seat.EventStepEnded, err)
}
}
if len(changed) > 0 {
h.appendUnlogged(key)
}
for _, s := range drops {
if drop != nil {
if err := drop(s.Seat, s.Node, s.Ask, why); err != nil {
h.logf("the drop of ask %s of the superseded %s is not answered: %v", s.Ask, key, err)
}
}
if unbound != nil {
if err := unbound(s.Ask); err != nil {
h.logf("the bound of ask %s of the superseded %s is not dropped: %v", s.Ask, key, err)
}
}
}
if err := h.said(p, false, true); err != nil && first == nil {
first = err
}
return true, first
}
}
func ptrTime(t time.Time) *time.Time { return &t }
@@ -0,0 +1,125 @@
package pipeline
import (
"errors"
"slices"
"strings"
"testing"
builds "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshbuilds"
seat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshpipelinerunner"
nodebuildgo "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/nodebuildgo"
)
// RN8 (design 47 G §31, §119, §350; A-182, A-202): a step keeps the node that took its ask; a newer commit changing the
// module's fingerprint, or a newer publish of the package that started a pipeline, ends the older running pipeline
// superseded, its asks dropped of the node that took each (any holder for one never taken), a mesh-builds step only ended;
// an unchanged fingerprint, a run on request and an equal publish supersede nothing.
func TestSupersede(t *testing.T) {
h, store, said, _, _, _ := fixture()
old := store[pkey]
old.p.Steps = append(old.p.Steps, seat.StepResult{Artifact: "program", Step: seat.StepResultStepComposition, Status: seat.StepResultStatusRequired,
Outcome: seat.StepResultOutcomeRunning, Ask: "b1", Seat: builds.Seat})
store[pkey] = old
if err := h.Taken("a1", "n7"); err != nil {
t.Fatal(err)
}
if p, _, _ := store.Get(pkey); p.Steps[0].Node != "n7" || p.Steps[1].Node != "" {
t.Fatalf("after ask-taken of a1 on n7 the steps name %q and %q", p.Steps[0].Node, p.Steps[1].Node)
}
type dropped struct{ seat, node, ask string }
var drops []dropped
var unbound []string
drop := func(s, node, ask, why string) error {
if !strings.Contains(why, "c2") {
t.Errorf("the drop of %s names no newer commit: %q", ask, why)
}
drops = append(drops, dropped{s, node, ask})
return nil
}
unbind := func(ask string) error { unbound = append(unbound, ask); return nil }
newer := func(key string, source int64, fp string, run string, pub *seat.Published) seat.Pipeline {
return seat.Pipeline{Pipeline: key, Branch: "main", Source: source, Module: "app", Commit: "c2", Fingerprint: fp, Run: run, Published: pub,
Outcome: seat.PipelineOutcomeRunning}
}
for _, c := range []struct {
name string
newer seat.Pipeline
}{
{"an unchanged fingerprint", newer("main.6.app", 6, "src2:aa", "", nil)},
{"a run on request", newer("main.r1.app", 6, "src2:bb", "r1", nil)},
{"an older source revision", newer("main.4.app", 4, "src2:bb", "", nil)},
} {
if n, err := h.Supersede(c.newer, drop, unbind); n != 0 || err != nil {
t.Errorf("%s superseded %d (%v)", c.name, n, err)
}
}
*said = nil
if n, err := h.Supersede(newer("main.6.app", 6, "src2:bb", "", nil), drop, unbind); n != 1 || err != nil {
t.Fatalf("a changed fingerprint superseded %d (%v)", n, err)
}
p, _, _ := store.Get(pkey)
if p.Outcome != seat.PipelineOutcomeSuperseded || p.EndCount != 1 || !strings.Contains(p.Why, "c2") {
t.Errorf("the older pipeline is %s (%d ends): %q", p.Outcome, p.EndCount, p.Why)
}
for _, s := range p.Steps {
if s.Outcome != seat.StepResultOutcomeSuperseded {
t.Errorf("the %s step is %s, want superseded", s.Step, s.Outcome)
}
}
want := []dropped{{nodebuildgo.Seat, "n7", "a1"}, {nodebuildgo.Seat, "", "a2"}}
if !slices.Equal(drops, want) || !slices.Equal(unbound, []string{"a1", "a2"}) {
t.Errorf("dropped %+v and unbound %v, want %+v (the mesh-builds step only ended)", drops, unbound, want)
}
if n := len(*said); n != 5 || (*said)[4] != seat.EventPipelineEnded+".app.main.c1" {
t.Errorf("said %v, want four step-ended then pipeline-ended", *said)
}
if n, _ := h.Supersede(newer("main.7.app", 7, "src2:cc", "", nil), drop, unbind); n != 0 {
t.Errorf("a superseded pipeline was superseded again: %d", n)
}
// A newer publish of a package supersedes the pipelines an older publish of it started, whatever their module.
pub := memStore{"main.5.lib": {p: seat.Pipeline{Pipeline: "main.5.lib", Branch: "main", Source: 5, Module: "lib", Fingerprint: "src2:aa",
Outcome: seat.PipelineOutcomeRunning, Published: &seat.Published{Artifact: "core:pkg", Version: "v1"}}, rev: 1}}
h.Store = pub
if n, _ := h.Supersede(newer("main.6.app", 6, "src2:aa", "", &seat.Published{Artifact: "core:pkg", Version: "v1"}), drop, unbind); n != 0 {
t.Errorf("a publish of the same version superseded %d", n)
}
if n, err := h.Supersede(newer("main.6.app", 6, "src2:aa", "", &seat.Published{Artifact: "core:pkg", Version: "v2"}), drop, unbind); n != 1 || err != nil {
t.Fatalf("a newer publish superseded %d (%v)", n, err)
}
if p, _, _ := pub.Get("main.5.lib"); p.Outcome != seat.PipelineOutcomeSuperseded || !strings.Contains(p.Why, "v2") {
t.Errorf("the older publish's pipeline is %s: %q", p.Outcome, p.Why)
}
}
// A-222, A-77: a drop is bounded by a key of its own armed before it is asked and deleted when it is answered; one never
// answered leaves its key to expire.
func TestBoundDrop(t *testing.T) {
var armed, answered []string
arm := func(key string) error { armed = append(armed, key); return nil }
done := func(s, ask string) error { answered = append(answered, s+"/"+ask); return nil }
if s, ask, ok := ParseDropBound(DropBoundKey(nodebuildgo.Seat, "step-ab")); !ok || s != nodebuildgo.Seat || ask != "step-ab" {
t.Errorf("the drop's key reads %q, %q, %v", s, ask, ok)
}
if _, _, ok := ParseDropBound("step-ab"); ok {
t.Error("an ask's bound was read as a drop's")
}
if IsAskKey(DropBoundKey("s", "step-ab")) {
t.Error("a drop's bound was taken for an ask's")
}
err := BoundDrop(func(string, string, string, string) error { return nil }, arm, done)(nodebuildgo.Seat, "n1", "step-ab", "why")
if err != nil || len(armed) != 1 || armed[0] != "drop."+nodebuildgo.Seat+".step-ab" || len(answered) != 1 {
t.Errorf("an answered drop: %v, armed %v, answered %v", err, armed, answered)
}
answered = nil
err = BoundDrop(func(string, string, string, string) error { return errors.New("no responders") }, arm, done)(nodebuildgo.Seat, "", "step-cd", "why")
if err == nil || len(armed) != 2 || len(answered) != 0 {
t.Errorf("an unanswered drop: %v, armed %v, answered %v (its key is left to expire)", err, armed, answered)
}
asked := false
err = BoundDrop(func(string, string, string, string) error { asked = true; return nil }, func(string) error { return errors.New("full") }, done)(nodebuildgo.Seat, "", "step-ef", "why")
if err == nil || asked {
t.Errorf("a drop whose bound is not armed: %v, asked %v", err, asked)
}
}
@@ -10,6 +10,7 @@ import (
seat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshpipelinerunner"
wants "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
nodebuildgo "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/nodebuildgo"
"git.novox.be/novox/mesh-sdk/go/keys"
"git.novox.be/novox/mesh-sdk/go/views"
)
@@ -21,6 +22,9 @@ type Verbs struct {
// Starter asks a step again (rerun), as it asks the steps of a start; View reads a branch's catalog half for the ask.
Starter *Starter
View func(branch string) (Half, bool)
// Candidate reads a run's candidate's catalog half, its group `run-<id>`, on branch at the revision its pipeline was placed
// from, for a rerun of a run's step (A-222): a run's steps are the candidate's, never the branch's.
Candidate func(group, branch string, revision int64) (Half, error)
// Acts records a hand act before the verb does it (rerun, accept, cancel); a nil Acts refuses the verb, nothing recorded.
Acts views.HandActs
// Drop drops the bound of an ask a cancelled pipeline no longer waits on.
@@ -299,15 +303,25 @@ func (v Verbs) Rerun(caller string, in seat.RerunInput) (seat.RerunAnswer, error
// askOf is the ask to put on the serving seat for step s again: the one Place makes for it, under a new id (an ask put twice
// under one id is kept once), saying it is a rerun and by whom.
func (v Verbs) askOf(p seat.Pipeline, s seat.StepResult, caller string, now time.Time) (*Asked, error) {
if v.Starter == nil || v.Starter.Registration == nil || v.Starter.Steps == nil || v.View == nil {
if v.Starter == nil || v.Starter.Registration == nil || v.Starter.Steps == nil || (v.View == nil && p.Run == "") {
return nil, errors.New("the runner cannot ask a step: it holds no registration or no ask")
}
reg, ok := v.Starter.Registration(p.Module)
if !ok {
return nil, fmt.Errorf("%s has no registration", p.Module)
}
half, ok := v.View(p.Branch)
if !ok {
var half Half
if p.Run != "" {
if v.Candidate == nil {
return nil, errors.New("the runner cannot read a run's candidate")
}
var err error
if half, err = v.Candidate(RunGroupPrefix+p.Run, keys.EscapeName(p.Branch), p.Revision); err != nil {
return nil, fmt.Errorf("the candidate %s%s's revision %d is not read, so its step is not asked: %w", RunGroupPrefix, p.Run, p.Revision, err)
}
} else if h, ok := v.View(p.Branch); ok {
half = h
} else {
return nil, fmt.Errorf("the catalog half of %s is not held yet: the step is asked when it is", p.Branch)
}
_, placed := Place(p, reg, half.Entries, now, v.Starter.Name)
@@ -338,6 +352,15 @@ func (v Verbs) askOf(p seat.Pipeline, s seat.StepResult, caller string, now time
return nil, fmt.Errorf("the %s step of %s is placed no ask now", s.Step, p.Pipeline)
}
// Run starts a run on request for the caller the bus proved (design 47 G §64; Starter.Run): its pipelines written, its
// run-started said, its steps placed when its candidate's revision is heard.
func (v Verbs) Run(caller string, in seat.RunInput) (seat.RunAnswer, error) {
if v.Starter == nil {
return seat.RunAnswer{}, errors.New("the runner cannot start a run: it holds no starter")
}
return v.Starter.Run(caller, in, NewRunID)
}
// Start starts by hand a delivering pipeline for the head of a watched branch the runner's catalog half holds, recorded in
// the hand-act log first (§306, §394): the pipeline of that branch's source revision and the module, as a take-in would
// start it. A head whose pipeline is written already starts nothing and is refused, naming the pipeline.
@@ -155,6 +155,59 @@ func TestAcceptRerunAndCancelWriteByCompareAndSetAndRecordTheirHandAct(t *testin
}
}
// A-222: a rerun of a run's step asks with the candidate's catalog half (its entries, here the directory), never the branch's,
// and is refused, nothing asked, when the candidate cannot be read.
func TestRerunOfARunAsksWithItsCandidatesHalf(t *testing.T) {
v, store, _, asks, _, _ := verbsFixture()
p, rev, _ := store.Get(pkey)
p.Run, p.Branch, p.Revision = "0a1b2c3d4e5f", "fix/1", 12
for i := range p.Steps {
if p.Steps[i].Step == "lint" && p.Steps[i].Artifact == "app:program" {
p.Steps[i].Outcome = seat.StepResultOutcomeFailed
}
}
store.Update(pkey, p, rev)
_, cand := placeFixture()
e := cand["app"]
e.Path = "candidate/app"
cand["app"] = e
var read struct {
group, branch string
revision int64
}
v.Candidate = func(group, branch string, revision int64) (Half, error) {
read.group, read.branch, read.revision = group, branch, revision
return Half{Revision: revision, Entries: cand}, nil
}
in := seat.RerunInput{Pipeline: pkey, Step: "lint", Artifact: "app:program", Why: "retry"}
if _, err := v.Rerun("operator", in); err != nil {
t.Fatal(err)
}
if b, ok := asks.asks[0].(nodebuildgo.StepAsk); !ok || b.Step == nil || b.Step.Fetch.Dir != "candidate/app" {
t.Errorf("the ask = %+v, want the candidate's directory", asks.asks[0])
}
if read.group != "run-0a1b2c3d4e5f" || read.branch != "fix/1" || read.revision != 12 {
t.Errorf("the candidate read = %+v", read)
}
p, rev, _ = store.Get(pkey)
p.Steps[stepIndex(p, "lint", "app:program")].Outcome = seat.StepResultOutcomeFailed
store.Update(pkey, p, rev)
v.Candidate = func(string, string, int64) (Half, error) { return Half{}, errors.New("no answer") }
n := len(asks.asks)
if _, err := v.Rerun("operator", in); err == nil || len(asks.asks) != n {
t.Errorf("a rerun with no candidate read: %v, asked %d more", err, len(asks.asks)-n)
}
}
func stepIndex(p seat.Pipeline, step, artifact string) int {
for i, s := range p.Steps {
if s.Step == step && s.Artifact == artifact {
return i
}
}
return -1
}
// A hand act the log does not take is a verb that does nothing (ADR 0565 clause 11).
func TestAVerbWhoseHandActIsNotRecordedDoesNothing(t *testing.T) {
v, store, _, _, _, _ := verbsFixture()
@@ -88,6 +88,8 @@ func TestManifest(t *testing.T) {
"seat:mesh-grants.grants-for",
// RN11 (§33, A-191): the author of a failed head, read as the forge records it.
"seat:git.pull",
// RN8 (§31, §350; A-182): a superseded step's ask dropped of the build seat that holds it.
"seat:node-build-go.drop", "seat:node-build-node.drop", "seat:node-build-image.drop",
// A-74, A-89: who holds the seat operator-channel, whose state asks holds the router's record of the approval ask.
"seat:mesh-module-assigner.assignments",
// A-102 (§22, §24): the accepted kinds the runner puts its steps on.
+4 -1
View File
@@ -67,7 +67,10 @@
"seat:node-build-image.check.step",
"seat:node-build-go.delivery.step",
"seat:node-build-node.delivery.step",
"seat:node-build-image.delivery.step"
"seat:node-build-image.delivery.step",
"seat:node-build-go.drop",
"seat:node-build-node.drop",
"seat:node-build-image.drop"
],
"uses": [
"operator-channel"
@@ -11,10 +11,12 @@ import (
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-delivery/modules/mesh-grants/grantsstate"
sdkbc "git.novox.be/novox/mesh-sdk/go/buscompose"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/process"
"git.novox.be/novox/mesh-sdk/go/seal"
@@ -403,3 +405,221 @@ func manifestIn(t *testing.T, module string) catalogue.Manifest {
}
return m
}
// composedBus is a server loading the accounts composed from records, its conf file rewritten and reloaded by recompose.
type composedBus struct {
t *testing.T
s *server.Server
conf string
}
func startComposedBus(t *testing.T, records buscompose.Records) *composedBus {
dir := t.TempDir()
b := &composedBus{t: t, conf: filepath.Join(dir, "server.conf")}
b.write(records, dir)
opts, err := server.ProcessConfigFile(b.conf)
if err != nil {
t.Fatalf("the composed list is not the server's: %v", err)
}
opts.NoLog, opts.NoSigs = true, true
if b.s, err = server.NewServer(opts); err != nil {
t.Fatal(err)
}
go b.s.Start()
if !b.s.ReadyForConnections(30 * time.Second) {
t.Fatal("the bus did not come up")
}
t.Cleanup(b.s.Shutdown)
return b
}
func (b *composedBus) write(records buscompose.Records, dir string) {
users, err := buscompose.Users(records)
if err != nil {
b.t.Fatal(err)
}
for i := range users {
users[i].PasswordHash = "pw-" + users[i].Username()
}
accounts, err := buscompose.ComposeAccounts(users)
if err != nil {
b.t.Fatal(err)
}
if err := os.WriteFile(b.conf, []byte("listen: 127.0.0.1:-1\njetstream { store_dir: \""+dir+"\" }\n"+accounts), 0o600); err != nil {
b.t.Fatal(err)
}
}
// recompose writes the accounts composed from records again and has the server load them.
func (b *composedBus) recompose(records buscompose.Records) {
b.write(records, filepath.Dir(b.conf))
if err := b.s.Reload(); err != nil {
b.t.Fatalf("the server does not load the composed list again: %v", err)
}
}
// dial connects as a composed user; the server's refusals of its subscriptions and publishes arrive on refusals.
func (b *composedBus) dial(user string) (*nats.Conn, chan string) {
refusals := make(chan string, 16)
nc, err := nats.Connect(b.s.ClientURL(), nats.UserInfo(user, "pw-"+user), nats.CustomInboxPrefix("_INBOX."+user),
nats.ErrorHandler(func(_ *nats.Conn, _ *nats.Subscription, err error) { refusals <- err.Error() }))
if err != nil {
b.t.Fatalf("%s: %v", user, err)
}
b.t.Cleanup(nc.Close)
return nc, refusals
}
// refusedWithin waits, within a bound, for the server's refusal naming subject.
func refusedWithin(refusals chan string, subject string) bool {
bound := time.NewTimer(5 * time.Second)
defer bound.Stop()
for {
select {
case e := <-refusals:
if strings.Contains(e, "Permissions Violation") && strings.Contains(e, subject) {
return true
}
case <-bound.C:
return false
}
}
}
// theToolRunnerHearsEveryEventOnTheBus, run by TestTheGrantsPartWhole, defends design 34 every-event §9 and §27 on a
// real server (ADR 0670 §1, §3, ADR 0671 §1, §2): the tool runner's account, as composed, hears a module's event, a
// module stream's and a restricted stream's whose grant value names no tool runner, still after that value changes and
// the accounts are composed again; it publishes none of them, hears no accept or proof, and gets no durable consumer
// and no acknowledgement on the restricted stream; another module hears no event it does not consume, and a module
// consuming the restricted stream with no entry in its grant value gets no consumer and no read.
func theToolRunnerHearsEveryEventOnTheBus(t *testing.T) {
const ledger = "mesh.stream.shop.event.booked"
records := func(auditorEntry []string) buscompose.Records {
return buscompose.Records{Nodes: []string{"a"}, Assigned: map[string][]buscompose.Declared{"a": {
{Module: buscompose.GrantsModule},
{Module: "node-tools", ToolRunner: true},
{Module: "mesh-delivery-view", Emits: []string{"delivered"}},
{Module: "plex"},
{Module: "shop", Streams: []buscompose.ModuleStream{
{Module: "shop", Name: "SHOP", Events: []string{"opened"}, MaxAgeDays: 1, MaxMiB: 1},
{Module: "shop", Name: "LEDGER", Events: []string{"booked"}, MaxAgeDays: 1, MaxMiB: 1, Restricted: true}}},
{Module: "auditor", RunsAs: "auditor", Consumes: []string{"shop.booked"},
Hears: []buscompose.Heard{{Stream: "LEDGER", Emitter: "shop", Filters: auditorEntry, Restricted: true}}},
}}}
}
bus := startComposedBus(t, records(nil))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
refusedIn := func() context.Context {
c, stop := context.WithTimeout(ctx, 2*time.Second)
t.Cleanup(stop)
return c
}
grantsConn, _ := bus.dial("a." + buscompose.GrantsModule)
grants, err := jetstream.New(grantsConn)
if err != nil {
t.Fatal(err)
}
for _, st := range []jetstream.StreamConfig{{Name: "SHOP", Subjects: []string{"mesh.stream.shop.event.opened"}},
{Name: "LEDGER", Subjects: []string{ledger}}} {
if _, err := grants.CreateStream(ctx, st); err != nil {
t.Fatalf("the grants part creates the declared stream %s: %v", st.Name, err)
}
}
runner, runnerRefused := bus.dial("a.node-tools")
heard := make(chan *nats.Msg, 16)
for _, s := range sdkbc.EveryEvent() {
if _, err := runner.ChanSubscribe(s, heard); err != nil {
t.Fatal(err)
}
}
if err := runner.Flush(); err != nil {
t.Fatal(err)
}
hears := func(subject, from string) {
t.Helper()
select {
case m := <-heard:
if m.Subject != subject {
t.Errorf("the tool runner heard %s, want %s published by %s", m.Subject, subject, from)
}
case <-time.After(5 * time.Second):
t.Errorf("the tool runner heard nothing, want %s published by %s", subject, from)
}
}
view, _ := bus.dial("a.mesh-delivery-view")
shop, _ := bus.dial("a.shop")
for _, e := range []struct {
conn *nats.Conn
subject, from string
}{
{view, "mesh.mod.mesh-delivery-view.event.delivered", "the view"},
{shop, "mesh.stream.shop.event.opened", "a module's stream"},
{shop, ledger, "a restricted stream whose grant names no tool runner"}} {
if err := e.conn.Publish(e.subject, []byte(`{}`)); err != nil {
t.Fatal(err)
}
if err := e.conn.Flush(); err != nil {
t.Fatal(err)
}
hears(e.subject, e.from)
}
// It publishes no event, and hears no accept nor proof (they are never events).
if err := runner.Publish("mesh.mod.mesh-delivery-view.event.delivered", []byte(`{}`)); err != nil {
t.Fatal(err)
}
if !refusedWithin(runnerRefused, "mesh.mod.mesh-delivery-view.event.delivered") {
t.Error("the tool runner published the view's event")
}
for _, s := range []string{"mesh.seat.channel.accept.show.k", "mesh.seat.intake.proof.code.k"} {
if _, err := runner.SubscribeSync(s); err != nil {
t.Fatal(err)
}
if !refusedWithin(runnerRefused, s) {
t.Errorf("the tool runner subscribed %s", s)
}
}
// No durable consumer, no acknowledgement on the restricted stream.
runnerJS, err := jetstream.New(runner)
if err != nil {
t.Fatal(err)
}
if _, err := runnerJS.CreateConsumer(refusedIn(), "LEDGER", jetstream.ConsumerConfig{Durable: "node-tools"}); err == nil {
t.Error("the tool runner made a durable consumer on the restricted stream")
}
if err := runner.Publish("$JS.ACK.LEDGER.node-tools.1.1.1.1.0", nil); err != nil {
t.Fatal(err)
}
if !refusedWithin(runnerRefused, "$JS.ACK.LEDGER") {
t.Error("the tool runner acknowledged on the restricted stream")
}
// Another module hears no event it does not consume.
plex, plexRefused := bus.dial("a.plex")
if _, err := plex.SubscribeSync("mesh.mod.mesh-delivery-view.event.delivered"); err != nil {
t.Fatal(err)
}
if !refusedWithin(plexRefused, "mesh.mod.mesh-delivery-view.event.delivered") {
t.Error("plex subscribed an event it does not consume")
}
// A consumer of the restricted stream with no entry in its grant value gets no consumer and no read.
auditor, _ := bus.dial("a.auditor")
auditorJS, err := jetstream.New(auditor)
if err != nil {
t.Fatal(err)
}
if _, err := auditorJS.CreateConsumer(refusedIn(), "LEDGER", jetstream.ConsumerConfig{Durable: "auditor"}); err == nil {
t.Error("a consumer with no entry made a durable consumer on the restricted stream")
}
if _, err := auditorJS.Stream(refusedIn(), "LEDGER"); err == nil {
t.Error("a consumer with no entry reads the restricted stream")
}
// The grant value changes and the accounts are composed again: the tool runner still hears the restricted stream.
bus.recompose(records([]string{ledger}))
if err := shop.Publish(ledger, []byte(`{}`)); err != nil {
t.Fatal(err)
}
if err := shop.Flush(); err != nil {
t.Fatal(err)
}
hears(ledger, "the restricted stream after its grant value changed")
}
@@ -627,6 +627,7 @@ func TestTheGrantsPartWhole(t *testing.T) {
// And what the part composes holds on a server that loads it (one integration test per module, the test budget).
t.Run("the composed grants hold on a server that loads them", theBusNodeFollowsTheSealedListUnderTheComposedGrants)
t.Run("the tool runner hears every event on a server that loads its grants", theToolRunnerHearsEveryEventOnTheBus)
}
// lockedLog is the part's log, read by the test while the part writes it.
@@ -11,6 +11,7 @@ import (
"sync"
"time"
"git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshgrants"
"git.novox.be/novox/mesh-sdk/go/seatlist"
"git.novox.be/novox/mesh-sdk/go/views"
@@ -257,6 +258,23 @@ func toolsOf(p *grants.Part) []stdio.Tool {
"composed yet by this version of the part, with its step, the acting part, its version, since when, its bound (10 minutes) and " +
"clock-from (the start of this version's first pass), and which have passed their bound. Read-only.",
Run: func(map[string]any) (any, error) { return p.WaitsOf() }},
{Name: grants.ToolCallers, Description: "The callers each verb and each accepted kind of the seat definitions admits (design 47 G §83): " +
"the list the definition names, or the default of the verb's class. Name a seat, or a seat and a verb, or neither for every one. Read-only.",
Input: schema(map[string]string{"seat": "a seat's name (default: every seat)", "verb": "a verb or accepted kind (default: every one)"}),
Run: func(a map[string]any) (any, error) {
return p.Callers(grants.Answer(a, "seat"), grants.Answer(a, "verb"))
}},
{Name: grants.ToolWhyRefused, Description: "Why an account's call of a seat's verb is refused (design 47 G §83): the call subject the account " +
"lacks a grant for (the bus's own answer, nats_user_can) and the definition's callers that leave it out. Read-only.",
Input: schema(map[string]string{"account": "the bus user, e.g. g14.node-tools", "verb": "the verb as <seat>.<verb>", "node": "the node, for a node seat"}, "account", "verb"),
Run: func(a map[string]any) (any, error) {
return p.WhyRefused(grants.Answer(a, "account"), grants.Answer(a, "verb"), grants.Answer(a, "node"))
}},
{Name: meshgrants.Seat + "." + meshgrants.VerbGrantsFor,
Description: "The grants a candidate would compose, per module (design 47 G §441): the grants it would add and remove against the branch's newest.",
Run: func(a map[string]any) (any, error) {
return p.GrantsFor(meshgrants.GrantsForInput{Branch: grants.Answer(a, "branch"), Run: grants.Answer(a, "run"), Source: grants.Answer(a, "source")})
}},
{Name: grants.ToolRelease, Description: "Release one held granting wait by a person's act with why, recorded as a hand act " +
"(the seat mesh-hand-acts) before it is released (design 47 G §276). grants_waits lists them.",
Input: schema(map[string]string{"module": "the held entry's module", "why": "why, kept in the hand act"}, "module", "why"),
+58 -6
View File
@@ -29,6 +29,7 @@ import (
"git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient"
"git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshgrants"
"git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshnodeenroller"
"git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault"
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
@@ -91,8 +92,11 @@ type fakeBus struct {
maxValue int
// entries are the catalog half's entries by fingerprint, the manifest each holds as the indexer's manifests state has it
// (entry); entriesAsked says every one is asked, not answered: the half cannot be read yet.
entries map[string]string
entriesAsked bool
entries map[string]string
entriesAsked bool
// revision is the catalog half of mesh-catalog's `revision`: the branch's `newest` and a `candidate` (a source or a group), each
// module's manifest.
revision map[string]map[string]string
catalogWaited []string
}
@@ -363,6 +367,21 @@ func (b *fakeBus) Call(key string, body any) (json.RawMessage, error) {
}
}
return json.Marshal(map[string]any{"users": out})
case grants.CallRevision:
var in meshcatalog.RevisionInput
raw, _ := json.Marshal(body)
_ = json.Unmarshal(raw, &in)
which := "newest"
if in.Group != "" || in.Source != 0 {
which = "candidate"
}
var out meshcatalog.RevisionAnswer
for module, manifest := range b.revision[which] {
var m map[string]any
_ = json.Unmarshal([]byte(manifest), &m)
out.Entries = append(out.Entries, meshcatalog.CatalogEntry{Module: module, Manifest: m})
}
return json.Marshal(out)
case grants.CallStreams:
if stream, _ := args["stream"].(string); stream != "" {
return json.Marshal(map[string]any{"detail": []map[string]any{{"name": stream, "consumer_detail": b.consumers[stream]}}})
@@ -602,10 +621,9 @@ func TestGrants(t *testing.T) {
if err != nil {
t.Fatalf("mesh-sdk cannot decode the manifest: %v", err)
}
// The part claims no seat: the seat mesh-grants served the lease write alone, which went with the controller
// (novox/hq ADR 0590 §3, issue 1126).
if m.Module != "mesh-grants" || len(m.Claims) != 0 {
t.Errorf("module %q claims %v: the part claims no seat", m.Module, m.Claims)
// The part claims the seat mesh-grants, which serves `grants-for` (design 47 G §401, §441).
if m.Module != "mesh-grants" || len(m.Claims) != 1 || m.Claims[0].Name != "mesh-grants" || !slices.Equal(m.Claims[0].Serves, []string{"grants-for"}) {
t.Errorf("module %q claims %v: the part claims the seat mesh-grants alone, serving grants-for", m.Module, m.Claims)
}
for _, row := range []struct {
what string
@@ -5095,3 +5113,37 @@ func TestAnOldStateSectionDeclaresItsCap(t *testing.T) {
}
}
}
// grants-for answers what the composed accounts gain and lose with a candidate's entries in place of the branch's newest (design
// 47 G §441, A-205): a tool the candidate adds is an added grant naming its verb, and an input that names no source nor run is
// refused.
func TestGrantsFor(t *testing.T) {
b := fixture()
candidateX := strings.Replace(strings.Replace(xManifest, `,"logs":[{"name":"history","max-age-days":30}]`, "", 1), `"state"`, `"emits":["hello"],"state"`, 1)
b.revision = map[string]map[string]string{"newest": {"mod-x": xManifest}, "candidate": {"mod-x": candidateX}}
p := partOn(b)
got, err := p.GrantsFor(meshgrants.GrantsForInput{Branch: "main", Run: "r1"})
if err != nil || got.Error != "" || len(got.Modules) != 1 || got.Modules[0].Module != "mod-x" {
t.Fatalf("grants-for: %+v, %v", got, err)
}
var verbs []string
for _, g := range got.Modules[0].Added {
verbs = append(verbs, g.Verb+" "+g.Subject)
}
if !slices.ContainsFunc(got.Modules[0].Added, func(g meshgrants.Grant) bool {
return g.Verb == "hello" && strings.HasPrefix(g.Subject, "mesh.mod.mod-x.")
}) {
t.Errorf("the event the candidate adds is not an added grant: %v / %+v", verbs, got.Modules[0])
}
if len(got.Modules[0].Removed) == 0 {
t.Error("the log the candidate drops is no removed grant")
}
b.revision["candidate"] = b.revision["newest"]
same, _ := p.GrantsFor(meshgrants.GrantsForInput{Branch: "main", Source: "7"})
if len(same.Modules) != 0 {
t.Errorf("a candidate equal to the newest changes grants: %+v", same.Modules)
}
if _, err := p.GrantsFor(meshgrants.GrantsForInput{Branch: "main"}); err == nil {
t.Error("grants-for with neither a source nor a run answered")
}
}
@@ -20,6 +20,7 @@ import (
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/buildstep"
"git.novox.be/novox/mesh-sdk/go/announce"
sdkbc "git.novox.be/novox/mesh-sdk/go/buscompose"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/seatdef"
"github.com/nats-io/nats.go/jetstream"
@@ -60,6 +61,8 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
t.Run("a module's ledger is written by its ledger account alone", aLedgerIsWrittenByItsLedgerAccountAlone)
t.Run("the bus module's process alone keeps every module's bus state and serves the broker's verbs", theBusModulesProcessKeepsBusState)
t.Run("a process serving a verb is granted its calls and chunk subjects, and the reverse goes to an account naming it", chunkSubjectsFollowTheAnswerer)
t.Run("the tool runner's runtime hears every event subject and nothing wider", theToolRunnerHearsEveryEvent)
t.Run("a per-machine state reaches its machine's keys below it and no other machine's", aPerMachineStateReachesItsMachinesKeys)
// ADR 0567 §3, §9: a node reads every key of the seat's assignments and its candidates (it composes with the whole
// proposal laid over), and none of its open proposals; it writes neither.
reads := strings.Join(NodeComposeReads("laptop"), " ")
@@ -1447,3 +1450,111 @@ func chunkSubjectsFollowTheAnswerer(t *testing.T) {
t.Errorf("the keeper's restore grant reaches a stream that is not restricted")
}
}
// EE-D2 (design 34 every-event §6, §8, §25, §27; ADR 0670 §1, §3, ADR 0671 §1): the runtime carrying the tool runner
// subscribes the three event subjects of mesh-sdk's EveryEvent, beside a module with a restricted stream, and nothing
// wider; it publishes no other module's or seat's event and derives no durable consumer or acknowledgement on that
// stream; a runtime carrying no tool runner gets none of the three.
func theToolRunnerHearsEveryEvent(t *testing.T) {
shop := func() Declared {
return Declared{Module: "shop", Serves: []string{"sell"}, Emits: []string{"sold"},
Streams: []ModuleStream{{Module: "shop", Name: "LEDGER", Events: []string{"booked"}, Restricted: true}}}
}
r := Records{Nodes: []string{"a", "b"}, Assigned: map[string][]Declared{
"a": {{Module: "node-tools", ToolRunner: true}, shop()},
"b": {shop()}}}
users, err := Users(r)
if err != nil {
t.Fatal(err)
}
every := sdkbc.EveryEvent()
if len(every) != 3 {
t.Fatalf("EveryEvent answers %v, want three subjects", every)
}
wider := []string{">", "mesh.>", "mesh.*.>", "mesh.seat.>", "mesh.mod.>", "mesh.stream.>"}
runners := 0
for _, u := range users {
p, err := PermissionsFor(u)
if err != nil {
t.Fatalf("%s: %v", u.Username(), err)
}
runs := false
for _, d := range u.Carries {
runs = runs || d.ToolRunner
}
for _, e := range every {
if slices.Contains(p.Subscribe, e) != runs {
t.Errorf("%s (tool runner %v) subscribes %s: %v", u.Username(), runs, e, !runs)
}
}
for _, s := range p.Subscribe {
if slices.Contains(wider, s) {
t.Errorf("%s subscribes %s, wider than every event", u.Username(), s)
}
}
if !runs {
continue
}
runners++
for _, s := range p.Subscribe {
if strings.HasPrefix(s, "$JS.") && strings.Contains(s, "LEDGER") {
t.Errorf("the tool runner's runtime hears %s on the restricted stream", s)
}
}
for _, s := range []string{"mesh.mod.other.event.x", "mesh.seat.mesh-catalog.event.manifest-accepted",
"mesh.stream.other.event.x"} {
if MayPublish(p, s) {
t.Errorf("the tool runner's runtime may publish another's event %s", s)
}
}
for _, s := range p.Publish {
if strings.Contains(s, "LEDGER") || strings.HasPrefix(s, "$JS.ACK.") {
t.Errorf("the tool runner's runtime may %s on the restricted stream", s)
}
}
}
if runners != 1 {
t.Errorf("%d runtimes carry the tool runner, want 1", runners)
}
}
// AM-D5 (design 46 approval mark §13): a per-machine state reaches its machine's key and the keys below it, `<node>.>`,
// and no other machine's, on the account and in the membership alike.
func aPerMachineStateReachesItsMachinesKeys(t *testing.T) {
runner := func() Declared {
return Declared{Module: "node-tools", ToolRunner: true,
State: []Bucket{{Module: "node-tools", Name: "approvals", PerMachine: true, KeyTTL: true, TTLSeconds: 172800}}}
}
r := Records{Nodes: []string{"a", "b"}, Assigned: map[string][]Declared{"a": {runner()}, "b": {runner()}}}
users, err := Users(r)
if err != nil {
t.Fatal(err)
}
const bucket = "node-tools_approvals"
found := false
for _, u := range users {
if u.Username() != "a.node-tools" {
continue
}
found = true
p, err := PermissionsFor(u)
if err != nil {
t.Fatal(err)
}
for key, reach := range map[string]bool{"a": true, "a.ask.1": true, "a.act.d": true, "b": false, "b.ask.1": false, "ab.ask.1": false} {
if MayPublish(p, "$KV."+bucket+"."+key) != reach {
t.Errorf("a's runner writes %s's key %s: %v", bucket, key, !reach)
}
if MayPublish(p, "$JS.API.DIRECT.GET.KV_"+bucket+".$KV."+bucket+"."+key) != reach {
t.Errorf("a's runner reads %s's key %s: %v", bucket, key, !reach)
}
}
}
if !found {
t.Fatal("no account a.node-tools composed")
}
// The membership names the machine's key, which reaches the keys below it in the same form (mesh-sdk membership).
if issued := stateIssuedFor(runner(), "a"); len(issued) != 1 || issued[0].Key != "a" || issued[0].Bucket != bucket {
t.Errorf("a's membership issues %+v", issued)
}
}
@@ -249,6 +249,11 @@ func boundOf(module string, bindings []membership.BusBinding) boundGrants {
case catalogue.StorageKeyValue:
if b.Bucket == base {
g.States = append(g.States, b.Local)
// Its object store beside it, `<state>-objects`, is its owner's alone (design 47 G §58, §91): the one bucket the
// binding names for this module's own state.
if b.Objects != "" && b.Objects == module+"_"+catalogue.StateObjectsName(b.Local) {
g.Objects = append(g.Objects, b.Objects)
}
}
case catalogue.StorageObjectStore:
if b.ObjectStore == base {
@@ -115,3 +115,6 @@ func callerNamed(user string, pub, sub []string) ([]string, []string) {
}
return unique(pub), unique(sub)
}
// SeatToolSubject is where a seat's verb is served: `mesh.seat.<seat>.tool.<verb>`, with the node last on a node seat.
func SeatToolSubject(s Seat, verb, node string) string { return seatToolSubject(s, verb, node) }
@@ -64,7 +64,6 @@ func MembershipFor(node string, d Declared, where Assignments) Membership {
m.Logs = append(logsIssuedFor(d), readLogsIssuedFor(withReadLogs(d, where.Logs))...)
m.Streams = streamsIssuedFor(d)
m.Hears = HeardIssuedFor(node, d)
m.Waits = waitsIssuedFor(d)
t := SeatTrafficOf(d.Module, d.Holds, d.Uses, d.Watches)
for _, s := range append(append([]Seat{}, d.Uses...), d.Watches...) {
if !s.Kinded {
@@ -29,23 +29,12 @@ func ModuleStreamSubject(module, event string) string {
// OnAModuleStream says a subject is one a module's own stream takes.
func OnAModuleStream(subject string) bool { return strings.HasPrefix(subject, moduleStreamPrefix) }
// WirePattern is a local event pattern as the bus spells it: `**` is `>`.
func WirePattern(local string) string {
parts := strings.Split(local, ".")
for i, p := range parts {
if p == "**" {
parts[i] = ">"
}
}
return strings.Join(parts, ".")
}
// A ModuleStream is one module's declared stream as the bus holds it.
type ModuleStream struct {
Module string
// Name is its name on the bus, `MAIL`.
Name string
// Events are the events it carries, as the bus spells them (WirePattern).
// Events are the events it carries, as the bus spells them (mesh-sdk buscompose.WirePattern).
Events []string
MaxAgeDays int
MaxMiB int
@@ -0,0 +1,69 @@
package buscompose
import "testing"
// Design 47 G §543, §544, §600: a build seat's ask names its asker, each queue has a named worker beside main's, an asker
// is granted the queues of its kinds under its own seat's name alone, and main's subject is the checker's and the builder's.
func TestNamedAsks(t *testing.T) {
build := Seat{Name: "node-build-go", Scope: "node", Accepts: []string{"delivery", "check", "recheck"}, PulledByKind: true,
AcceptCallers: map[string][]string{
"delivery": {"mesh-builds"},
"check": {"mesh-pipeline-runner", "mesh-catalog", "mesh-delivery-checker"},
"recheck": {"mesh-delivery-checker"},
}}
asker := func(module string, held ...string) Principal {
p := Principal{Kind: KindModule, Node: "one", Module: module, Uses: []Seat{build}}
for _, h := range held {
p.Holds = append(p.Holds, Seat{Name: h})
}
return p
}
const base = "mesh.seat.node-build-go.accept."
for _, c := range []struct {
who Principal
want map[string]bool
}{
{asker("runner-module", "mesh-pipeline-runner"), map[string]bool{
base + "check.mesh-pipeline-runner": true, base + "delivery.mesh-pipeline-runner": false,
base + "check.mesh-catalog": false, base + "check": false, base + "delivery": false}},
{asker("an-indexer", "mesh-catalog"), map[string]bool{
base + "check.mesh-catalog": true, base + "check.mesh-pipeline-runner": false, base + "recheck.mesh-catalog": false}},
{asker("the-builder", "mesh-builds"), map[string]bool{
base + "delivery.mesh-builds": true, base + "delivery": true, base + "check": true, base + "check.mesh-builds": false}},
{asker("the-checker", "mesh-delivery-checker"), map[string]bool{
base + "check.mesh-delivery-checker": true, base + "recheck.mesh-delivery-checker": true, base + "recheck": true,
base + "delivery.mesh-delivery-checker": false}},
{asker("a-stranger"), map[string]bool{base + "check": false, base + "delivery": false, base + "check.a-stranger": false}},
} {
perms, err := PermissionsFor(c.who)
if err != nil {
t.Fatal(err)
}
for subject, want := range c.want {
if got := MayPublish(perms, subject); got != want {
t.Errorf("%s may publish %s: %v, want %v", c.who.Module, subject, got, want)
}
}
}
// Each queue's workers: main's and the named one, filtered to subjects that overlap nothing.
held := Seat{Name: "node-build-go", Scope: "node", Accepts: []string{"delivery", "check"}, PulledByKind: true}
tr := SeatTrafficOf("go-module", []Seat{held}, nil, nil)
filters := map[string]string{}
for _, w := range tr.Workers {
filters[w.Consumer] = w.Filter
}
for consumer, filter := range map[string]string{
"SEAT_NODE_BUILD_GO_CHECK_worker": base + "check",
"SEAT_NODE_BUILD_GO_CHECK_named_worker": base + "check.*",
"SEAT_NODE_BUILD_GO_DELIVERY_worker": base + "delivery",
"SEAT_NODE_BUILD_GO_DELIVERY_named_worker": base + "delivery.*",
} {
if filters[consumer] != filter {
t.Errorf("worker %s filters %q, want %q", consumer, filters[consumer], filter)
}
}
_, consumers := SeatTrafficObjects([]Principal{{Kind: KindModule, Node: "one", Module: "go-module", Holds: []Seat{held}}})
if len(consumers) != 4 {
t.Errorf("the work queue's consumers: %d, want 4 (a main and a named worker for each of two queues)", len(consumers))
}
}
@@ -103,6 +103,10 @@ type Seat struct {
// PulledByKind says each accepted kind is a worker of its own on the seat's queue, filtered to that kind, which
// every holder pulls in the order Accepts lists them: the build seats' (novox/hq ADR 0525 §2, ADR 0536 §4).
PulledByKind bool
// AcceptCallers are, for a seat pulled by kind, the seats that may ask each of its queues, by queue: the `seat:<seat>`
// entries of the callers of the accepted kinds the definition puts on that queue (design 47 G §543, §600). The asker
// in a named ask's subject is the asking seat's name (A-163). Empty where the seat list is unread.
AcceptCallers map[string][]string
// HeldByTheEngine says every node's engine holds the seat on its own account (catalogue.Seat.HeldByTheEngine, novox/hq
// ADR 0563 §1): each of its events names the engine's machine as its last token (machineEvent).
HeldByTheEngine bool
@@ -1192,14 +1196,13 @@ func permissionsFor(p Principal) (Permissions, error) {
pub = append(pub, logGrants(d.Module, logNames(d.Logs))...)
pub = append(pub, readLogGrants(d.ReadLogs)...)
}
// **And the event families a session may wait on** (novox/hq ADR 0367 §2): the tool runner's module's own,
// named one by one in its manifest, as a plain subscription — no consumer is made for them, and no
// call or setting adds one.
waits, err := waitGrants(p.Carries)
if err != nil {
return Permissions{}, err
// **And every event subject** (design 34 every-event §6, §25; novox/hq ADR 0670 §1, ADR 0671 §1): the runtime
// carrying the tool runner hears every seat's, module's and module stream's events, a restricted stream
// included, as a plain subscription: no publish, no durable consumer, no acknowledgement and no deny is added
// for it, and a runtime carrying no tool runner gets none of them.
if slices.ContainsFunc(p.Carries, func(d Declared) bool { return d.ToolRunner }) {
sub = append(sub, sdkbc.EveryEvent()...)
}
sub = append(sub, waits...)
// **Never the traffic of a trusted holder** (novox/hq ADR 0259 §8): the machine's runtime runs as the
// operator's account, which every agent runs as, so a module saying warrants or speaking for a kind
// that proves its sender is never composed into it — refused here, naming it, whatever registration
@@ -20,7 +20,6 @@ type (
KindHeld = membership.KindHeld
StreamIssued = membership.StreamIssued
HeardIssued = membership.HeardIssued
WaitIssued = membership.WaitIssued
)
// AssignmentsStream holds every assignment's membership, the newest per subject.
@@ -35,6 +35,16 @@ const BuildAskMaxDeliver = 5
// KindedBenches are the seats that may be kinded (ADR 0234 §2): making another is a decision, recorded.
var KindedBenches = map[string]bool{"channel": true, "intake": true}
// NamedWorkerName is the worker of a pulled-by-kind seat's named asks, `accept.<queue>.<asker>`, beside the queue's own (WorkerName):
// two filters that overlap nothing, so the work queue takes both (design 47 G §544).
func NamedWorkerName(seat, queue string) string {
return "SEAT_" + upperSnake(seat) + "_" + upperSnake(queue) + "_named_worker"
}
// MainAskers are the seats that may publish a build seat's ask on main's subject `accept.<queue>`, which names no asker: the
// checker and the builder, until main's subject goes (design 47 G §543, §600, A-184).
var MainAskers = []string{"mesh-delivery-checker", "mesh-builds"}
// WorkerName is the worker a seat's holders pull from: one for the seat, or one per kind on a kinded bench.
func WorkerName(seat, kind string) string {
if kind == "" {
@@ -86,6 +96,9 @@ func SeatTrafficOf(module string, holds, uses, watches []Seat) SeatTraffic {
for _, a := range s.Accepts {
t.Workers = append(t.Workers, Worker{Stream: seatStreamName(s.Name), Consumer: WorkerName(s.Name, a),
Filter: "mesh.seat." + s.Name + ".accept." + a})
// Its named asks have a worker of their own (§544); a holder of G pulls it, one before G knows only main's.
t.Workers = append(t.Workers, Worker{Stream: seatStreamName(s.Name), Consumer: NamedWorkerName(s.Name, a),
Filter: "mesh.seat." + s.Name + ".accept." + a + ".*"})
}
} else if len(s.Accepts) > 0 {
stream := seatStreamName(s.Name)
@@ -121,7 +134,25 @@ func SeatTrafficOf(module string, holds, uses, watches []Seat) SeatTraffic {
if !s.isNewTraffic() {
continue
}
if s.PulledByKind {
// **An ask names its asker** (§543, §600): each asker is granted `accept.<queue>.<asker>` for the queues whose
// kinds it is a caller of, its name the seat it holds, and main's `accept.<queue>` is the checker's and the
// builder's alone.
for _, q := range s.Accepts {
for _, h := range holds {
if namesVerb(s.AcceptCallers[q], h.Name) {
t.Publish = append(t.Publish, seatSubject(s, "accept", q+"."+h.Name))
}
if namesVerb(MainAskers, h.Name) {
t.Publish = append(t.Publish, seatSubject(s, "accept", q))
}
}
}
}
for _, a := range s.Accepts {
if s.PulledByKind {
break
}
switch {
case namesVerb(s.ByCaller, a):
t.Publish = append(t.Publish, seatSubject(s, "accept", a+"."+module))
@@ -205,7 +236,7 @@ func (s Seat) isNewTraffic() bool {
// plainVerbs is a seat's accepts or emits with those the rules above compose taken out: a verb named by
// its caller and every verb of a kinded bench are composed by SeatTrafficOf and nowhere else.
func plainVerbs(s Seat, verbs []string) []string {
if s.Kinded {
if s.Kinded || s.PulledByKind {
return nil
}
var out []string
@@ -2,6 +2,7 @@ package buscompose
import (
"fmt"
sdkbc "git.novox.be/novox/mesh-sdk/go/buscompose"
"slices"
"strings"
"time"
@@ -36,8 +37,8 @@ type Bucket struct {
History int
// TTLSeconds is how long a value lives; zero is until replaced or deleted.
TTLSeconds int
// PerMachine says each machine's instance writes only the key named for its machine (novox/hq ADR
// 0260), and is granted that key and no other.
// PerMachine says each machine's instance writes only the key named for its machine and the keys below it (novox/hq
// ADR 0260; design 46 approval mark §13), and is granted those and no other machine's.
PerMachine bool
// KeyTTL says the bucket allows a time to live per key (novox/hq ADR 0433 §1): its owner writes each key with its
// own, and the server writes a delete marker, kept KeyTTLMarkerTTL, when it ends.
@@ -166,8 +167,11 @@ func stateGrants(a stateAccess) []string {
if machineKey == "" {
continue // a machine with no usable name reaches none of it
}
read(bucket, machineKey)
out = append(out, "$KV."+bucket+"."+machineKey)
// Its machine's key and the keys below it, no other machine's (design 46 approval mark §13).
for _, k := range sdkbc.PerMachineKeys(machineKey) {
read(bucket, k)
out = append(out, "$KV."+bucket+"."+k)
}
continue
}
read(bucket, "")
@@ -0,0 +1,38 @@
package buscompose
import (
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/membership"
)
// Design 47 G §58, §59, §91, §124: a state's `<state>-objects` store is its owner's alone, and a sent store is read, never
// written, by the modules it is for.
func TestStateObjectsAndSentStores(t *testing.T) {
owner, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "shop", Bound: []membership.BusBinding{
{Local: "orders", Storage: catalogue.StorageKeyValue, Template: catalogue.TemplateState, Bucket: "shop_orders", Objects: "shop_orders-objects"}}})
if err != nil {
t.Fatal(err)
}
other, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "bank"})
if err != nil {
t.Fatal(err)
}
for _, subject := range []string{"$O.shop_orders-objects.C.x", "$O.shop_orders-objects.M.x", "$JS.API.DIRECT.GET.OBJ_shop_orders-objects.x"} {
if !MayPublish(owner, subject) {
t.Errorf("the owner may not publish %s", subject)
}
if MayPublish(other, subject) {
t.Errorf("another module may publish %s", subject)
}
}
reader, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "bank", ReadsObjects: []string{"shop_sent-objects"}})
if err != nil {
t.Fatal(err)
}
if !MayPublish(reader, "$JS.API.DIRECT.GET.OBJ_shop_sent-objects.x") || MayPublish(reader, "$O.shop_sent-objects.C.x") ||
MayPublish(reader, "$JS.API.DIRECT.GET.OBJ_other_sent-objects.x") {
t.Error("a reader of a sent store reads that one and writes none")
}
}
File diff suppressed because one or more lines are too long
@@ -56,9 +56,6 @@ type Declared struct {
// CatchUps are the module's own tools that bring a kept data forward after a restore of it, as its manifest's kept data
// name them (design 47 G §406, §631, A-108): the node-engine on a node that places the module is granted to call each.
CatchUps []string
// Waits are the event families a session may wait on through the tool runner, the tool runner's module's
// alone (novox/hq ADR 0367 §2): its account subscribes them for a call, through no consumer.
Waits []Wait
// State is the state it keeps, each a bucket its instances write (novox/hq ADR 0201).
State []Bucket
// Logs are the logs it keeps, each a stream its instances append to (novox/hq ADR 0297).
@@ -592,14 +589,14 @@ func Composed(d Declared) bool { return d.Module != EngineModule }
func CarriedByRunner(d Declared) bool { return Composed(d) && d.RunsAs == "" && runnerServes(d) }
// runnerServes says the machine's tool runner has something of this module to serve (novox/hq ADR 0436 §1,
// ADR 0437 §4, issue 1329): tools or loads it answers, a seat it holds, event families a session waits on,
// ADR 0437 §4, issue 1329): tools or loads it answers, a seat it holds,
// a bundle the runtime loads (Loads), or a check its health asks. A module with none of these is a process of its own on its own credential, and
// carrying it would hand every session on the machine the runner's account with that module's state, seat
// traffic and streams. A module with no account of its own (NoAccount) is spoken for by the runner, so it
// stays carried, and so does the runtime module itself. Whether the module has its own broker secret is not
// the test: a module with tools and its own credential is still carried (ADR 0175).
func runnerServes(d Declared) bool {
return d.ToolRunner || d.NoAccount || d.Loads || len(d.Serves) > 0 || len(d.Holds) > 0 || len(d.Waits) > 0 || len(d.Checks) > 0
return d.ToolRunner || d.NoAccount || d.Loads || len(d.Serves) > 0 || len(d.Holds) > 0 || len(d.Checks) > 0
}
// declaredStreamsOf is the stream of every bucket, log and stream a module placed anywhere declares, once each, in name
@@ -1,94 +0,0 @@
package buscompose
import (
"fmt"
"strings"
)
// What a session may wait on through the mesh MCP server (novox/hq ADR 0367).
//
// **A grant apart from `consumes`, and the tool runner's alone.** The tool runner's module names in its
// manifest the event families a session may wait on, one by one; the runtime's account is granted a plain
// subscription on those subjects and nothing wider — no durable consumer, no queue of its own — and its
// membership names the same list, from which `mesh_wait` refuses any other. Both are read from this one
// composition, so the grant and what the tool runner offers cannot disagree, and nothing but the manifest
// adds a family (§6).
// Wait is one event family the tool runner's module declares: its name as the manifest gives it, and the
// emitter — a seat or a module — and the event, as the inventory resolved them.
type Wait struct {
// Name is the family as the manifest names it, `<emitter>.<event>`: what a session asks for.
Name string
Emitter string
// Seat says the emitter is a seat, whose events live under the seat's namespace (novox/hq ADR 0121).
Seat bool
// Event is the event, with any further token the subject carries: `decided.claude-code`, the asker of a
// seat that names it last (ADR 0259 §3).
Event string
// Last names what the seat's event subject ends in — "kind" for a kinded bench, "asker" for a seat naming
// its caller (ADR 0259 §3) — and is empty for an event that ends at its name. A family needing it is waited
// on with it given, and refused without.
Last string
}
// WaitSubject is where a family's events land. Each token is checked before it becomes a subject: a
// wildcard, a dot inside a name or an empty token would widen the grant.
func WaitSubject(w Wait) (string, error) {
if !safeSubject.MatchString(w.Emitter) {
return "", fmt.Errorf("%q cannot name an emitter of a waited event: it would widen the subject", w.Emitter)
}
for _, t := range strings.Split(w.Event, ".") {
if !safeSubject.MatchString(t) {
return "", fmt.Errorf("%q cannot name a waited event: each of its names is plain, never a wildcard "+
"(novox/hq ADR 0367 §2)", w.Event)
}
}
if w.Last != "" && !strings.Contains(w.Event, ".") {
return "", fmt.Errorf("%s names no %s: %s's events are said for each %s, so a family is %s.%s.<%s>; "+
"without it no event would ever be heard (novox/hq ADR 0367 §5)",
w.Name, w.Last, w.Emitter, w.Last, w.Emitter, w.Event, w.Last)
}
if w.Seat {
return "mesh.seat." + w.Emitter + ".event." + w.Event, nil
}
return "mesh.mod." + w.Emitter + ".event." + w.Event, nil
}
// waitGrants is the subscription the tool runner's account is granted for its module's families, and an
// error for a carried module other than the tool runner's that declares any (ADR 0367 §2).
func waitGrants(carries []Declared) ([]string, error) {
var sub []string
for _, d := range carries {
if len(d.Waits) == 0 {
continue
}
if !d.ToolRunner {
return nil, fmt.Errorf("%s declares what a session waits on; only the tool runner's module (its manifest says "+
"tool-runner) does (novox/hq ADR 0367 §2)", d.Module)
}
for _, w := range d.Waits {
s, err := WaitSubject(w)
if err != nil {
return nil, err
}
sub = append(sub, s)
}
}
return sub, nil
}
// waitsIssuedFor is the families a membership names: the tool runner's module's, and nobody else's.
func waitsIssuedFor(d Declared) []WaitIssued {
if !d.ToolRunner {
return nil
}
var out []WaitIssued
for _, w := range d.Waits {
s, err := WaitSubject(w)
if err != nil {
continue // refused where the account is composed, which names it
}
out = append(out, WaitIssued{Event: w.Name, Subject: s})
}
return out
}
@@ -0,0 +1,139 @@
package grants
import (
"encoding/json"
"errors"
"fmt"
"slices"
"sort"
"strings"
"git.novox.be/novox/mesh-sdk/go/seatdef"
"mesh-grants/internal/buscompose"
)
// The tools that say who may call and why a call is refused (design 47 G §83, GR2).
const (
ToolCallers = "grants_callers"
ToolWhyRefused = "grants_why_refused"
)
// CallerLine is one verb's, or one accepted kind's, callers as a seat definition gives them.
type CallerLine struct {
Seat string `json:"seat"`
// Verb is the verb's name, or the accepted kind's.
Verb string `json:"verb"`
// Kind is `verb` or `accepts`.
Kind string `json:"kind"`
Class string `json:"class,omitempty"`
// Callers are the entries the verb admits: the list the definition names, or its class's default.
Callers []string `json:"callers"`
// Named says the definition names the list; false is the class's default (§391).
Named bool `json:"named"`
}
// definitions are the seat definitions the part composes from, or why it has none.
func (p *Part) definitions() (map[string]seatdef.Definition, error) {
d, ok := p.SeatList.(SeatDefinitions)
if !ok {
return nil, errors.New("the seat list is not read here: the definitions' callers are unknown")
}
defs := d.Definitions()
if len(defs) == 0 {
return nil, errors.New("the seat list holds no definitions yet")
}
return defs, nil
}
// CallerLines are the callers each verb and each accepted kind admits, from the definitions, of the seat named (every seat
// when empty) and the verb named (every verb when empty).
func CallerLines(defs map[string]seatdef.Definition, seat, verb string) []CallerLine {
var out []CallerLine
for _, d := range defs {
if seat != "" && d.Seat != seat {
continue
}
for _, v := range d.Verbs {
if verb == "" || v.Name == verb {
out = append(out, CallerLine{Seat: d.Seat, Verb: v.Name, Kind: "verb", Class: v.Class,
Callers: v.EffectiveCallers(), Named: v.Callers != nil && !v.Callers.IsText()})
}
}
for _, a := range d.Accepts {
if verb == "" || a.Name == verb {
l := CallerLine{Seat: d.Seat, Verb: a.Name, Kind: "accepts"}
if a.Callers != nil && !a.Callers.IsText() {
l.Callers, l.Named = a.Callers.List, true
}
out = append(out, l)
}
}
}
sort.Slice(out, func(i, j int) bool {
a, b := out[i], out[j]
return a.Seat+"\x00"+a.Kind+"\x00"+a.Verb < b.Seat+"\x00"+b.Kind+"\x00"+b.Verb
})
return out
}
// Callers answers the tool grants_callers.
func (p *Part) Callers(seat, verb string) (any, error) {
defs, err := p.definitions()
if err != nil {
return nil, err
}
lines := CallerLines(defs, seat, verb)
if len(lines) == 0 {
return nil, fmt.Errorf("no seat definition names a verb or an accepted kind matching seat %q verb %q", seat, verb)
}
return map[string]any{"callers": lines, "count": len(lines)}, nil
}
// WhyRefused answers the tool grants_why_refused: the grant an account lacks to call address (`<seat>.<verb>`), and the
// definition's line that leaves it out. The bus answers whether the account may publish the verb's call subject
// (nats.nats_user_can, the account's grants as the server holds them); the definition's callers say who was meant to.
func (p *Part) WhyRefused(account, address, node string) (any, error) {
seat, verb, ok := strings.Cut(address, ".")
if account == "" || !ok || seat == "" || verb == "" {
return nil, errors.New("why_refused names an account and a verb as <seat>.<verb>")
}
defs, err := p.definitions()
if err != nil {
return nil, err
}
lines := CallerLines(defs, seat, verb)
if len(lines) == 0 {
return nil, fmt.Errorf("no seat definition names the verb %s", address)
}
def := defs[seat]
s := buscompose.Seat{Name: seat, Scope: def.Scope}
subject := buscompose.CalledSubject(buscompose.SeatToolSubject(s, verb, node), account)
if subject == "" {
return nil, fmt.Errorf("%s is not the name of a bus user", account)
}
out := map[string]any{"account": account, "verb": address, "subject": subject, "definition": lines}
raw, err := p.Call.Call(CallUserCan, map[string]any{"user": account, "action": "publish", "subject": subject})
if err != nil {
out["bus"] = "not read: " + err.Error()
return out, nil
}
var can struct {
Allowed *bool `json:"allowed"`
}
_ = json.Unmarshal(raw, &can)
out["bus"] = json.RawMessage(raw)
switch {
case can.Allowed == nil:
out["says"] = "the bus's answer names no verdict; read it beside the definition's line"
case *can.Allowed:
out["says"] = "not refused at the bus: the account holds the grant to publish " + subject
default:
callers := lines[0].Callers
out["says"] = fmt.Sprintf("the account lacks the grant to publish %s; the definition admits %s (%s), and the grants part grants a call only to a caller one of those entries names",
subject, strings.Join(callers, ", "), map[bool]string{true: "named by the definition", false: "the class's default"}[lines[0].Named])
out["lacks"] = subject
out["admitted_by"] = slices.Clone(callers)
}
return out, nil
}
@@ -0,0 +1,57 @@
package grants
import (
"encoding/json"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/seatdef"
)
type defsGate struct{ defs map[string]seatdef.Definition }
func (defsGate) MayAct(...string) bool { return true }
func (g defsGate) Definitions() map[string]seatdef.Definition { return g.defs }
type canCall struct {
allowed bool
asked map[string]any
}
func (c *canCall) Call(key string, body any) (json.RawMessage, error) {
c.asked = body.(map[string]any)
return json.Marshal(map[string]any{"allowed": c.allowed})
}
// grants_callers and grants_why_refused read the definitions' callers (design 47 G §83): a verb that names none takes its
// class's default, and a refused account is told the call subject it lacks and the callers the definition admits.
func TestCallersAndWhyRefused(t *testing.T) {
defs := map[string]seatdef.Definition{"mesh-wants": {Seat: "mesh-wants", Scope: "mesh", Verbs: []seatdef.Verb{
{Name: "upgrade", Class: seatdef.ClassAct},
{Name: "entry", Callers: &seatdef.Callers{List: []string{"seat:mesh-pipeline-runner", "operator"}}},
}}}
lines := CallerLines(defs, "mesh-wants", "")
if len(lines) != 2 || lines[0].Verb != "entry" || !lines[0].Named || lines[1].Named ||
strings.Join(lines[1].Callers, ",") != "operator,sessions" {
t.Fatalf("lines: %+v", lines)
}
can := &canCall{}
p := &Part{SeatList: defsGate{defs}, Call: can}
got, err := p.WhyRefused("one.shop", "mesh-wants.upgrade", "")
if err != nil {
t.Fatal(err)
}
out := got.(map[string]any)
if out["lacks"] != "mesh.seat.mesh-wants.call.upgrade.one~shop" || can.asked["subject"] != out["lacks"] ||
!strings.Contains(out["says"].(string), "operator, sessions") {
t.Errorf("a refused caller: %+v", out)
}
can.allowed = true
got, _ = p.WhyRefused("one.shop", "mesh-wants.upgrade", "")
if _, lacks := got.(map[string]any)["lacks"]; lacks {
t.Errorf("an allowed caller lacks a grant: %+v", got)
}
if _, err := p.WhyRefused("one.shop", "mesh-wants.nothing", ""); err == nil {
t.Error("a verb no definition names was explained")
}
}
@@ -0,0 +1,188 @@
package grants
import (
"encoding/json"
"errors"
"fmt"
"maps"
"slices"
"sort"
"strconv"
"strings"
"git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshgrants"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// **The seat mesh-grants' read `grants-for`** (design 47 G §441, §401, A-205): the grants a candidate would compose, per
// module, added and removed against the branch's newest. The candidate's catalog half is taken by mesh-catalog's `revision` (a
// run by its group `run-<id>`, a source revision by the branch and `source`), the branch's newest by `revision` with the branch
// alone; the answer is what the composed memberships' permissions gain and lose with the candidate's entries swapped in for the
// branch's newest.
// RunGroup is the group of a run's candidate in mesh-catalog's `revision` (A-205).
const RunGroup = "run-"
// CallRevision is the seat mesh-catalog's `revision`, as the part calls it.
const CallRevision = "seat:" + meshcatalog.Seat + "." + meshcatalog.VerbRevision
// revisionEntries reads a catalog graph revision's entries, page after page, as manifests by module. none says the branch has
// no revision yet.
func (p *Part) revisionEntries(in meshcatalog.RevisionInput) (manifests map[string]catalogue.Manifest, none bool, err error) {
manifests = map[string]catalogue.Manifest{}
for {
raw, err := p.Call.Call(CallRevision, in)
if err != nil {
return nil, false, fmt.Errorf("%s does not answer: %w", CallRevision, err)
}
var a meshcatalog.RevisionAnswer
if err := json.Unmarshal(raw, &a); err != nil {
return nil, false, fmt.Errorf("%s answered what is not a revision: %w", CallRevision, err)
}
if a.NoneYet {
return nil, true, nil
}
for _, e := range a.Entries {
body, err := json.Marshal(e.Manifest)
if err != nil || len(e.Manifest) == 0 {
continue
}
m, err := catalogue.Decode(body)
if err != nil {
return nil, false, fmt.Errorf("the manifest of %s in the revision does not decode: %w", e.Module, err)
}
manifests[e.Module] = m
}
if a.Next == "" || a.Next == in.After {
return manifests, false, nil
}
in.After = a.Next
}
}
// grantsOfEntries is, by module, the subjects the composed accounts of that module may publish or subscribe, composed from in with
// the entries' manifests in place of the trunk's and of what runs.
func grantsOfEntries(in Inputs, entries map[string]catalogue.Manifest) (map[string]map[string]bool, error) {
in.Manifests = maps.Clone(in.Manifests)
in.Running = maps.Clone(in.Running)
for module, m := range entries {
in.Manifests[module] = m
for key := range in.Running {
if strings.HasSuffix(key, "."+module) {
delete(in.Running, key)
}
}
}
recs, _ := ListRecords(in)
users, refused, err := ComposeEach(recs)
if err != nil {
return nil, err
}
if len(refused) > 0 {
user := sortedKeys(refused)[0]
return nil, fmt.Errorf("the grants of %s do not compose: %s", user, refused[user])
}
out := map[string]map[string]bool{}
for _, u := range users {
_, module, ok := strings.Cut(u.User, ".")
if _, named := entries[module]; !ok || !named {
continue
}
if out[module] == nil {
out[module] = map[string]bool{}
}
for _, s := range append(slices.Clone(u.Publish), u.Subscribe...) {
out[module][s] = true
}
}
return out, nil
}
// verbOf names the verb, event or tool a subject is for: `mesh.seat.<seat>.<kind>.<name>…` and `mesh.mod.<module>.<kind>.<name>…`.
func verbOf(subject string) string {
parts := strings.Split(subject, ".")
if len(parts) >= 5 && parts[0] == "mesh" && (parts[1] == "seat" || parts[1] == "mod") {
return parts[4]
}
return ""
}
func grantList(subjects []string) []meshgrants.Grant {
out := []meshgrants.Grant{}
for _, s := range subjects {
out = append(out, meshgrants.Grant{Subject: s, Verb: verbOf(s)})
}
return out
}
// GrantsFor answers the verb grants-for.
func (p *Part) GrantsFor(in meshgrants.GrantsForInput) (meshgrants.GrantsFor, error) {
if in.Branch == "" {
return meshgrants.GrantsFor{}, errors.New("grants-for names the candidate's watched branch")
}
candidate := meshcatalog.RevisionInput{Branch: in.Branch}
switch {
case in.Run != "":
candidate.Group = RunGroup + in.Run
case in.Source != "":
n, err := strconv.ParseInt(in.Source, 10, 64)
if err != nil {
return meshgrants.GrantsFor{}, fmt.Errorf("the source revision %q is not a number", in.Source)
}
candidate.Source = n
default:
return meshgrants.GrantsFor{}, errors.New("grants-for names a source revision or a run")
}
cand, none, err := p.revisionEntries(candidate)
if err != nil || none {
return meshgrants.GrantsFor{Error: orWhy(err, "the candidate's catalog half is not held: the branch has no revision of it")}, nil
}
newest, none, err := p.revisionEntries(meshcatalog.RevisionInput{Branch: in.Branch})
if err != nil || none {
return meshgrants.GrantsFor{Error: orWhy(err, "the branch's newest catalog half is not held")}, nil
}
base := map[string]catalogue.Manifest{}
for module := range cand {
if m, ok := newest[module]; ok {
base[module] = m
}
}
input := p.inputs()
was, err := grantsOfEntries(input, base)
if err != nil {
return meshgrants.GrantsFor{Error: "the branch's newest does not compose: " + err.Error()}, nil
}
will, err := grantsOfEntries(input, cand)
if err != nil {
return meshgrants.GrantsFor{Error: "the candidate does not compose: " + err.Error()}, nil
}
var out meshgrants.GrantsFor
for _, module := range sortedKeys(will) {
var added, removed []string
for s := range will[module] {
if !was[module][s] {
added = append(added, s)
}
}
for s := range was[module] {
if !will[module][s] {
removed = append(removed, s)
}
}
if len(added)+len(removed) == 0 {
continue
}
sort.Strings(added)
sort.Strings(removed)
out.Modules = append(out.Modules, meshgrants.GrantsForModulesItem{Module: module, Added: grantList(added), Removed: grantList(removed)})
}
return out, nil
}
func orWhy(err error, why string) string {
if err != nil {
return err.Error()
}
return why
}
@@ -190,6 +190,10 @@ type Inputs struct {
// EventSubjects are, by seat, the subjects of its events that carry parts, by the event's local name, as the definitions
// give them: where a node-scoped seat's event names its machine (buscompose.Seat.EventSubjects).
EventSubjects map[string]map[string]string
// AcceptCallers are, by pulled-by-kind seat and queue, the seats that may ask it (acceptCallersOf).
AcceptCallers map[string]map[string][]string
// VerbCallers are, by seat and verb, the callers each verb of the definitions admits (verbCallersOf).
VerbCallers map[string]map[string][]string
// Defined are the events a seat definition of the seat list defines, by local name, and mesh-sdk's finding events: the
// events a `*` emitter before them reaches each declaring module's own stream for (design 47 G §386, §474, §644).
Defined map[string]bool
@@ -370,6 +374,8 @@ func ReadInputs(r Reader, cache *Manifests) Inputs {
in.Kept = buscompose.KeptEventsOf(d.Definitions())
in.Chunked = buscompose.ChunkVerbsOf(d.Definitions())
in.EventSubjects = eventSubjectsOf(d.Definitions())
in.AcceptCallers = acceptCallersOf(d.Definitions())
in.VerbCallers = verbCallersOf(d.Definitions())
in.Defined = definedEventsOf(d.Definitions())
}
nodes := map[string]bool{}
@@ -135,3 +135,45 @@ func withEventSubjects(subjects map[string]map[string]string, d *buscompose.Decl
}
}
}
// acceptCallersOf is, by pulled-by-kind seat and then by queue, the seats that may ask the queue: the `seat:<seat>` entries
// of the callers of every accepted kind the definition puts on it (a kind's `stream` is its queue, else its name is). Never a
// name of the grants part's own: it reads the definitions' lists (design 47 G §543, §600, A-163).
func acceptCallersOf(defs map[string]seatdef.Definition) map[string]map[string][]string {
out := map[string]map[string][]string{}
for _, d := range defs {
if !d.PulledByKind {
continue
}
for _, a := range d.Accepts {
if a.Callers == nil || a.Callers.IsText() {
continue
}
queue := a.Name
if a.Stream != "" {
queue = a.Stream
}
for _, e := range a.Callers.List {
if seat, ok := strings.CutPrefix(e, seatdef.CallerSeatPrefix); ok && !slices.Contains(out[d.Seat][queue], seat) {
if out[d.Seat] == nil {
out[d.Seat] = map[string][]string{}
}
out[d.Seat][queue] = append(out[d.Seat][queue], seat)
}
}
}
}
return out
}
// withAcceptCallers gives each pulled-by-kind seat a declaration holds or uses the askers of its queues (Seat.AcceptCallers).
func withAcceptCallers(askers map[string]map[string][]string, d *buscompose.Declared) {
if len(askers) == 0 {
return
}
for _, list := range []*[]buscompose.Seat{&d.Holds, &d.Uses, &d.Watches} {
for i := range *list {
(*list)[i].AcceptCallers = askers[(*list)[i].Name]
}
}
}
@@ -0,0 +1,36 @@
package grants
import (
"slices"
"testing"
"git.novox.be/novox/mesh-sdk/go/seatdef"
)
// The askers of a build seat's queue are read from the definition's callers lists (design 47 G §543, §600, A-163): a kind's
// `stream` is its queue, a text callers names none, and only `seat:` entries are askers.
func TestAcceptCallersOf(t *testing.T) {
msg := func(name, stream string, callers ...string) seatdef.Message {
m := seatdef.Message{Name: name, Stream: stream}
if callers != nil {
m.Callers = &seatdef.Callers{List: callers}
}
return m
}
got := acceptCallersOf(map[string]seatdef.Definition{
"node-build-go": {Seat: "node-build-go", PulledByKind: true, Accepts: []seatdef.Message{
msg("check", "", "seat:mesh-catalog", "operator"),
msg("step", "check", "seat:mesh-pipeline-runner"),
msg("delivery", "", "seat:mesh-builds"),
{Name: "recheck", Callers: &seatdef.Callers{Text: "the checker"}},
}},
"other": {Seat: "other", Accepts: []seatdef.Message{msg("go", "", "seat:x")}},
})
if len(got) != 1 {
t.Fatalf("seats: %v, want node-build-go alone", got)
}
q := got["node-build-go"]
if !slices.Equal(q["check"], []string{"mesh-catalog", "mesh-pipeline-runner"}) || !slices.Equal(q["delivery"], []string{"mesh-builds"}) || len(q) != 2 {
t.Errorf("queues: %v", q)
}
}
+9 -24
View File
@@ -3,6 +3,7 @@ package grants
import (
"fmt"
"git.novox.be/novox/mesh-contracts/go/seats/meshbroker"
sdkbc "git.novox.be/novox/mesh-sdk/go/buscompose"
"maps"
"reflect"
"slices"
@@ -101,10 +102,14 @@ func records(in Inputs, union bool) (buscompose.Records, []Gap) {
d.Watches, d.Consumes, d.Hears, keptHeard = keptOutOfHearing(in.Kept, d.Watches, d.Consumes, d.Hears)
d.HearsAStream = d.HearsAStream || keptHeard
d.Reads, d.ReadsObjects, d.ReadsRefused = readsOfObjects(m.Module, node, d.Reads, d.ReadsRefused, declared, values)
// The sent stores of the modules it is for (design 47 G §59, §124), read from the catalog graph's manifests and the
// definitions' callers.
d.ReadsObjects = append(d.ReadsObjects, sentStoresRead(m, in.Manifests, in.VerbCallers)...)
// Its bindings as kept (bindings.go): the same source its membership's `bindings` is written from in this pass,
// so a grant and its binding are written together, never one before the other (issue 1966, the kind of issue 249).
d.Bindings = in.Bindings[m.Module]
withEventSubjects(in.EventSubjects, &d)
withAcceptCallers(in.AcceptCallers, &d)
return d
}
d := declare(m)
@@ -136,6 +141,7 @@ func records(in Inputs, union bool) (buscompose.Records, []Gap) {
out.Engine = &buscompose.Declared{Module: m.Module, Consumes: m.Consumes, Watches: watches, Invokes: m.Invokes,
State: bucketsFor(m, in.Nodes), Logs: logsOf(m), Streams: streamsOf(m)}
withEventSubjects(in.EventSubjects, out.Engine)
withAcceptCallers(in.AcceptCallers, out.Engine)
}
return out, gaps
}
@@ -146,7 +152,7 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio
fromModules, watches := consumedOf(m, seats, declarers)
d := buscompose.Declared{
Module: m.Module, ToolRunner: catalogue.IsToolRunner(m), Emits: m.EmitsAll(), Consumes: fromModules, Watches: watches, Serves: m.Tools,
Invokes: m.Invokes, Waits: waitsOf(m, seats), State: bucketsOf(m), Reads: m.Reads, Logs: logsOf(m),
Invokes: m.Invokes, State: bucketsOf(m), Reads: m.Reads, Logs: logsOf(m),
Streams: streamsOf(m), Queues: queueOwnersOf(m), Processes: processesOf(m), ServesOwn: ownServesOf(m), Checks: catalogue.HealthChecks(m),
RunsAs: m.RunsAs, Issued: issuedTools(m),
// The keys its served values fill, which every node may read where it runs (issue 1126).
@@ -306,27 +312,6 @@ func loadsABundle(m catalogue.Manifest) bool {
return false
}
func waitsOf(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaration) []buscompose.Wait {
var out []buscompose.Wait
for _, w := range m.Waits {
emitter, event, _ := strings.Cut(w, ".")
first, _, _ := strings.Cut(event, ".")
wait := buscompose.Wait{Name: w, Emitter: emitter, Event: event}
if s, isASeat := seats[emitter]; isASeat {
switch {
case s.Kinded && catalogue.SeatSays(s.Emits, first):
wait.Seat, wait.Last = true, "kind"
case s.NamedByCaller(first) && catalogue.SeatSays(s.Emits, first):
wait.Seat, wait.Last = true, "asker"
case catalogue.SeatSays(s.Emits, event):
wait.Seat = true
}
}
out = append(out, wait)
}
return out
}
func issuedTools(m catalogue.Manifest) []string {
var out []string
for _, o := range m.Provides {
@@ -430,7 +415,7 @@ func streamsOf(m catalogue.Manifest) []buscompose.ModuleStream {
for _, s := range m.Streams {
events := make([]string, 0, len(s.Events))
for _, e := range s.Events {
events = append(events, buscompose.WirePattern(e))
events = append(events, sdkbc.WirePattern(e))
}
out = append(out, buscompose.ModuleStream{Module: m.Module, Name: s.BusName(), Events: events,
MaxAgeDays: s.Days(), MaxMiB: s.Cap(), Restricted: s.Grant != nil,
@@ -512,7 +497,7 @@ func hearsOf(m catalogue.Manifest, declared map[string]catalogue.Manifest, node
for _, p := range parts {
h := buscompose.Heard{Stream: p.Stream.BusName(), Emitter: p.Emitter, Restricted: p.Stream.Grant != nil}
for _, pattern := range p.Patterns {
h.Filters = append(h.Filters, buscompose.ModuleStreamSubject(p.Emitter, buscompose.WirePattern(pattern)))
h.Filters = append(h.Filters, buscompose.ModuleStreamSubject(p.Emitter, sdkbc.WirePattern(pattern)))
}
out = append(out, h)
}
@@ -0,0 +1,86 @@
package grants
import (
"slices"
"strings"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/seatdef"
)
// **A module's sent store is read by the modules it is for** (design 47 G §59, §124): the grants part derives, from the
// catalog graph and the seat definitions, never from a manifest field, a standing read of `<module>_sent-objects` for the
// callers the module's seat definition names for the verbs it serves, and for the consumers whose `consumes` declare its
// events. The grant follows the catalog graph, so it is composed with each module's declaration at every pass.
// verbCallersOf is, by seat and verb, the callers each verb of the definitions admits (a verb naming none takes its class's
// default).
func verbCallersOf(defs map[string]seatdef.Definition) map[string]map[string][]string {
out := map[string]map[string][]string{}
for _, d := range defs {
for _, v := range d.Verbs {
if out[d.Seat] == nil {
out[d.Seat] = map[string][]string{}
}
out[d.Seat][v.Name] = v.EffectiveCallers()
}
}
return out
}
// admits says whether a verb's callers name the module: `invokers` (every module whose manifest invokes the verb), the module
// itself, or a seat it claims.
func admits(callers []string, m catalogue.Manifest) bool {
for _, c := range callers {
switch {
case c == seatdef.CallerInvokers, c == seatdef.CallerModulePrefix+m.Module:
return true
}
if seat, ok := strings.CutPrefix(c, seatdef.CallerSeatPrefix); ok && slices.ContainsFunc(m.Claims, func(x catalogue.Claim) bool { return x.Name == seat }) {
return true
}
}
return false
}
// sentStoresRead are the bus names of the sent stores the module m reads: of each module whose event it consumes (the module's
// own, or the seat a module claims), and of each module claiming a seat whose verb m invokes where the verb's callers admit m.
// Never m's own store, which its binding grants it.
func sentStoresRead(m catalogue.Manifest, all map[string]catalogue.Manifest, callers map[string]map[string][]string) []string {
claimants := func(name string) []string {
var out []string
if _, ok := all[name]; ok {
out = append(out, name)
}
for module, x := range all {
if slices.ContainsFunc(x.Claims, func(c catalogue.Claim) bool { return c.Name == name }) {
out = append(out, module)
}
}
return out
}
var stores []string
for _, c := range m.Consumes {
name, _, ok := strings.Cut(strings.TrimPrefix(c, "seat:"), ".")
if !ok || name == "*" {
continue
}
for _, module := range claimants(name) {
stores = append(stores, catalogue.SentObjectsName(module))
}
}
for _, in := range m.Invokes {
seat, verb, ok := catalogue.SeatInvoke(in)
if !ok || !admits(callers[seat][verb], m) {
continue
}
for module, x := range all {
if slices.ContainsFunc(x.Claims, func(c catalogue.Claim) bool { return c.Name == seat }) {
stores = append(stores, catalogue.SentObjectsName(module))
}
}
}
stores = slices.DeleteFunc(stores, func(s string) bool { return s == catalogue.SentObjectsName(m.Module) })
slices.Sort(stores)
return slices.Compact(stores)
}
@@ -0,0 +1,38 @@
package grants
import (
"slices"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/seatdef"
)
// The sent stores a module reads follow the catalog graph (design 47 G §59, §124): the consumers of a module's events and the
// callers a seat's verb admits, and no other module.
func TestSentStoresRead(t *testing.T) {
holder := catalogue.Manifest{Module: "shop", Claims: []catalogue.Claim{{Name: "orders-seat"}}}
all := map[string]catalogue.Manifest{"shop": holder, "bank": {Module: "bank"}, "plain": {Module: "plain"}}
callers := map[string]map[string][]string{"orders-seat": {
"place": {"seat:billing", "module:plain"}, "list": seatdef.DefaultCallers(seatdef.ClassRead), "wipe": seatdef.DefaultCallers(seatdef.ClassAct)}}
for _, c := range []struct {
name string
m catalogue.Manifest
want []string
}{
{"consumes a module's event", catalogue.Manifest{Module: "bank", Consumes: []string{"shop.sold"}}, []string{"shop_sent-objects"}},
{"consumes a seat's event", catalogue.Manifest{Module: "bank", Consumes: []string{"orders-seat.sold"}}, []string{"shop_sent-objects"}},
{"invokes a verb that names its seat", catalogue.Manifest{Module: "bank", Claims: []catalogue.Claim{{Name: "billing"}},
Invokes: []string{"seat:orders-seat.place"}}, []string{"shop_sent-objects"}},
{"invokes a verb that names it", catalogue.Manifest{Module: "plain", Invokes: []string{"seat:orders-seat.place"}}, []string{"shop_sent-objects"}},
{"invokes a read, its callers include invokers", catalogue.Manifest{Module: "bank", Invokes: []string{"seat:orders-seat.list"}}, []string{"shop_sent-objects"}},
{"invokes an act no one names it for", catalogue.Manifest{Module: "bank", Invokes: []string{"seat:orders-seat.wipe"}}, nil},
{"invokes a verb that names others", catalogue.Manifest{Module: "bank", Invokes: []string{"seat:orders-seat.place"}}, nil},
{"declares neither", catalogue.Manifest{Module: "bank"}, nil},
{"its own store is its binding's", holder, nil},
} {
if got := sentStoresRead(c.m, all, callers); !slices.Equal(got, c.want) {
t.Errorf("%s: %v, want %v", c.name, got, c.want)
}
}
}
+3 -3
View File
@@ -14,8 +14,8 @@ import (
"mesh-grants/internal/buscompose"
)
// The part's tools, as its manifest lists them. The part claims no seat: the seat mesh-grants served the lease write
// alone, which went with the controller (novox/hq ADR 0590 §3, issue 1126).
// The part's tools, as its manifest lists them. The part claims the seat mesh-grants, which serves `grants-for` (design 47 G
// §401, §441); its own tools are named below.
const (
ToolGrants = "grants_grants"
ToolMembership = "grants_membership"
@@ -40,7 +40,7 @@ const (
// Tools is every tool, in the manifest's order.
var Tools = []string{ToolGrants, ToolMembership, ToolUserList, ToolComposeNow, ToolAssert, ToolAccounts, ToolConsumerMatch,
ToolAcceptHashes, ToolFirstViews, ToolFirstViewAgain, ToolRunning, ToolWaits, ToolRelease, ToolSeatList, ToolSeatListAgain}
ToolAcceptHashes, ToolFirstViews, ToolFirstViewAgain, ToolRunning, ToolWaits, ToolRelease, ToolSeatList, ToolSeatListAgain, ToolCallers, ToolWhyRefused}
// WritesGrants is what grants says of the part's own grants: written once the list the bus loaded carries a build's
// accounts, and the one key the assigner reads (ADR 0571 clause 9, which changes ADR 0547 clause 5).
+12 -1
View File
@@ -2,6 +2,15 @@
"module": "mesh-grants",
"version": "0.1",
"slug": "grants",
"claims": [
{
"name": "mesh-grants",
"scope": "mesh",
"serves": [
"grants-for"
]
}
],
"reads": [
"mesh-config.resolved",
"node-engine.have",
@@ -73,7 +82,9 @@
"grants_waits",
"grants_release",
"grants_seat_list",
"grants_seat_list_again"
"grants_seat_list_again",
"grants_callers",
"grants_why_refused"
],
"state": [
{