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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
Vendored
+4
-1
@@ -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.
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user