L3 final: verb, reader-capability and bus-state edges; one unreadable manifest holds only its logins; the indexer holds mesh-catalog (ADR 0614, ADR 0604, issue 774) #149

Merged
mesh-tools merged 20 commits from feat/l3-final into main 2026-11-04 00:23:50 +00:00
72 changed files with 1464 additions and 236 deletions
@@ -28,6 +28,8 @@ import (
"git.novox.be/novox/mesh-sdk/go/process"
"git.novox.be/novox/mesh-sdk/go/provisions"
"git.novox.be/novox/mesh-delivery/sdk/graph"
"mesh-delivery-assigner/internal/assign"
)
@@ -62,6 +64,7 @@ var readBuckets = []string{
provisions.ProvisionsBucket,
delivery.SourceBucket,
delivery.MergesBucket,
graph.Bucket,
}
// readFamilies are the states kept one bucket per node the assigner reads, each named in its manifest's reads: every
@@ -148,6 +151,11 @@ func open(ctx context.Context, js process.BucketOpener, logf func(string, ...any
logf("the bucket %s cannot be opened (%v): no release's Approve answers a record ask until it is bound", name, err)
continue
}
if name == graph.Bucket && err != nil {
// The indexer's module graph (ADR 0614 §2, §3): without it no want waits for a verb it invokes until it is bound.
logf("the bucket %s cannot be opened (%v): no want waits for a verb it invokes until it is bound once the bus says it was made", name, err)
continue
}
if name == delivery.PartGrantsBucket && err != nil {
// The grants part's grants (ADR 0571 clause 9): without them no module counts as granted until it is bound.
logf("the bucket %s cannot be opened (%v): no grant is read until it is bound once the bus says it was made", name, err)
@@ -166,7 +174,7 @@ func open(ctx context.Context, js process.BucketOpener, logf func(string, ...any
func startsWithout(name string) bool {
switch name {
case delivery.SeatHoldersBucket, provisions.ProvisionsBucket, assign.PoliciesBucket, assign.FindingsBucket,
delivery.AssignerPutsBucket, delivery.SourceBucket, delivery.MergesBucket:
delivery.AssignerPutsBucket, delivery.SourceBucket, delivery.MergesBucket, graph.Bucket:
return true
}
return slices.Contains(seatBuckets, name) || name == delivery.PartGrantsBucket
@@ -251,6 +259,7 @@ var watched = []string{
delivery.SeatHoldersBucket,
delivery.SourceBucket,
delivery.MergesBucket,
graph.Bucket,
}
// follow watches every bucket a pass reads and makes a pass at start, once every watch handed what stands, and after
@@ -155,10 +155,14 @@ func (p *passer) wants(ctx context.Context) {
defer func() { p.sayNeither(neither, stale, left) }()
// Where grants are read from, once for the pass: the grants part's (issue 774, ADR 0571 clause 9).
grants := p.w.grantsBucket()
// The verb edges, once for the pass (ADR 0614 §2, §3).
moduleGraph := p.moduleGraph()
served := p.served(ctx, get, moduleGraph, nodes)
offering := p.offering(ctx, get, manifests, nodes) // bus state (ADR 0614 §5)
for name := range names {
fp := trunk[name].FP2
name := name
m := assign.Module{Name: name, FP: fp, Manifest: manifests[name], Chosen: chosenOf(chosen, name), Provisions: provisionsOf(name), Answering: answering(manifests, nodes), Offered: offered(manifests), Nodes: nodesOf[name], Now: time.Now(), Puts: puts[name],
m := assign.Module{Name: name, FP: fp, Manifest: manifests[name], Needs: trunk[name].Needs, Chosen: chosenOf(chosen, name), Provisions: provisionsOf(name), Answering: answering(manifests, nodes), Offered: offered(manifests), Nodes: nodesOf[name], Now: time.Now(), Puts: puts[name],
Self: func(node string) bool { return placed[node] },
Composed: func(node, fp string) (delivery.ComposedDigest, bool) {
return p.w.composed(node, name, fp) // a placed node's own: the digest its own have names (ADR 0571 §6)
@@ -192,6 +196,10 @@ func (p *passer) wants(ctx context.Context) {
p.clearHeld(name, fp)
}
}
if served != nil {
m.Invokes, m.Served = keptInvokes(moduleGraph, name), served
}
m.Offering = offering
last := map[string]string{}
for _, node := range m.Nodes {
last[node] = p.w.lastHealthy(node, name, fp)
@@ -17,6 +17,8 @@ import (
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-delivery/sdk/graph"
"mesh-delivery-assigner/internal/assign"
)
@@ -148,6 +150,23 @@ func TestAnAnswerCountsOnlyForTheAskingItAnswered(t *testing.T) {
t.Error("an answer to an ask taken away was taken")
}
t.Run("a put of the bus acts on the operator's warrant alone", putOfTheBus)
// ADR 0614 §4: the rules read what each node's engine states it reads from its record in `<node>.node`; a node with no
// record, or one that states nothing, is read as stating nothing (the rules then read the baseline).
t.Run("the rules read what each node's engine reads", func(t *testing.T) {
w := newWorld()
for node, reads := range map[string][]string{"ace": {"word:module", "word:tool-runner"}, "g14": nil} {
raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Record: &delivery.NodeRecord{Node: node, ManifestReads: reads}})
if err != nil {
t.Fatal(err)
}
key, _ := delivery.HaveNodeKey(node)
w.put(delivery.HaveBucket, key, raw)
}
n := w.nodes(nil, nil)
if !slices.Equal(n.Reads["ace"], []string{"word:module", "word:tool-runner"}) || len(n.Reads["g14"]) != 0 || len(n.Reads["novox"]) != 0 {
t.Errorf("the nodes' reads are %v", n.Reads)
}
})
}
// memEntry is a value a memKV holds, as a bucket's Get answers it.
@@ -388,6 +407,8 @@ func putOfTheBus(t *testing.T) {
func TestAModuleOnNeitherListIsRaisedOnceAndClearedOnce(t *testing.T) {
// A pair only one source names is a condition of the same kind (issue 1126): folded here to hold the count.
aPlacementAloneNamesWhereModulesRun(t)
// The verb edges a want waits on come from the graph and what runs (ADR 0614 §2, §3): folded here to hold the count.
aWantWaitsOnTheVerbEdgesTheGraphKeeps(t)
t.Run("gathers", testThePassGathersTheModulesOnNeitherList)
p := newPasser(buckets{}, time.Date(2026, 10, 25, 15, 0, 0, 0, time.UTC))
var said []asks.Condition
@@ -1004,3 +1025,61 @@ func TestAnApprovedPlacementCallsNoControllerVerb(t *testing.T) {
t.Fatalf("the old conditions cleared = %+v", conds)
}
}
// ADR 0614 §2, §3: of what a module invokes, the pass keeps as edges the verbs the graph does not leave out and another
// module serves; a verb is served once a running healthy build's manifest (the builder's build, the indexer's manifest)
// serves it, and a build whose manifest cannot be read counts what the graph says its module serves. No graph read: no wait.
func aWantWaitsOnTheVerbEdgesTheGraphKeeps(t *testing.T) {
p := newPasser(buckets{}, time.Now())
fp := func(c string) string { return delivery.SourcePrefix2 + strings.Repeat(c, 64) }
if p.served(context.Background(), nil, graph.Graph{}, assign.Nodes{}) != nil {
t.Error("with no graph read, a want waits on a verb")
}
for module, n := range map[string]graph.Node{
"caller": {FP: fp("c"), Repository: "novox/caller", Invokes: []string{"seat:x.v", "loop.l", "none.t", "caller.own"}, LeftOut: []string{"loop.l"},
Serves: []string{"caller.own"}},
"server": {FP: fp("e"), Repository: "novox/server", Serves: []string{"seat:x.v", "seat:x.w"}},
"loop": {FP: fp("1"), Repository: "novox/loop", Serves: []string{"loop.l"}},
"late": {FP: fp("2"), Repository: "novox/late", Serves: []string{"late.t"}},
} {
raw, err := graph.Encode(n)
if err != nil {
t.Fatal(err)
}
p.w.put(graph.Bucket, module, raw)
}
g := p.moduleGraph()
if got := keptInvokes(g, "caller"); !slices.Equal(got, []string{"seat:x.v"}) || keptInvokes(g, "unknown") != nil {
t.Errorf("the kept verb edges of caller = %v", got)
}
digest := strings.Repeat("d", 64)
man, _ := delivery.EncodeManifest(delivery.Manifest{Module: "server", JSON: `{"module":"server","claims":[{"name":"x","scope":"mesh","serves":["w"]}]}`})
get := func(_ context.Context, key string) ([]byte, error) {
if key == digest {
return man, nil
}
return nil, errNotThere
}
build, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, Manifest: digest})
key, _ := delivery.BuilderBuildKey("server", fp("f"))
p.w.put(delivery.BuilderBuildsBucket, key, build)
running := delivery.Have{Phase: delivery.PhaseRunning, Health: delivery.HealthOK}
at := func(f string) delivery.Have { h := running; h.FP = f; return h }
served := p.served(context.Background(), get, g, assign.Nodes{Haves: map[string]map[string]delivery.Have{
"g14": {"server": at(fp("f")), "late": at(fp("2"))}}})
for v, want := range map[string]bool{"seat:x.w": true, "seat:x.v": false, "late.t": true} {
if served(v) != want {
t.Errorf("served(%s) = %v, want %v: the running server's manifest serves w alone, late's is not read", v, served(v), want)
}
}
// ADR 0614 §5: bus-state counts as offered only from a healthy build on a node present; the bus module's build on an
// offline node offers nothing, and one whose manifest is not read counts its trunk manifest's offer.
bus := map[string]catalogue.Manifest{"bus": {Module: "bus", Provides: []catalogue.Offer{{Name: graph.BusState, Scope: catalogue.ScopeMesh}}}}
for _, online := range []bool{false, true} {
offering := p.offering(context.Background(), get, bus, assign.Nodes{Online: map[string]bool{"n2": online},
Haves: map[string]map[string]delivery.Have{"n2": {"bus": at(fp("2"))}}})
if offering(graph.BusState) != online {
t.Errorf("with the bus module's node online %v, bus-state offered %v", online, offering(graph.BusState))
}
}
}
@@ -0,0 +1,128 @@
package main
import (
"context"
"slices"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-delivery/sdk/graph"
"mesh-delivery-assigner/internal/assign"
)
// The verb edges a want waits on (novox/hq ADR 0614 §2, §3): the module graph the indexer writes names what each module
// invokes and serves and which of its verb edges it leaves out on a cycle (one place decides the cycle); a want waits for
// each kept edge until a build serving its verb runs healthy, read from the haves, the builder's builds and the indexer's
// manifests, as a provider is counted (ADR 0603 §5).
// moduleGraph is the module graph as the indexer last wrote it; a key that does not decode is left out, so its module
// waits on no verb.
func (p *passer) moduleGraph() graph.Graph {
g := graph.Graph{}
for key, raw := range p.w.read(graph.Bucket) {
module, err := graph.ParseKey(key)
if err != nil {
continue
}
if n, err := graph.Decode(raw); err == nil {
g[module] = n
}
}
return g
}
// keptInvokes are the verbs of module the graph keeps as edges: each it invokes and does not leave out, that a module
// other than itself serves. A verb no module of the graph serves is no edge, so it holds nothing; nil when the graph holds
// no node of module.
func keptInvokes(g graph.Graph, module string) []string {
n, ok := g[module]
if !ok {
return nil
}
var out []string
for _, v := range n.Invokes {
if slices.Contains(n.LeftOut, v) {
continue
}
if slices.ContainsFunc(g.Servers(v), func(s string) bool { return s != module }) {
out = append(out, v)
}
}
return out
}
// served is, for this pass, whether a build serving a verb runs healthy on some node (graph.Served): every running healthy
// have's build (the builder's builds key of its module and fingerprint) names its manifest, and the verbs that manifest
// serves are served. A healthy build whose manifest cannot be read counts what the graph says its module serves, said in
// the log, so a build read late never holds its callers for good; nil when the graph holds nothing, and no want waits on
// a verb.
func (p *passer) served(ctx context.Context, get getter, g graph.Graph, n assign.Nodes) func(string) bool {
if len(g) == 0 {
return nil
}
served, unread := graph.Served(n.Haves, func(module, fp string) ([]string, bool) { return p.servesOf(ctx, get, module, fp) },
func(module string) []string { return g[module].Serves })
if len(unread) > 0 {
p.logf("the manifest of the running build of %v cannot be read, so what the module graph says they serve is counted as served", unread)
}
return served
}
// offering is, for this pass, whether a build whose manifest offers a provision runs healthy on a node present (ADR 0603
// §5, ADR 0614 §5): every running healthy have on an online node, its build's manifest read as servesOf reads it. A
// healthy build whose manifest cannot be read counts what its module's trunk manifest offers, so a build read late never
// holds a consumer for good.
func (p *passer) offering(ctx context.Context, get getter, trunk map[string]catalogue.Manifest, n assign.Nodes) func(string) bool {
offered := map[string]bool{}
for node, haves := range n.Haves {
if !n.Online[node] {
continue
}
for module, h := range haves {
if h.Phase != delivery.PhaseRunning || h.Health != delivery.HealthOK {
continue
}
m, ok := p.manifestOf(ctx, get, module, h.FP)
if !ok {
m = trunk[module]
}
for _, o := range m.Offers() {
offered[o] = true
}
}
}
return func(provision string) bool { return offered[provision] }
}
// servesOf are the verbs the manifest of module's build at fp serves (graph.ServesOf), and whether it could be read.
func (p *passer) servesOf(ctx context.Context, get getter, module, fp string) ([]string, bool) {
m, ok := p.manifestOf(ctx, get, module, fp)
if !ok {
return nil, false
}
return graph.ServesOf(m), true
}
// manifestOf is the manifest of module's build at fp (the builder's build names its digest in the indexer's manifests),
// and whether it could be read.
func (p *passer) manifestOf(ctx context.Context, get getter, module, fp string) (catalogue.Manifest, bool) {
key, err := delivery.BuilderBuildKey(module, fp)
if err != nil {
return catalogue.Manifest{}, false
}
raw := p.w.value(delivery.BuilderBuildsBucket, key)
if raw == nil {
return catalogue.Manifest{}, false
}
b, err := delivery.DecodeBuilderBuild(raw)
if err != nil || b.Manifest == "" {
return catalogue.Manifest{}, false
}
got := p.w.manifest(ctx, get, b.Manifest)
if got.err != "" {
return catalogue.Manifest{}, false
}
return got.manifest, true
}
@@ -338,7 +338,8 @@ func (w *world) wantsOf() map[string]map[string]delivery.Want {
// nodes is what the six rules read of the nodes: presence, haves, and every provision's providers with their nodes.
func (w *world) nodes(manifests map[string]catalogue.Manifest, assigned map[string][]string) assign.Nodes {
n := assign.Nodes{Online: map[string]bool{}, Haves: map[string]map[string]delivery.Have{}, Providers: map[string]map[string][]string{}}
n := assign.Nodes{Online: map[string]bool{}, Haves: map[string]map[string]delivery.Have{}, Providers: map[string]map[string][]string{},
Reads: map[string][]string{}}
for key, value := range w.read(delivery.PresenceBucket) {
node, _, _, err := delivery.ParsePresenceKey(key)
if err != nil || node == "" {
@@ -350,7 +351,14 @@ func (w *world) nodes(manifests map[string]catalogue.Manifest, assigned map[stri
}
for key, value := range w.read(delivery.HaveBucket) {
node, module, isNode, err := delivery.ParseHaveKey(key)
if err != nil || isNode {
if err != nil {
continue
}
if isNode {
// What the node's engine reads (ADR 0614 §4); a record that states none is read as the baseline.
if h, err := delivery.DecodeHaveNode(value); err == nil && h.Record != nil {
n.Reads[node] = h.Record.ManifestReads
}
continue
}
if h, err := delivery.DecodeHave(value); err == nil {
+5 -1
View File
@@ -3,7 +3,7 @@ module mesh-delivery-assigner
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0
)
@@ -20,3 +20,7 @@ require (
golang.org/x/sys v0.48.0 // indirect
golang.org/x/time v0.15.0 // indirect
)
require git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000
replace git.novox.be/novox/mesh-delivery/sdk => ../../sdk
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 h1:RtAFbUJRP1qO9AIMM2ue+cXNzqj7WZ7IOlSqNvttwuU=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 h1:XEbIVImp6yGjA91rp0iZBoDdL5KfamjOuDLwWvqh0hc=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
@@ -0,0 +1,38 @@
package assign
import (
"slices"
"git.novox.be/novox/mesh-delivery/sdk/graph"
)
// verbWaiting says why the module's want on node waits for a verb it invokes (novox/hq ADR 0614 §2): the first seat verb or
// module tool of Invokes (the module graph's verb edges, those it leaves out on a cycle not among them, §3) that no
// running healthy build serves. Nil Served (nothing read of what runs) waits for none, and a verb a running build serves
// already adds no wait. It holds under the provisions' rule (RuleProvisions): no reader knows a rule number of its own
// yet, so the words name the edge (graph.VerbWaitWords, which the delivery view reads).
func (m Module) verbWaiting(node string) (string, bool) {
if m.Served == nil {
return "", false
}
for _, v := range slices.Sorted(slices.Values(m.Invokes)) {
if !m.Served(v) {
return graph.VerbWaitWords(v, m.Name, node), true
}
}
return "", false
}
// busStateWaiting says why the module's want on node waits for bus state (ADR 0614 §5): its manifest asks bus state (a
// bus-state entry, as the module graph reads it) and no build whose manifest offers bus-state runs healthy on a node
// present. The provision bus-state is answered with no credential of a consumer's own, so ADR 0603 §5 counts the
// provider's own have and no provisioner key is waited for, handed or not. Nil Offering (nothing read of what runs) waits
// for none, and neither does a module that offers bus-state itself. It holds under the provisions' rule, its words naming bus state (graph.BusStateWaitWords).
func (m Module) busStateWaiting(node string) (string, bool) {
if len(m.Manifest.BusState) == 0 || m.Offering == nil || m.Offering(graph.BusState) ||
slices.Contains(m.Manifest.Offers(), graph.BusState) { // the bus module offering it waits on nobody for it
return "", false
}
return graph.BusStateWaitWords(m.Name, node), true
}
@@ -11,6 +11,8 @@ import (
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/provisions"
"git.novox.be/novox/mesh-delivery/sdk/graph"
)
// Module is what the six rules read of one module (ADR 0429 §6, ADR 0528 clause 3): its newest fingerprint F at the
@@ -40,6 +42,17 @@ type Module struct {
// manifest the trunk holds and the modules the mesh provides itself, placed or not. Nil: not known, and no requirement
// is waited on for want of an offer.
Offered func(provision string) bool
// Invokes are the seat verbs and module tools the module graph says the module invokes and keeps as edges to the
// modules serving them, those it leaves out on a cycle not among them (ADR 0614 §2, §3): the want waits until each is
// served (Served). Nil: no graph node is read, and no want waits on a verb.
Invokes []string
// Served says whether a build serving a seat verb or module tool runs healthy, counted from its holder's have (ADR 0614
// §2, as ADR 0603 §5 counts a provider's). Nil: nothing is read of what runs, and no want waits on a verb.
Served func(invoke string) bool
// Offering says whether a build whose manifest offers a provision runs healthy on a node present, counted from the
// provider's own have (ADR 0603 §5): what a want asking bus state waits for (ADR 0614 §5). Nil: nothing is read of what
// runs, and no want waits on bus state.
Offering func(provision string) bool
// Chosen is a person's choice of the module's upgrade policy (the assigner's `policies`, ADR 0565 §7); nil: none.
Chosen *catalogue.PersonPolicy
Nodes []string
@@ -64,6 +77,9 @@ type Module struct {
Now time.Time
// Puts are the puts of the module, by node (ADR 0546): while one owns a node's want (PutOwns), the rules pass over it.
Puts map[string]delivery.Put
// Needs are the reader capabilities the manifest at FP needs (the indexer's modules value, catalogue.ManifestNeeds, ADR
// 0614 §4): a node's want moves only while that node's engine states each of them. Nil: none.
Needs []string
// Self says whether node composes the module itself: the catalogue's placement names the node (ADR 0547 §2). It is the one
// source of a want's Self: every want the rules write or keep is derived from it (selfDerived), so a want written Self while
// the node was placed does not outlive the placement (issue 1126). Nil: no node does.
@@ -85,6 +101,9 @@ type Nodes struct {
Haves map[string]map[string]delivery.Have
// Providers is, by provision, the modules that provide it, with the nodes each is assigned to.
Providers map[string]map[string][]string
// Reads is, by node, the reader capabilities its node-engine states in its node record (delivery.NodeRecord.ManifestReads,
// ADR 0614 §4). A node with none, or none read, is read as stating the baseline (catalogue.StatedManifestReads).
Reads map[string][]string
}
// healthy says whether a have runs fp healthy.
@@ -138,15 +157,36 @@ func holds(m Module, node string, n Nodes, ordering bool) (bool, int, string) {
return false, delivery.RuleGrant, fmt.Sprintf("the controller has not issued the credential of %s on %s for %s", m.Name, node,
short(m.FP))
}
if words, lacking := m.readerLacking(node, n); lacking {
return false, delivery.RuleComposed, words
}
if _, ok := m.composition(node, m.FP); !ok {
return false, delivery.RuleComposed, fmt.Sprintf("%s has named no composition of its own for %s at %s yet", node, m.Name, short(m.FP))
}
if words, waiting := m.provisionWaiting(node); waiting {
return false, delivery.RuleProvisions, words
}
if words, waiting := m.verbWaiting(node); waiting {
return false, delivery.RuleProvisions, words
}
if words, waiting := m.busStateWaiting(node); waiting {
return false, delivery.RuleProvisions, words
}
return true, 0, ""
}
// readerLacking says why the module's want on node is held for the node's engine (ADR 0614 §4): a reader capability its
// manifest needs that the engine does not state, read as the baseline when it states none. It is held under rule 7 (the
// composition), since no rule of its own is numbered in this release, with words naming the capability; it moves once the
// engine states it. The node-engine's own new build is held only by a word of its own manifest no engine states.
func (m Module) readerLacking(node string, n Nodes) (string, bool) {
missing := catalogue.MissingManifestReads(m.Needs, n.Reads[node])
if len(missing) == 0 {
return "", false
}
return graph.ReaderWaitWords(node, missing, m.Name, short(m.FP)), true // the words the delivery view names the edge by
}
// provisionWaiting says why the module's want on node waits for a provision (ADR 0570 §4): a provision it requires that has
// no key yet, or the first of its keys that is not provided. Nil Provisions (the seat's state not on the bus) waits for none.
func (m Module) provisionWaiting(node string) (string, bool) {
@@ -9,6 +9,8 @@ import (
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/provisions"
"git.novox.be/novox/mesh-delivery/sdk/graph"
)
func fpOf(c byte) string { return delivery.SourcePrefix2 + strings.Repeat(string(c), 64) }
@@ -104,6 +106,90 @@ func TestEachRuleHoldsTheWantBack(t *testing.T) {
t.Errorf("provided provisions held g14's want: %+v (%s)", out.Wants["g14"], out.Rules["g14"].Words)
}
}
// ADR 0614 §4: a want is held on a node whose engine does not state a reader capability the manifest needs, under rule 7
// with the capability as its words, and moves once the engine states it; an engine stating nothing reads the baseline,
// so a manifest of the baseline's words, the node-engine's own new build among them, is not held.
hostNeeds, err := catalogue.ManifestNeeds([]byte(`{"module":"mesh-host","version":"1","slug":"host","build":{"artifacts":[` +
`{"name":"host-arch","kind":"bundle","language":"go","system":"arch","from":"cmd/mesh-host"}]},"consumes":["restic.backed-up"],` +
`"state":[{"name":"health"}],"config":{"x":{"type":"number","minimum":1,"default":50,"runtime":true}},` +
`"resources":[{"id":"k","type":"file","path":"/var/lib/k","content":"${config:x}\n"}]}`))
if err != nil {
t.Fatal(err)
}
for _, c := range []struct {
why string
name string
needs []string
reads map[string][]string
held bool
}{
{"a word the baseline lacks, on engines stating nothing", "mail", []string{"word:module", "word:tool-runner"}, nil, true},
{"the same, once both engines state it", "mail", []string{"word:module", "word:tool-runner"},
map[string][]string{"ace": catalogue.ManifestReads(), "g14": catalogue.ManifestReads()}, false},
{"the baseline's words on engines stating nothing", "mail", []string{"word:claims.serves", "seat:mesh-catalog", "form:config"}, nil, false},
{"the node-engine's new build, whose contract has more words than its manifest uses", "mesh-host", hostNeeds, nil, false},
} {
m, n := ready()
m.Name, m.Needs, n.Reads = c.name, c.needs, c.reads
n.Haves["ace"][c.name], n.Haves["g14"][c.name] = n.Haves["ace"]["mail"], n.Haves["g14"]["mail"]
out := Wants(m, n, wantedOld(), nil)
if moved := out.Wants["g14"].FP == fNew; moved == c.held {
t.Errorf("%s: g14's want %+v, held by %+v", c.why, out.Wants["g14"], out.Rules["g14"])
}
if r := out.Rules["g14"]; c.held && (r.Rule != delivery.RuleComposed || !strings.Contains(r.Words, "word:tool-runner") ||
!strings.Contains(r.Words, "node-engine of g14")) {
t.Errorf("%s: held by %+v, want rule 7 naming the capability", c.why, r)
}
if w := out.Wants["g14"]; c.held && (w.Rule != delivery.RuleComposed || w.Newest != fNew) {
t.Errorf("%s: g14's want %+v, want it kept at what it runs and naming rule 7", c.why, w)
}
}
// ADR 0614 §2, §3: a want waits, under the provisions' rule, until each verb it invokes is served by a running build,
// and moves once one is; a verb a running build serves already adds no wait, and an edge the graph leaves out on a cycle
// is not among Invokes, so it holds nothing; nothing read of what runs waits for none.
for _, c := range []struct {
name string
invokes []string
served []string
nilRead bool
holds string
}{
{"a verb no running build serves", []string{"store.store_put", "seat:mesh-catalog.modules"}, []string{"store.store_put"}, false, "seat:mesh-catalog.modules"},
{"once a build serving it runs", []string{"seat:mesh-catalog.modules"}, []string{"seat:mesh-catalog.modules"}, false, ""},
{"an edge left out on a cycle", nil, nil, false, ""},
{"nothing read of what runs", []string{"seat:mesh-catalog.modules"}, nil, true, ""},
// ADR 0614 §5: a module asking bus state is held while no build offering bus-state runs, and moves once one does.
{"bus state no running build offers", nil, nil, false, graph.BusState},
{"bus state a running build offers", nil, []string{graph.BusState}, false, ""},
{"bus state its own module offers", nil, nil, false, ""},
} {
m, n := ready()
m.Invokes = c.invokes
if !c.nilRead {
m.Served = func(v string) bool { return slices.Contains(c.served, v) }
}
if strings.HasPrefix(c.name, "bus state") {
m.Manifest.BusState = []catalogue.BusStateEntry{{Name: "cache", Storage: "key-value"}}
m.Offering = func(p string) bool { return slices.Contains(c.served, p) }
if c.name == "bus state its own module offers" {
m.Manifest.Provides = []catalogue.Offer{{Name: graph.BusState, Scope: catalogue.ScopeMesh}}
}
}
words := graph.VerbWaitWords(c.holds, "mail", "g14")
if c.holds == graph.BusState {
words = graph.BusStateWaitWords("mail", "g14")
}
out := Wants(m, n, wantedOld(), nil)
if c.holds == "" {
if out.Wants["g14"].FP != fNew {
t.Errorf("%s: held %+v", c.name, out.Rules["g14"])
}
continue
}
if out.Wants["g14"].FP == fNew || out.Rules["g14"].Rule != delivery.RuleProvisions || out.Rules["g14"].Words != words {
t.Errorf("%s: want %+v, held by %+v", c.name, out.Wants["g14"], out.Rules["g14"])
}
}
// ADR 0603 §9: the newest source revision governs. The module now at fNew no longer requires the database an older revision
// asked for and requires oidc instead; the seat's keys follow the newest manifest, so the older edge is no wait: a provided
// oidc moves the want, an asked one holds it and names oidc and never database.
@@ -34,8 +34,9 @@ func TestManifest(t *testing.T) {
reads := slices.Clone(m.Reads)
slices.Sort(reads)
// ADR 0593 §8: it composes nothing, so it reads no candidate, held or resolved value, compose step or build log; it reads
// every node's published compositions to compare them, and the gate's source and merges, for the release that carries a build (ADR 0601 §1).
want := []string{"mesh-delivery-builder.builds", "mesh-delivery-gate.merges", "mesh-delivery-gate.source", "mesh-delivery-indexer.manifests", "mesh-delivery-indexer.modules",
// every node's published compositions to compare them, and the gate's source and merges, for the release that carries a build (ADR 0601 §1);
// and the indexer's module graph, whose verb edges a want waits on (ADR 0614 §2, §3).
want := []string{"mesh-delivery-builder.builds", "mesh-delivery-gate.merges", "mesh-delivery-gate.source", "mesh-delivery-indexer.graph", "mesh-delivery-indexer.manifests", "mesh-delivery-indexer.modules",
"mesh-delivery-teller.answers", "mesh-delivery-watchman.presence", "mesh-grants.grants", "mesh-host.composed",
"mesh-provisioner.provisions", "node-engine.have"}
if !reflect.DeepEqual(reads, want) {
@@ -27,6 +27,7 @@
"mesh-delivery-builder.builds",
"mesh-delivery-gate.merges",
"mesh-delivery-gate.source",
"mesh-delivery-indexer.graph",
"mesh-delivery-indexer.manifests",
"mesh-delivery-indexer.modules",
"mesh-delivery-teller.answers",
@@ -3,7 +3,7 @@ module git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8
github.com/nats-io/nats.go v1.54.0
)
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 h1:RtAFbUJRP1qO9AIMM2ue+cXNzqj7WZ7IOlSqNvttwuU=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 h1:XEbIVImp6yGjA91rp0iZBoDdL5KfamjOuDLwWvqh0hc=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
+1 -1
View File
@@ -3,7 +3,7 @@ module mesh-delivery-builder
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0
)
+2 -2
View File
@@ -1,7 +1,7 @@
git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-20261102193506-d221c633799a h1:oxgksCiq272/afXbUION/dxtAS5MPGnmyq6+AZQxmXg=
git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-20261102193506-d221c633799a/go.mod h1:kpsX4Pgss5EeuBvel1zWa2ogj7MkR3jZso6oa/J0YUQ=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 h1:RtAFbUJRP1qO9AIMM2ue+cXNzqj7WZ7IOlSqNvttwuU=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 h1:XEbIVImp6yGjA91rp0iZBoDdL5KfamjOuDLwWvqh0hc=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
@@ -17,6 +17,8 @@ import (
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/process"
"git.novox.be/novox/mesh-delivery/sdk/graph"
"mesh-delivery-checker/internal/check"
)
@@ -95,6 +97,58 @@ func (n nodeNames) Names(ctx context.Context) ([]string, error) {
return out, nil
}
// runningServes says what the running builds serve (novox/hq ADR 0614 §7), read when a check asks it: every module's have
// in the node-engine's have that runs healthy, the builder's build of its fingerprint, and the verbs its manifest serves
// (graph.Served). A build whose manifest cannot be read serves nothing here, said in the log.
type runningServes struct {
have *nodeNames
builds check.BuildStates
read check.ManifestReader
logf func(format string, args ...any)
}
func (r runningServes) Served(ctx context.Context) (func(string) bool, error) {
if r.have == nil || r.have.kv == nil || r.builds == nil || r.read == nil {
return nil, fmt.Errorf("the node-engine's have, the builder's builds or the indexer's manifests cannot be read")
}
lister, err := r.have.kv.ListKeys(ctx)
if err != nil {
return nil, err
}
haves := map[string]map[string]delivery.Have{}
for key := range lister.Keys() {
node, module, isNode, err := delivery.ParseHaveKey(key)
if err != nil || isNode {
continue
}
e, err := r.have.kv.Get(ctx, key)
if err != nil {
continue
}
if h, err := delivery.DecodeHave(e.Value()); err == nil {
if haves[node] == nil {
haves[node] = map[string]delivery.Have{}
}
haves[node][module] = h
}
}
served, unread := graph.Served(haves, func(module, fp string) ([]string, bool) {
b, found, err := r.builds.Build(ctx, module, fp)
if err != nil || !found || b.Manifest == "" {
return nil, false
}
m, err := r.read.Manifest(ctx, b.Manifest)
if err != nil {
return nil, false
}
return graph.ServesOf(m), true
}, nil)
if len(unread) > 0 && r.logf != nil {
r.logf("the manifest of the running build of %v cannot be read, so it serves nothing the check counts (ADR 0614 §7)", unread)
}
return served, nil
}
func newJudge(d judgeDeps) check.Judge {
var nodes interface {
Names(ctx context.Context) ([]string, error)
@@ -102,8 +156,12 @@ func newJudge(d judgeDeps) check.Judge {
if d.nodes != nil {
nodes = d.nodes
}
return &check.Examiner{Nodes: nodes, Compositions: d.compositions, Asks: d.asks, Toolchains: d.tools,
ex := &check.Examiner{Nodes: nodes, Compositions: d.compositions, Asks: d.asks, Toolchains: d.tools,
Kept: check.Kept{Store: d.kept}, Previews: d.previews, Modules: d.repos, Packages: d.repos, Published: d.published, Forge: d.forge, Runs: d.repos, Builds: d.builds, BuildEnds: d.ends, BuildWait: d.buildWait, Announce: d.announce, Trunks: d.trunks, Manifests: d.repos, Read: d.repos, Predefined: d.predefined, Logf: d.logf}
if d.nodes != nil && d.builds != nil && d.repos != nil {
ex.Serving = runningServes{have: d.nodes, builds: d.builds, read: d.repos, logf: d.logf}
}
return ex
}
// manifestKV reads a manifest by its digest.
+1 -1
View File
@@ -3,7 +3,7 @@ module mesh-delivery-checker
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0
)
+2 -2
View File
@@ -1,7 +1,7 @@
git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-20261102193506-d221c633799a h1:oxgksCiq272/afXbUION/dxtAS5MPGnmyq6+AZQxmXg=
git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-20261102193506-d221c633799a/go.mod h1:kpsX4Pgss5EeuBvel1zWa2ogj7MkR3jZso6oa/J0YUQ=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 h1:RtAFbUJRP1qO9AIMM2ue+cXNzqj7WZ7IOlSqNvttwuU=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 h1:XEbIVImp6yGjA91rp0iZBoDdL5KfamjOuDLwWvqh0hc=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
@@ -28,6 +28,9 @@ import (
// trunk's base; the base's own build is waited on.
//
// A module the trunk holds no record of yet is not built by the builder until it is merged, so it is not waited on either.
//
// A changed build module whose manifest invokes a seat verb or module tool no running build serves is one the check cannot
// run (ADR 0614 §7): the modules it builds are composed from their manifests, as above, and the words name the verb.
// buildsOfTheCheck are the builds the check waits on before it composes (module to fingerprint) and the words for the
// changed modules it composes without waiting on their build, one line each, sorted. A manifest that cannot be read leaves
@@ -88,6 +91,14 @@ func (st *state) buildsOfTheCheck(ctx context.Context) (map[string]string, []str
words = append(words, fmt.Sprintf("the change alters the build module %s: %s, which it builds, are built by the running %s, not the changed one, which the check cannot run, so the check does not wait on their builds; they are built again once the agreed toolchain moves (ADR 0603 §3, §10)",
bm, strings.Join(built, ", "), bm))
}
if unserved := st.unservedVerbs(ctx, changed[bm]); len(unserved) > 0 {
what := "nothing it builds is in the change"
if len(built) > 0 {
what = strings.Join(built, ", ") + ", which it builds, are composed from their manifests"
}
words = append(words, fmt.Sprintf("the changed build module %s invokes %s, which no running build serves, so the check cannot run it: %s (ADR 0614 §7)",
bm, strings.Join(unserved, ", "), what))
}
}
g := graph.OfManifests(changed)
for _, base := range slices.Sorted(maps.Keys(changed)) {
@@ -109,3 +120,23 @@ func buildsWith(m catalogue.Manifest, language string) bool {
return a.Kind == catalogue.ArtifactBundle && a.Language == language
})
}
// unservedVerbs are the seat verbs and module tools m invokes (graph.InvokesOf) that no running build serves, sorted; none
// when nothing reads what runs, or what runs cannot be read (said in the log: nothing is said that was not read).
func (st *state) unservedVerbs(ctx context.Context, m catalogue.Manifest) []string {
if st.j.Serving == nil {
return nil
}
served, err := st.j.Serving.Served(ctx)
if err != nil {
st.j.logf("%s: what the running builds serve cannot be read, so no build module is named for a verb it invokes: %v", st.c.Key, err)
return nil
}
var out []string
for _, v := range graph.InvokesOf(m) {
if !served(v) {
out = append(out, v)
}
}
return out
}
@@ -127,6 +127,13 @@ func (f *fakeEnds) Ended(_ context.Context, builds map[string]string) (map[strin
func (f *fakeEnds) Done(context.Context, map[string]string) bool { return false }
// fixedServing are the verbs the running builds serve.
type fixedServing []string
func (f fixedServing) Served(context.Context) (func(string) bool, error) {
return func(v string) bool { return slices.Contains(f, v) }, nil
}
// knownRuns are the modules the trunk holds a record of (the indexer's modules): any commit.
type knownRuns []string
@@ -154,10 +161,17 @@ func aChangeToABuildModuleOrBaseNamesTheModulesBuiltOnIt(t *testing.T) {
builds map[string]delivery.BuilderBuild // by module, at fp; nil leaves the check without the builds' ends
runs knownRuns // nil: the trunk holds every module
want []string
waited string // the modules waited on, sorted
failed string // the failure's test, when the check fails on a build
tests bool // the tests fail: the check fails before it would wait
waited string // the modules waited on, sorted
failed string // the failure's test, when the check fails on a build
tests bool // the tests fail: the check fails before it would wait
served []string // what the running builds serve (ADR 0614 §7); nil reads nothing of what runs
}{
{name: "the Go build module invokes a verb no running build serves",
manifest: map[string]catalogue.Manifest{"mesh-build-go": {Module: "mesh-build-go", Build: goBuild,
Invokes: []string{"seat:package-registry.publish", "seat:artifact-store.put"}}, "web": {Module: "web", Build: goBuild}},
builds: map[string]delivery.BuilderBuild{"mesh-build-go": done}, served: []string{"seat:artifact-store.put"},
want: []string{"the changed build module mesh-build-go invokes seat:package-registry.publish, which no running build serves, " +
"so the check cannot run it: web, which it builds, are composed from their manifests (ADR 0614 §7)"}, waited: "mesh-build-go"},
{name: "the Go build module changes",
manifest: map[string]catalogue.Manifest{"mesh-build-go": {Module: "mesh-build-go", Build: goBuild}, "web": {Module: "web", Build: goBuild},
"mail": {Module: "mail", Build: goBuild}, "docs": {Module: "docs"}},
@@ -211,6 +225,9 @@ func aChangeToABuildModuleOrBaseNamesTheModulesBuiltOnIt(t *testing.T) {
if row.runs != nil {
b.ex.Runs = row.runs
}
if row.served != nil {
b.ex.Serving = fixedServing(row.served)
}
read, fps := fixedManifests{}, map[string]delivery.CandidateModule{}
for module, m := range row.manifest {
d := dig(string(rune('a' + len(read))))
@@ -1029,32 +1029,53 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) {
// untouched is the judge's refusal of mail, which the change leaves as it is, and trunkMail its refusal on the trunk:
// a problem the trunk had is a warning, one the change causes fails (ADR 0579 §2)
untouched, trunkMail string
// cycle reads the manifests, so the change's module graph is worked out (ADR 0603 §2): the change's web awaits
// mail's site while mail awaits web's database; trunkCycle has the trunk's web await it too.
cycle, trunkCycle bool
// cycle reads the manifests, so the change's module graph is worked out (ADR 0603 §2): with "provisions" the change's
// web awaits mail's site while mail awaits web's database, and trunkCycle has the trunk's web await it too; with "bases"
// each is built on the other; with "verbs" each invokes the other's tool, and with "a verb and a provision" web invokes
// mail's tool while mail awaits web's database: both left out, so neither is refused (ADR 0614 §3, §6).
cycle string
trunkCycle bool
}{
{name: "accepted"},
{name: "an untouched manifest's problem the trunk had", untouched: "mail says no credential form", trunkMail: "mail says no credential form"},
{name: "a problem the change causes in an untouched manifest", untouched: "mail reads a state web no longer keeps",
fails: "refuses the manifest of mail: mail reads a state web no longer keeps"},
{name: "a cycle the change makes", cycle: true, fails: "the change's module graph has the cycle mail -> web -> mail"},
{name: "a cycle the trunk has", cycle: true, trunkCycle: true},
{name: "a cycle the change makes", cycle: "provisions", fails: "the change's module graph has the cycle mail -> web -> mail"},
{name: "a cycle the trunk has", cycle: "provisions", trunkCycle: true},
{name: "a cycle of bases", cycle: "bases", fails: "the change's module graph has the cycle mail -> web -> mail"},
{name: "a cycle of verbs", cycle: "verbs"},
{name: "a cycle of a verb and a provision", cycle: "a verb and a provision"},
{name: "refused", refused: "web requires \"amqp\"\nweb has no id", fails: "the indexer's judge at mesh-delivery's trunk " + strings.Repeat("d", 40) + ` refuses the manifest of web: web requires "amqp"; web has no id`},
} {
b := newBench(t)
b.ex.Manifests = fixedDigests{"mail": strings.Repeat("a", 64), "web": strings.Repeat("b", 64)}
b.ex.Trunks = fixedTrunks{heads: map[string]string{DeliveryRepository: trunk}}
b.ex.Predefined = keptList(`{"font-size": {"type": "number", "description": "the size"}}`)
if row.cycle {
if row.cycle != "" {
offer := func(name string) []catalogue.Offer {
return []catalogue.Offer{{Name: name, Scope: catalogue.ScopeMesh, AnsweredBy: catalogue.AnsweredByHelper}}
}
on := func(module string) *catalogue.Build {
return &catalogue.Build{On: []catalogue.BuildsOn{{Arg: "BASE", Module: module, Artifact: "img"}}}
}
trunkWeb := catalogue.Manifest{Module: "web", Provides: offer("database")}
if row.trunkCycle {
trunkWeb.Requires = []string{"site"}
}
b.ex.Read = fixedManifests{strings.Repeat("a", 64): {Module: "mail", Provides: offer("site"), Requires: []string{"database"}},
strings.Repeat("b", 64): trunkWeb, web: {Module: "web", Provides: offer("database"), Requires: []string{"site"}}}
mail := catalogue.Manifest{Module: "mail", Provides: offer("site"), Requires: []string{"database"}}
changed := catalogue.Manifest{Module: "web", Provides: offer("database"), Requires: []string{"site"}}
switch row.cycle {
case "bases":
mail = catalogue.Manifest{Module: "mail", Build: on("web")}
changed = catalogue.Manifest{Module: "web", Build: on("mail")}
case "verbs":
mail = catalogue.Manifest{Module: "mail", Tools: []string{"mail_send"}, Invokes: []string{"web.web_page"}}
changed = catalogue.Manifest{Module: "web", Tools: []string{"web_page"}, Invokes: []string{"mail.mail_send"}}
case "a verb and a provision":
mail = catalogue.Manifest{Module: "mail", Tools: []string{"mail_send"}, Requires: []string{"database"}}
changed = catalogue.Manifest{Module: "web", Provides: offer("database"), Invokes: []string{"mail.mail_send"}}
}
b.ex.Read = fixedManifests{strings.Repeat("a", 64): mail, strings.Repeat("b", 64): trunkWeb, web: changed}
}
var judged []delivery.ComposeJob
answer := b.asks.Answer
@@ -15,7 +15,9 @@ import (
)
// The check refuses a change whose module graph has a cycle and names it (novox/hq ADR 0603 §2): modules that would each
// wait for the other for good, through their bases or the provisions they await. The graph is worked out from the
// wait for the other for good, through their bases, the provisions and bus state they await, or the verb edges the graph
// keeps (ADR 0614 §6). A verb edge on a cycle is left out of the graph, so it is never refused (§3), and a package or a
// build module's edge is a trigger, never a wait, so a cycle through one is not refused either (§6). The graph is worked out from the
// catalogue as the change leaves it (every trunk manifest, each one a pull request touches replaced by its candidate's),
// with the contract's ManifestGraph, the way the indexer works out its graph's edges. A cycle the trunk already has among
// the same modules is the trunk's: said in the verdict's notes, not the change's failure.
@@ -70,5 +72,5 @@ func (st *state) refuseCycle(ctx context.Context) *delivery.Verdict {
}
}
return fail(goModule, fmt.Sprintf("the change's module graph has the cycle %s: these modules would each wait for the other for "+
"good, through their bases or the provisions they await (ADR 0603 §2)", words))
"good, through their bases, the provisions and bus state they await, or the verbs they invoke (ADR 0603 §2, ADR 0614 §6)", words))
}
@@ -122,6 +122,12 @@ type Examiner struct {
// Read reads a kept manifest by its digest: the change's module graph is worked out from them (graph_cycle.go, novox/hq
// ADR 0603 §2); nil works out no graph.
Read ManifestReader
// Serving says, from what runs, whether a running healthy build serves a seat verb or module tool (novox/hq ADR 0614
// §7): the node-engines' haves, the builder's build of each, its manifest (graph.Served). Nil reads nothing, and no
// changed build module is named for a verb it invokes.
Serving interface {
Served(ctx context.Context) (func(invoke string) bool, error)
}
// Predefined reads the catalogue's predefined keys the indexer keeps (manifests_judge.go); nil reads none.
Predefined PredefinedList
// Compositions makes a check's composition (ADR 0593 §7): the engines' answers (EngineCompositions); nil leaves the
+1 -1
View File
@@ -3,7 +3,7 @@ module mesh-delivery-forge-bridge
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0
)
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 h1:RtAFbUJRP1qO9AIMM2ue+cXNzqj7WZ7IOlSqNvttwuU=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 h1:XEbIVImp6yGjA91rp0iZBoDdL5KfamjOuDLwWvqh0hc=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
@@ -4,7 +4,6 @@ import (
"fmt"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-delivery/sdk/graph"
@@ -12,30 +11,6 @@ import (
"mesh-delivery-indexer/internal/index"
)
// CatalogTools are the seat mesh-catalog's four verbs as tools named <seat>.<verb>, with the descriptions and inputs the
// seat's contract gives them (novox/hq ADR 0604 §1, §2), answered from the indexer's own states. They are served once the
// indexer's manifest claims the seat, in the release after L3's (the lead's decision): until then main serves them not.
func CatalogTools(c index.CatalogRead) []stdio.Tool {
seat, ok := catalogue.SeatNamed(catalogue.CatalogSeat)
if !ok {
return nil
}
str := func(args map[string]any, k string) string { s, _ := args[k].(string); return s }
runs := map[string]func(map[string]any) (any, error){
"modules": func(map[string]any) (any, error) { return c.CatalogModulesAnswer() },
"module": func(a map[string]any) (any, error) { return c.CatalogModuleAnswer(str(a, "module")) },
"provides": func(a map[string]any) (any, error) { return c.CatalogProvidesAnswer(str(a, "provision")) },
"dependents": func(a map[string]any) (any, error) { return c.CatalogDependentsAnswer(str(a, "module")) },
}
var out []stdio.Tool
for _, v := range seat.Serves {
if run, ok := runs[v.Name]; ok {
out = append(out, stdio.Tool{Name: catalogue.CatalogSeat + "." + v.Name, Description: v.Description, Input: v.Input, Run: run})
}
}
return out
}
// catalogRead reads the indexer's modules, manifests and graph states for the seat's verbs.
func catalogRead(modules, manifests, nodes stdio.KeptState) index.CatalogRead {
return index.CatalogRead{
@@ -97,7 +72,7 @@ func catalogRead(modules, manifests, nodes stdio.KeptState) index.CatalogRead {
}
}
// catalogTools are the seat's verbs over the indexer's own states, as main serves them once the indexer claims the seat.
// catalogTools are the seat's verbs over the indexer's own states, served beside the indexer's own tools, since the indexer's manifest claims the seat (ADR 0604 §2).
func catalogTools() []stdio.Tool {
return CatalogTools(catalogRead(stdio.State("modules"), stdio.State("manifests"), stdio.State(graph.State)))
return index.CatalogTools(catalogRead(stdio.State("modules"), stdio.State("manifests"), stdio.State(graph.State)))
}
@@ -104,12 +104,14 @@ func Tools(ask asker, now func() time.Time, read stdio.KeptState, counts func()
Description: "Judge the manifests again now, as the indexer does at its start and after each merge, and answer how the module's " +
"verdict stands (waits, refused or accepted, and why). For a manifest that waits for a judge that knows its fields, or " +
"after a manifest was mended where the indexer has not heard it. A waiting manifest becomes refused here only when the " +
"running indexer is the newest build of itself the trunk holds. indexer_verdicts reads the result.",
"running indexer is the newest build of itself the trunk holds. With a digest it judges that one manifest of the " +
"module (a candidate's, or one an older judge refused) in the catalogue the trunk holds. indexer_verdicts reads the result.",
Input: map[string]any{"type": "object", "required": []string{"module"}, "properties": map[string]any{
"module": map[string]any{"type": "string", "description": "the module's name, as the catalogue registers it"},
"digest": map[string]any{"type": "string", "description": "one manifest digest of the module to judge (indexer_verdicts names it); none judges every manifest the trunk holds"},
"why": map[string]any{"type": "string", "description": "why it is judged again now, in plain words"}}},
Run: func(args map[string]any) (any, error) {
key, value, err := index.NewJudgeAgain(str(args, "module"), str(args, "why"), stdio.CallerOf(args), now())
key, value, err := index.NewJudgeAgain(str(args, "module"), str(args, "digest"), str(args, "why"), stdio.CallerOf(args), now())
if err != nil {
return nil, err
}
@@ -190,7 +192,7 @@ func heldCounts(s stdio.KeptState) func() (index.OldSectionsCount, error) {
}
func main() {
if err := stdio.Serve("", Tools(askTakeIn(stdio.State(index.TakeInsState), 25*time.Second), time.Now, stdio.State(index.VerdictsState), heldCounts(stdio.State(index.OldSectionsState)))); err != nil {
if err := stdio.Serve("", append(Tools(askTakeIn(stdio.State(index.TakeInsState), 25*time.Second), time.Now, stdio.State(index.VerdictsState), heldCounts(stdio.State(index.OldSectionsState))), catalogTools()...)); err != nil {
fmt.Fprintf(os.Stderr, "[mesh-delivery-indexer-tools] %v\n", err)
os.Exit(1)
}
@@ -15,7 +15,7 @@ import (
type takerIn interface {
TakeIn(ctx context.Context, module string) index.TakenIn
TakeInRepository(ctx context.Context, repository string) index.TakenIn
JudgeAgain(ctx context.Context, module string) index.TakenIn
JudgeAgain(ctx context.Context, module, digest string) index.TakenIn
}
// takenIn answers one value of the takeins state (the tool indexer_take_in, issue 1945): a value not yet answered takes the
@@ -38,7 +38,7 @@ func takenIn(ctx context.Context, ix takerIn, kv jetstream.KeyValue, e jetstream
asked, r = "take-in of a repository", ix.TakeInRepository(ctx, a.Repository)
a.Module = a.Repository
} else if a.Judge {
asked, r = "judging again", ix.JudgeAgain(ctx, a.Module)
asked, r = "judging again", ix.JudgeAgain(ctx, a.Module, a.Digest)
} else {
r = ix.TakeIn(ctx, a.Module)
}
+1 -1
View File
@@ -3,7 +3,7 @@ module mesh-delivery-indexer
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0
)
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 h1:RtAFbUJRP1qO9AIMM2ue+cXNzqj7WZ7IOlSqNvttwuU=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 h1:XEbIVImp6yGjA91rp0iZBoDdL5KfamjOuDLwWvqh0hc=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
@@ -186,6 +186,20 @@ func (ix *Indexer) ApplyMessage(ctx context.Context, f delivery.Forge) (Applied,
if held, err = ix.heldModules(ctx); err != nil {
return applied, err
}
// Each manifest the pull carries is judged by this judge, unless it holds a verdict already that this judge made or
// that accepts it: a refusal an older judge made never stands for a candidate (or a digest back on the trunk).
over := map[string]string{}
for _, digest := range digests {
if ix.Manifests == nil {
break
}
if value, _, found, err := ix.Manifests.Get(ctx, digest); err == nil && found {
if kept, err := delivery.DecodeManifest(value); err == nil {
over[kept.Module] = digest
}
}
}
ix.judgeDigests(ctx, over, fmt.Sprintf("%s#%d", f.Repository, f.Number))
if err := ix.configCandidateOfPull(ctx, f, held); err != nil {
if !errors.Is(err, ErrUnreadable) {
return applied, err
@@ -7,6 +7,7 @@ import (
"slices"
"strings"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
@@ -147,3 +148,27 @@ func (c CatalogRead) graphAndModules() (graph.Graph, map[string]delivery.Module,
}
return g, mods, nil
}
// CatalogTools are the seat mesh-catalog's four verbs as tools named <seat>.<verb>, with the descriptions and inputs the
// seat's contract gives them (novox/hq ADR 0604 §1, §2), answered from the indexer's own states. They are served once the
// indexer's manifest claims the seat, in the release after L3's (the lead's decision): until then main serves them not.
func CatalogTools(c CatalogRead) []stdio.Tool {
seat, ok := catalogue.SeatNamed(catalogue.CatalogSeat)
if !ok {
return nil
}
str := func(args map[string]any, k string) string { s, _ := args[k].(string); return s }
runs := map[string]func(map[string]any) (any, error){
"modules": func(map[string]any) (any, error) { return c.CatalogModulesAnswer() },
"module": func(a map[string]any) (any, error) { return c.CatalogModuleAnswer(str(a, "module")) },
"provides": func(a map[string]any) (any, error) { return c.CatalogProvidesAnswer(str(a, "provision")) },
"dependents": func(a map[string]any) (any, error) { return c.CatalogDependentsAnswer(str(a, "module")) },
}
var out []stdio.Tool
for _, v := range seat.Serves {
if run, ok := runs[v.Name]; ok {
out = append(out, stdio.Tool{Name: catalogue.CatalogSeat + "." + v.Name, Description: v.Description, Input: v.Input, Run: run})
}
}
return out
}
@@ -93,6 +93,19 @@ func catalogVerbsAnswerFromTheStates(t *testing.T) {
}
return true
}
// The tools bundle serves the four verbs as mesh-catalog.<verb>, answering from the same states.
var names []string
for _, tool := range CatalogTools(c) {
names = append(names, tool.Name)
if tool.Name == "mesh-catalog.modules" {
if got, err := tool.Run(nil); err != nil || !strings.Contains(string(answer(got, nil)), `"module":"avahi"`) {
t.Errorf("mesh-catalog.modules answered %v, %v", got, err)
}
}
}
if !slices.Equal(names, []string{"mesh-catalog.modules", "mesh-catalog.module", "mesh-catalog.provides", "mesh-catalog.dependents"}) {
t.Errorf("the tools bundle serves %v", names)
}
raw := answer(c.CatalogModulesAnswer())
if got, want := keysAt(t, raw, "modules"), capturedKeys(t, "catalog_modules.json", "modules"); !contains(got, want) {
t.Errorf("modules' fields %v, want the old catalog's %v", got, want)
@@ -15,8 +15,8 @@ import (
)
// The module graph (novox/hq ADR 0603 §2): at every recompute the indexer writes, in its state graph
// (graph.Bucket), one key per module it holds the inputs of, its build and provision edges as that recompute
// computed them, and deletes the key of a module the modules state no longer holds. A key is written only when its value
// (graph.Bucket), one key per module it holds the inputs of, its build, provision and verb edges as that recompute
// computed them (ADR 0614 §1), the verb edges on a cycle left out over the whole graph (§3), and deletes the key of a module the modules state no longer holds. A key is written only when its value
// changes, so its history of 5 is five changes of the module's edges. The delivery view, the checker and the seat
// mesh-catalog's verbs read it; nothing stores an order (ADR 0603 §1).
@@ -89,13 +89,20 @@ func (ix *Indexer) writeGraph(ctx context.Context, edges map[string]graphEdges)
}
return out
}
// The verb edges on a cycle are left out over the whole graph (ADR 0614 §3), every module it knows, so a module past
// GraphMost still closes the cycle it lies on.
whole := graph.Graph{}
for name, e := range edges {
whole[name] = graphNode(e, providers)
}
whole.LeaveOut()
for _, name := range names {
key, err := graph.Key(name)
if err != nil {
ix.logf("graph.%s is not written: %v", name, err)
continue
}
next := graphNode(edges[name], providers)
next := whole[name]
err = write(ctx, ix.Graph, key, func(value []byte, found bool) ([]byte, bool, error) {
if found {
if cur, err := graph.Decode(value); err == nil && graph.Same(cur, next) {
@@ -7,6 +7,7 @@ import (
"slices"
"strings"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"mesh-delivery-indexer/internal/fingerprint"
@@ -95,6 +96,9 @@ func (ix *Indexer) applyMoving(ctx context.Context, f delivery.Forge, drop bool,
}
dirOf[name] = dir
next := delivery.Module{Repository: f.Repository, Commit: f.Merge, Src: ix.srcOf(f.Repository, f.Merge), Manifest: digests[dir], Path: pathOf(dir)}
// The reader capabilities a changed manifest needs are written with it, before the judge's pass names them with its
// verdict, so no reader takes a new manifest as needing nothing (ADR 0614 §4).
next.Needs, _ = catalogue.ManifestNeeds([]byte(raw))
if next.Manifest == "" {
next.None2 = "the manifest cannot be read: it cannot be kept as a value"
} else {
@@ -10,6 +10,7 @@ import (
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/process"
)
@@ -413,6 +414,11 @@ func TestAMergeAddingAModuleDirectoryWritesItsValueWithNoBuildRecordAnywhere(t *
if m.FP2 == "" && m.None2 == "" {
t.Errorf("module = %+v, want a new-form fingerprint or its reason", m)
}
// ADR 0614 §4: the merge writes the reader capabilities its manifest needs, so no reader takes it as needing nothing
// before the judge's pass.
if want, _ := catalogue.ManifestNeeds([]byte(avahi)); len(m.Needs) == 0 || !slices.Equal(m.Needs, want) {
t.Errorf("module needs %v, want %v", m.Needs, want)
}
}
func TestAMergeWritesOnlyTheModulesItsManifestsName(t *testing.T) { // I1
@@ -31,7 +31,9 @@ type TakeInAsk struct {
Repository string `json:"repository,omitempty"`
Why string `json:"why,omitempty"`
// Judge asks the manifests to be judged again now (indexer_judge_again) instead of the module to be taken in.
Judge bool `json:"judge,omitempty"`
Judge bool `json:"judge,omitempty"`
// Digest names, with Judge, the one manifest digest of the module to judge (indexer_judge with a digest).
Digest string `json:"digest,omitempty"`
By string `json:"by,omitempty"`
At time.Time `json:"at"`
Result *TakenIn `json:"result,omitempty"`
@@ -365,8 +365,9 @@ func (ix *Indexer) writeOverlay(ctx context.Context, overlay, finals map[string]
held := found && err == nil
if held && cur.Manifest == next.Manifest {
// The judge's verdict stands while the digest does (ADR 0578 §3, ADR 0579 §1): a recompute never writes a
// refused or outgrown module as accepted; only the judge changes it.
// refused or outgrown module as accepted; only the judge changes it. So do the reader capabilities it named.
next.Verdict, next.Problems, next.Unknown, next.Warnings = cur.Verdict, cur.Problems, cur.Unknown, cur.Warnings
next.Needs = cur.Needs
}
// The source revision at which FP2 last changed (ADR 0601 §1): this merge's when it moved it (0 until the
// source revision naming the merge is heard, which stamps it), else the one held.
@@ -401,6 +402,7 @@ func sameButWhere(cur, next delivery.Module) bool {
next = withV(next)
next.Commit, next.Src, next.FPSrc = cur.Commit, cur.Src, cur.FPSrc
next.Verdict, next.Problems, next.Unknown, next.Warnings = cur.Verdict, cur.Problems, cur.Unknown, cur.Warnings
next.Needs = cur.Needs
return reflect.DeepEqual(cur, next)
}
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"reflect"
"slices"
"strings"
"testing"
"time"
@@ -330,6 +331,24 @@ func TestBasesAreComputedFirstAndNoFingerprintTakesTheBuilder(t *testing.T) { //
t.Errorf("%s moved with a build of build-agent: fp2 = %q, want %s", name, m.FP2, fp)
}
}
// ADR 0614 §2, §3: the graph names what each module invokes and serves, and leaves out, over the whole graph, the
// verb edges of two modules calling each other, from either side; a third calling one of them keeps its edge.
invoking := func(name, invokes string) mod {
raw := renamed(t, "avahi", name)
return mod{name, strings.Replace(raw, `"version": "1",`, `"version": "1", "invokes": [`+invokes+`],`, 1)}
}
vx, _ := newFormIndexer(t, invoking("caller", `"callee.avahi_status"`), invoking("callee", `"caller.avahi_browse", "*"`),
invoking("third", `"callee.avahi_status"`))
for name, want := range map[string][2][]string{
"caller": {{"callee.avahi_status"}, {"callee.avahi_status"}},
"callee": {{"caller.avahi_browse"}, {"caller.avahi_browse"}},
"third": {{"callee.avahi_status"}, nil},
} {
if g := graphOf(t, vx, name); !reflect.DeepEqual(g.Invokes, want[0]) || !reflect.DeepEqual(g.LeftOut, want[1]) ||
!slices.Contains(g.Serves, name+".avahi_status") {
t.Errorf("graph.%s = %+v, want it to invoke %v and leave out %v", name, g, want[0], want[1])
}
}
}
func TestABuildHeardBeforeAnyToolchainIsReadDropsNoFingerprint(t *testing.T) { // 1727 (review: a restart flapped every FP2)
@@ -49,6 +49,14 @@ type Judged struct {
// watchman raises as manifest-outgrown. A manifest in fresh (the digests a merge just took in) with no verdict yet, and
// one already refused, is judged in full.
func (ix *Indexer) JudgeAll(ctx context.Context, fresh map[string]bool) (Judged, error) {
return ix.judgeOver(ctx, fresh, nil)
}
// judgeOver is JudgeAll; with over (module to manifest digest) it judges those digests alone, each in the catalogue the
// modules state holds with its module's manifest replaced by it (a module the state does not hold added), and writes
// their verdicts and nothing else: no event, no count of the old sections, and no module's value but one that names the
// digest judged (carryVerdict). A digest of over with no verdict yet is judged in full, as a fresh one.
func (ix *Indexer) judgeOver(ctx context.Context, fresh map[string]bool, over map[string]string) (Judged, error) {
// A merge, the start pass and indexer_judge may all judge at once: one pass at a time, so none writes a verdict from
// modules another is rewriting.
ix.judgeMu.Lock()
@@ -61,6 +69,15 @@ func (ix *Indexer) JudgeAll(ctx context.Context, fresh map[string]bool) (Judged,
if err != nil {
return out, err
}
if over != nil {
fresh = map[string]bool{}
for module, digest := range over {
m := held[module]
m.Manifest = digest
held[module] = m
fresh[digest] = true
}
}
raws := map[string][]byte{}
digests := map[string]string{}
for _, name := range slices.Sorted(maps.Keys(held)) {
@@ -99,12 +116,12 @@ func (ix *Indexer) JudgeAll(ctx context.Context, fresh map[string]bool) (Judged,
// release's Approve; ADR 0578 §1): it is refused only when this program is the newest build of the indexer the trunk
// holds, so no judge that could know the field is still to come.
newest := ix.ownBuildIsNewest(held)
id := ix.JudgeID
if id == "" {
id = judge.Identity()
}
id := ix.judgeID()
var accepted []catalogue.Manifest
for _, name := range slices.Sorted(maps.Keys(digests)) {
if _, judged := over[name]; over != nil && !judged {
continue
}
out.Judged++
v := delivery.ManifestVerdict{Module: name, State: delivery.VerdictAccepted, Judge: id, At: ix.now()}
if fields := waits[name]; len(fields) > 0 {
@@ -143,7 +160,10 @@ func (ix *Indexer) JudgeAll(ctx context.Context, fresh map[string]bool) (Judged,
ix.logf("judge: the verdict on %s (digest %s) is not written: %v", name, digests[name], err)
continue
}
if wrote {
if wrote && over != nil {
out.Written++
ix.logf("judge: %s judged again by %s: %s", digests[name], id, v.State)
} else if wrote {
out.Written++
if v.Waits() {
ix.logf("judge: %s", v.Why())
@@ -160,10 +180,15 @@ func (ix *Indexer) JudgeAll(ctx context.Context, fresh map[string]bool) (Judged,
}
}
}
if err := ix.carryVerdict(ctx, name, digests[name], v); err != nil {
// The reader capabilities the manifest's own words, forms and seat names need, named with the verdict (ADR 0614 §4).
needs, _ := catalogue.ManifestNeeds(raws[name])
if err := ix.carryVerdict(ctx, name, digests[name], v, needs); err != nil {
ix.logf("judge: modules.%s does not carry its verdict: %v", name, err)
}
}
if over != nil {
return out, nil
}
if err := ix.keepOldSections(ctx, judge.CountOldSections(accepted)); err != nil {
ix.logf("judge: the count of the old sections is not kept: %v", err)
}
@@ -171,14 +196,86 @@ func (ix *Indexer) JudgeAll(ctx context.Context, fresh map[string]bool) (Judged,
}
// JudgeAtStart is JudgeAll of every manifest held, none fresh, as the indexer starts (a judge that changed judges every
// one again); one that fails is judged again at the next message or source revision heard (kickJudge).
// one again); one that fails is judged again at the next message or source revision heard (kickJudge). Then every other
// digest of the verdicts state that another judge refused or held waiting (one of a candidate, or one back on the trunk
// later) is judged again by this judge (judgeStale), so a refusal an older judge made never stands for good.
func (ix *Indexer) JudgeAtStart(ctx context.Context) (Judged, error) {
ix.learnOwnBuild(ctx)
j, err := ix.JudgeAll(ctx, nil)
ix.judgePending.Store(err != nil)
if err == nil {
ix.judgeStale(ctx)
}
return j, err
}
// judgeID is the judge a verdict of this program names.
func (ix *Indexer) judgeID() string {
if ix.JudgeID != "" {
return ix.JudgeID
}
return judge.Identity()
}
// staleVerdict says whether the digest's verdict wants this judge's judgement: there is none, or another judge refused
// it or held it waiting. An accepted verdict stays accepted under any later judge (ADR 0579 §1), so judging it again
// changes nothing.
func (ix *Indexer) staleVerdict(ctx context.Context, digest string) bool {
value, _, found, err := ix.Verdicts.Get(ctx, digest)
if err != nil {
return false // unread: left, said by the next pass that reads it
}
if !found {
return true
}
v, err := delivery.DecodeManifestVerdict(value)
return err != nil || (v.Held() && v.Judge != ix.judgeID())
}
// judgeDigests judges each digest of over (module to digest) whose verdict is stale (staleVerdict), one at a time in the
// catalogue the modules state holds; a pass that fails is said and the others go on.
func (ix *Indexer) judgeDigests(ctx context.Context, over map[string]string, why string) {
if ix.Verdicts == nil || ix.Manifests == nil || ix.Modules == nil {
return
}
for _, module := range slices.Sorted(maps.Keys(over)) {
if !ix.staleVerdict(ctx, over[module]) {
continue
}
if _, err := ix.judgeOver(ctx, nil, map[string]string{module: over[module]}); err != nil {
ix.logf("judge: the manifest %s of %s (%s) is not judged: %v", over[module], module, why, err)
}
}
}
// judgeStale judges again every digest of the verdicts state another judge refused or held waiting, each as a manifest
// of the module its verdict names (judgeDigests). Never on a timer: at the start pass.
func (ix *Indexer) judgeStale(ctx context.Context) {
if ix.Verdicts == nil {
return
}
keys, err := ix.Verdicts.Keys(ctx)
if err != nil {
ix.logf("judge: the verdicts other judges made are not judged again: listing the verdicts: %v", err)
return
}
byModule := map[string][]string{}
for _, digest := range keys {
value, _, found, err := ix.Verdicts.Get(ctx, digest)
if err != nil || !found {
continue
}
if v, err := delivery.DecodeManifestVerdict(value); err == nil && v.Held() && v.Judge != ix.judgeID() {
byModule[v.Module] = append(byModule[v.Module], digest)
}
}
for _, module := range slices.Sorted(maps.Keys(byModule)) {
for _, digest := range byModule[module] {
ix.judgeDigests(ctx, map[string]string{module: digest}, "made by another judge")
}
}
}
// kickJudge judges again a start pass that failed, when the indexer hears something: never on a timer.
func (ix *Indexer) kickJudge(ctx context.Context) {
if !ix.judgePending.Load() {
@@ -257,9 +354,10 @@ func (ix *Indexer) ownBuildIsNewest(held map[string]delivery.Module) bool {
}
// carryVerdict puts a refusal into the module's value, and takes one out once the manifest is accepted, while the value
// still names the digest judged; an accepted module's value carries nothing, so judging writes no value of a module the
// still names the digest judged, and the reader capabilities the manifest needs beside it (needs, ADR 0614 §4); an
// accepted module's value carries no verdict, so judging writes no value of a module the
// judge accepts (a reader takes a value without a verdict as accepted, delivery.TakeManifestVerdict).
func (ix *Indexer) carryVerdict(ctx context.Context, name, digest string, v delivery.ManifestVerdict) error {
func (ix *Indexer) carryVerdict(ctx context.Context, name, digest string, v delivery.ManifestVerdict, needs []string) error {
key, err := delivery.ModulesKey(name)
if err != nil {
return err
@@ -280,10 +378,11 @@ func (ix *Indexer) carryVerdict(ctx context.Context, name, digest string, v deli
case v.Refused():
state = delivery.VerdictRefused
}
if cur.Verdict == state && slices.Equal(cur.Problems, problems) && slices.Equal(cur.Unknown, v.Unknown) && slices.Equal(cur.Warnings, v.Warnings) {
if cur.Verdict == state && slices.Equal(cur.Problems, problems) && slices.Equal(cur.Unknown, v.Unknown) && slices.Equal(cur.Warnings, v.Warnings) &&
slices.Equal(cur.Needs, needs) {
return nil, false, nil
}
cur.Verdict, cur.Problems, cur.Unknown, cur.Warnings = state, problems, v.Unknown, v.Warnings
cur.Verdict, cur.Problems, cur.Unknown, cur.Warnings, cur.Needs = state, problems, v.Unknown, v.Warnings, needs
if state == "" {
cur.Problems = nil
}
@@ -370,26 +469,55 @@ func ListVerdicts(keys []string, get func(key string) ([]byte, error), module st
// JudgeAgain judges the manifests again now, as at the start, and answers how the module's stands: the tool
// indexer_judge. A module the modules state does not hold, or whose manifest is not in the manifests state, is
// answered so.
func (ix *Indexer) JudgeAgain(ctx context.Context, module string) TakenIn {
// answered so. With a digest it judges that manifest alone, in the catalogue the trunk holds (judgeOver), whoever made
// its verdict: a candidate's, or one the trunk names again, refused by an older judge.
func (ix *Indexer) JudgeAgain(ctx context.Context, module, digest string) TakenIn {
out := TakenIn{Module: module}
held, err := ix.heldModules(ctx)
if err != nil {
out.Answer = "the modules state cannot be read: " + err.Error()
return out
}
// The build this program runs is asked again, so a start pass that missed the engine's answer is recovered without a restart.
ix.learnOwnBuild(ctx)
if digest != "" {
// One digest (a candidate's, or one the trunk no longer names), judged by this judge in the catalogue the trunk holds.
value, _, found, err := ix.Manifests.Get(ctx, digest)
kept, derr := delivery.DecodeManifest(value)
switch {
case err != nil || !found:
out.Answer = "the indexer keeps no manifest of digest " + digest
return out
case derr != nil:
out.Answer = "the manifest of digest " + digest + " cannot be read: " + derr.Error()
return out
case module != "" && kept.Module != module:
out.Answer = "the manifest of digest " + digest + " is " + kept.Module + "'s, not " + module + "'s"
return out
}
out.Module = kept.Module
if _, err := ix.judgeOver(ctx, nil, map[string]string{kept.Module: digest}); err != nil {
out.Answer = "the manifest could not be judged: " + err.Error()
return out
}
return ix.answerVerdict(ctx, out, digest)
}
m, ok := held[module]
if !ok || m.Manifest == "" {
out.Answer = "the indexer holds no manifest of " + module
return out
}
// The build this program runs is asked again, so a start pass that missed the engine's answer is recovered without a restart.
ix.learnOwnBuild(ctx)
if _, err := ix.JudgeAll(ctx, nil); err != nil {
out.Answer = "the manifests could not be judged: " + err.Error()
return out
}
value, _, found, err := ix.Verdicts.Get(ctx, m.Manifest)
return ix.answerVerdict(ctx, out, m.Manifest)
}
// answerVerdict is out answering how the verdict under digest stands.
func (ix *Indexer) answerVerdict(ctx context.Context, out TakenIn, digest string) TakenIn {
module := out.Module
value, _, found, err := ix.Verdicts.Get(ctx, digest)
if err != nil || !found {
out.Answer = "judged, but the verdict on " + module + " cannot be read"
return out
@@ -407,8 +535,14 @@ func (ix *Indexer) JudgeAgain(ctx context.Context, module string) TakenIn {
return out
}
// NewJudgeAgain is the value indexer_judge writes for a module: its key in the takeins state and the value.
func NewJudgeAgain(module, why, by string, at time.Time) (string, []byte, error) {
// NewJudgeAgain is the value indexer_judge writes for a module, and one digest of its manifest when given: its key in
// the takeins state and the value.
func NewJudgeAgain(module, digest, why, by string, at time.Time) (string, []byte, error) {
if digest != "" {
if _, err := delivery.ManifestsKey(digest); err != nil {
return "", nil, err
}
}
key, data, err := NewTakeIn(module, why, by, at)
if err != nil {
return "", nil, err
@@ -417,7 +551,7 @@ func NewJudgeAgain(module, why, by string, at time.Time) (string, []byte, error)
if err := json.Unmarshal(data, &a); err != nil {
return "", nil, err
}
a.Judge = true
a.Judge, a.Digest = true, digest
data, err = json.Marshal(a)
return judgeAgainPrefix + key, data, err
}
@@ -4,10 +4,12 @@ import (
"context"
"errors"
"fmt"
"slices"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
@@ -113,6 +115,7 @@ func TestTheIndexerJudgesEveryManifestOnce(t *testing.T) {
// A merge's write of a module whose digest it leaves as it is carries the verdict over, and nothing is fresh; a changed
// digest is fresh, judged in full (review R1, R2).
w := &mergeWrite{}
oldNeeds := module("old").Needs
same := delivery.Module{FP2: "src2:" + strings.Repeat("c", 64), Manifest: changed, Repository: "novox/mesh-catalog", Commit: "c2"}
if err := ix.writeOverlay(ctx, map[string]delivery.Module{"old": same}, nil, w); err != nil {
t.Fatal(err)
@@ -120,6 +123,9 @@ func TestTheIndexerJudgesEveryManifestOnce(t *testing.T) {
if m := module("old"); m.Verdict != delivery.VerdictRefused || len(m.Problems) == 0 || len(w.fresh) != 0 {
t.Fatalf("a merge rewrote a refused module as %+v, fresh %v", m, w.fresh)
}
if got := module("old").Needs; len(oldNeeds) == 0 || !slices.Equal(got, oldNeeds) {
t.Fatalf("a merge leaving the digest dropped the reader capabilities the judge named: %v, held %v", got, oldNeeds)
}
other := strings.Repeat("d", 64)
same.Manifest = other
if err := ix.writeOverlay(ctx, map[string]delivery.Module{"old": same}, nil, w); err != nil || !w.fresh[other] {
@@ -137,6 +143,15 @@ func TestTheIndexerJudgesEveryManifestOnce(t *testing.T) {
t.Fatal(err)
}
}
// ADR 0614 §4: the judge names, with the verdict, the reader capabilities the manifest's own words need: the bus-state
// words, which an engine stating nothing (the baseline) lacks; a manifest of the baseline's words lacks none.
if needs := module("moved").Needs; !slices.Contains(needs, "word:bus-state.storage") ||
!slices.Contains(catalogue.MissingManifestReads(needs, nil), "word:bus-state") {
t.Fatalf("modules.moved needs %v", needs)
}
if needs := module("good").Needs; !slices.Contains(needs, "word:module") || len(catalogue.MissingManifestReads(needs, nil)) != 0 {
t.Fatalf("modules.good needs %v", needs)
}
if len(accepted) != 1 || accepted[0].Module != "moved" || accepted[0].Digest != moved || accepted[0].Judge != "judge-2" ||
len(accepted[0].BusState) != 2 || accepted[0].BusState[0].Name != "cache" || accepted[0].BusState[1].Template != "log" ||
string(accepted[0].BusState[0].Options["history"]) != "8" {
@@ -261,25 +276,66 @@ func TestTheIndexerJudgesEveryManifestOnce(t *testing.T) {
// the indexer is the newer one, so the field waits; asked and answered with that same build it is refused).
ix.ownFP = ""
ix.OwnBuild = func(context.Context) (string, error) { return "src2:" + strings.Repeat("a", 64), nil }
if got := ix.JudgeAgain(ctx, "later"); !got.Stored || !strings.Contains(got.Answer, "refused") || ix.ownFP == "" {
if got := ix.JudgeAgain(ctx, "later", ""); !got.Stored || !strings.Contains(got.Answer, "refused") || ix.ownFP == "" {
t.Fatalf("indexer_judge did not learn the running build: %+v", got)
}
ix.ownFP = ownFP
ix.OwnBuild = nil
if got := ix.JudgeAgain(ctx, "later"); !got.Stored || !strings.Contains(got.Answer, "waits") || !strings.Contains(got.Answer, "a-field-from-later") {
if got := ix.JudgeAgain(ctx, "later", ""); !got.Stored || !strings.Contains(got.Answer, "waits") || !strings.Contains(got.Answer, "a-field-from-later") {
t.Fatalf("indexer_judge: %+v", got)
}
if rows, _ := ListVerdicts(keys, get, "later", false); len(rows) != 1 {
t.Fatalf("indexer_verdicts of later after: %+v", rows)
}
if got := ix.JudgeAgain(ctx, "nobody"); got.Stored || !strings.Contains(got.Answer, "holds no manifest") {
if got := ix.JudgeAgain(ctx, "nobody", ""); got.Stored || !strings.Contains(got.Answer, "holds no manifest") {
t.Fatalf("indexer_judge of nobody: %+v", got)
}
if key, value, err := NewJudgeAgain("later", "why", "me", time.Now()); err != nil || key != "judge.later" {
if key, value, err := NewJudgeAgain("later", "", "why", "me", time.Now()); err != nil || key != "judge.later" {
t.Fatalf("the ask: %q %v", key, err)
} else if a, err := DecodeTakeIn(value); err != nil || !a.Judge || a.Module != "later" {
t.Fatalf("the ask's value: %+v %v", a, err)
}
// A refusal an older judge made never stands: a digest the trunk no longer names (a candidate's, or one back on the
// trunk later) refused by judge-0 for words it did not know is judged again by this judge at the start, at a pull that
// carries it, and by indexer_judge with its digest; one this judge refused itself is left as it is.
stale := func(module, raw string) string {
digest, err := ix.keepManifest(ctx, module, raw)
if err != nil {
t.Fatal(err)
}
if _, err := ix.writeVerdict(ctx, digest, delivery.ManifestVerdict{Module: module, State: delivery.VerdictRefused,
Problems: []string{module + " says tool-runner, which this judge does not know"}, Judge: "judge-0", At: time.Now()}); err != nil {
t.Fatal(err)
}
return digest
}
atStart := stale("tools", `{"module":"tools","version":"7"}`)
ownRefusal := stale("client", `{"module":"client","version":"7"}`)
if _, err := ix.writeVerdict(ctx, ownRefusal, delivery.ManifestVerdict{Module: "client", State: delivery.VerdictRefused,
Problems: []string{"x"}, Judge: ix.judgeID(), At: time.Now()}); err != nil {
t.Fatal(err)
}
if _, err := ix.JudgeAtStart(ctx); err != nil || verdict(atStart).Refused() || verdict(atStart).Judge != ix.judgeID() || !verdict(ownRefusal).Refused() {
t.Fatalf("the start pass: %+v, this judge's own refusal %+v (%v)", verdict(atStart), verdict(ownRefusal), err)
}
byPull := stale("tools", `{"module":"tools","version":"8"}`)
pull := delivery.Forge{Kind: delivery.ForgePull, Repository: "novox/mesh-tools", Number: 3, Branch: "main",
Manifests: map[string]string{"modules/tools": `{"module":"tools","version":"8"}`}}
if _, err := ix.ApplyMessage(ctx, pull); err != nil || verdict(byPull).Refused() || verdict(byPull).Judge != ix.judgeID() {
t.Fatalf("a pull carrying a digest an older judge refused: %+v %v", verdict(byPull), err)
}
byTool := stale("tools", `{"module":"tools","version":"9"}`)
if got := ix.JudgeAgain(ctx, "client", byTool); got.Stored || !strings.Contains(got.Answer, "tools's, not client's") || !verdict(byTool).Refused() {
t.Fatalf("indexer_judge with another module's digest: %+v", got)
}
if got := ix.JudgeAgain(ctx, "tools", byTool); !got.Stored || !strings.Contains(got.Answer, "accepted") || verdict(byTool).Refused() {
t.Fatalf("indexer_judge with a digest: %+v %+v", got, verdict(byTool))
}
if key, value, err := NewJudgeAgain("tools", byTool, "why", "me", time.Now()); err != nil || key != "judge.tools" {
t.Fatalf("the ask with a digest: %q %v", key, err)
} else if a, err := DecodeTakeIn(value); err != nil || a.Digest != byTool {
t.Fatalf("the ask's digest: %+v %v", a, err)
}
// Without the verdicts state the indexer judges nothing.
ix.Verdicts = nil
if j, err := ix.JudgeAll(ctx, nil); err != nil || j.Judged != 0 {
+2 -3
View File
@@ -10,7 +10,6 @@ import (
json "encoding/json"
errors "errors"
fmt "fmt"
toolrunner "git.novox.be/novox/mesh-delivery/sdk/toolrunner"
catalogue "git.novox.be/novox/mesh-sdk/go/catalogue"
math "math"
net "net"
@@ -137,7 +136,7 @@ func spokenSystems() string {
// and was simply absent — seven modules' tools went missing that way on 2026-10-03. Refused here,
// naming the field that would deliver it.
func undeliveredBundles(m catalogue.Manifest) []string {
if m.Build == nil || toolrunner.Is(m) {
if m.Build == nil || catalogue.IsToolRunner(m) {
return nil
}
named := runByAResource(m)
@@ -3307,7 +3306,7 @@ func waitProblems(m catalogue.Manifest) []string {
if len(m.Waits) == 0 {
return nil
}
if !toolrunner.Is(m) {
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)}
}
@@ -163,18 +163,11 @@ func TestABundleNothingDeliversIsRefused(t *testing.T) {
t.Errorf("a bundle delivered by %s was refused: %v", name, p)
}
}
// The tool runner is what says it is one (issue 774), and, while node-tools' trunk manifest leaves `tool-runner` out
// for the running indexer, mesh-sdk's deprecated catalogue.RuntimeModule too (issue 1585 note 42): any other name alone
// exempts nothing.
kept := base()
kept.Module = catalogue.RuntimeModule
if p := undeliveredBundles(kept); len(p) != 0 {
t.Errorf("a bundle of the kept runtime module, which does not say it is the tool runner, was refused: %v", p)
}
// The tool runner is what says it is one (issue 774): its name alone exempts nothing.
named := base()
named.Module = "agent-tools"
named.Module = "node-tools"
if p := undeliveredBundles(named); len(p) != 1 {
t.Errorf("a bundle of a module that does not say it is the tool runner: %v", p)
t.Errorf("a bundle of a module named as the tool runner, which does not say it is one: %v", p)
}
}
@@ -3122,7 +3115,7 @@ func TestAVerbWithAnUnknownKeyIsRefused(t *testing.T) {
func TestAWaitIsRefusedUnlessItNamesOneFamilyOfTheToolRunner(t *testing.T) {
for manifest, want := range map[string]string{
`{"module":"shop","version":"1","waits":["mesh-knowledge.reviewed"]}`: "only the tool runner's module",
`{"module":"agent-tools","version":"1","waits":["mesh-knowledge.reviewed"]}`: "only the tool runner's module", // its name says nothing (issue 774)
`{"module":"node-tools","version":"1","waits":["mesh-knowledge.reviewed"]}`: "only the tool runner's module", // its name says nothing (issue 774)
`{"module":"node-tools","version":"1","tool-runner":true,"waits":["mesh-knowledge.*"]}`: "never a wildcard",
`{"module":"node-tools","version":"1","tool-runner":true,"waits":["**"]}`: "never a wildcard",
`{"module":"node-tools","version":"1","tool-runner":true,"waits":["mesh-knowledge.>"]}`: "never a wildcard",
@@ -3141,11 +3134,6 @@ func TestAWaitIsRefusedUnlessItNamesOneFamilyOfTheToolRunner(t *testing.T) {
t.Errorf("%s refused for the wrong reason (want %q): %v", manifest, want, err)
}
}
// mesh-sdk's deprecated catalogue.RuntimeModule waits without saying `tool-runner`, while node-tools' trunk manifest
// leaves the word out for the running indexer (issue 774, issue 1585 note 42).
if _, err := ParseManifest([]byte(`{"module":"` + catalogue.RuntimeModule + `","version":"1","waits":["mesh-knowledge.reviewed"]}`)); err != nil {
t.Errorf("the kept runtime module's waits were refused: %v", err)
}
}
// 1178/AC2: a work-queue stream is read by exactly one process of its module and by no other module's consumes: one
+17 -5
View File
@@ -12,6 +12,7 @@ import (
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-delivery/sdk/graph"
@@ -38,9 +39,14 @@ type artifact struct {
}
type manifest struct {
Module string `json:"module"`
Version string `json:"version"`
Slug string `json:"slug"`
Module string `json:"module"`
Version string `json:"version"`
Slug string `json:"slug"`
Claims []struct {
Name string `json:"name"`
Scope string `json:"scope"`
Serves []string `json:"serves"`
} `json:"claims"`
Tools []string `json:"tools"`
Emits []string `json:"emits"`
Consumes []string `json:"consumes"`
@@ -122,8 +128,14 @@ func TestManifestDeclarations(t *testing.T) { // 1238/AC1, 1444 (reads the build
if want := []string{"indexer_take_in", "indexer_old_sections", "indexer_verdicts", "indexer_judge"}; !reflect.DeepEqual(m.Tools, want) {
t.Errorf("tools = %v, want %v", m.Tools, want)
}
// No streams, no bus, no seat (ADR 0437 §4); it says no event.
for _, k := range []string{"streams", "logs", "claims", "provides", "requires", "uses", "defines-seats", "seat", "seats", "bus"} {
// ADR 0604 §2: it holds the seat mesh-catalog, serving its four verbs from its tools bundle (which stays without a
// request subject).
if len(m.Claims) != 1 || m.Claims[0].Name != catalogue.CatalogSeat || m.Claims[0].Scope != catalogue.ScopeMesh ||
!slices.Equal(m.Claims[0].Serves, []string{"modules", "module", "provides", "dependents"}) {
t.Errorf("claims = %+v, want mesh-catalog with its four verbs", m.Claims)
}
// No streams, no bus, no other seat (ADR 0437 §4); it says no event.
for _, k := range []string{"streams", "logs", "provides", "requires", "uses", "defines-seats", "seat", "seats", "bus"} {
if _, ok := top[k]; ok {
t.Errorf("the manifest declares %q: the indexer has none", k)
}
+12
View File
@@ -2,6 +2,18 @@
"module": "mesh-delivery-indexer",
"version": "1",
"slug": "indexer",
"claims": [
{
"name": "mesh-catalog",
"scope": "mesh",
"serves": [
"modules",
"module",
"provides",
"dependents"
]
}
],
"tools": [
"indexer_take_in",
"indexer_old_sections",
+1 -1
View File
@@ -3,7 +3,7 @@ module mesh-delivery-teller
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0
)
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 h1:RtAFbUJRP1qO9AIMM2ue+cXNzqj7WZ7IOlSqNvttwuU=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 h1:XEbIVImp6yGjA91rp0iZBoDdL5KfamjOuDLwWvqh0hc=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
@@ -48,9 +48,11 @@ func Tools(r view.Reader, u view.UsageReader) []stdio.Tool {
}},
{Name: view.TiersTool,
Description: "A source revision's tiers, worked out now and never stored (novox/hq ADR 0603 §6): the modules the revision moves " +
"(each whose fingerprint last changed at it), ordered by the module graph (a module after the bases and the providers " +
"of the provisions it awaits among them), and for each module what it waits on now and why: its build key, on each node " +
"that wants it its want (the assigner's rule and words when held back), its have, and each provision it awaits. Nodes " +
"(each whose fingerprint last changed at it), ordered by the module graph (a module after the bases, the providers " +
"of the provisions and bus state it awaits and the modules serving the verbs it invokes, among them; novox/hq ADR 0614), " +
"and for each module what it waits on now and why, named by its edge: its build key, on each node that wants it its want " +
"(the assigner's rule and words when held back: a verb no running build serves, a reader capability the node-engine " +
"does not state), its have, each provision and its bus state; and the verb edges the graph leaves out on a cycle. Nodes " +
"the watchman says are offline are named away and left out. At most 500 modules, said when cut short; said when the " +
"module graph cannot be read. Give revision for an older one, none for the gate's newest. Read-only.",
Input: map[string]any{"type": "object", "properties": map[string]any{
@@ -26,6 +26,9 @@ func encoded(t *testing.T, v []byte, err error) string {
return string(v)
}
// short is a fingerprint as the assigner's words shorten it.
func short(fp string) string { return fp[:len("src2:")+12] }
// tiersToolSaysEachModulesWait: view_tiers orders a source revision's modules by the module graph and names each one's
// wait (ADR 0603 §6): a consumer after its provider, held by the assigner's provision rule and its provision not provided;
// a module whose build waits for a package; a provider with no wait; a node offline named away; older modules left out.
@@ -104,6 +107,32 @@ func tiersToolSaysEachModulesWait(t *testing.T) {
if out, err := run(t, r, view.TiersTool, map[string]any{}); err != nil || out.(view.Tiers).Revision != 9 || out.(view.Tiers).Modules != 3 {
t.Errorf("view_tiers of the gate's newest = %+v, %v", out, err)
}
// ADR 0614 §2-§5: a want held on a verb, a reader capability or bus state is named by its edge; bus state is no
// provisioner key (none is ever made for it), so a module asking it shows no provision wait of it; and each verb edge
// the graph leaves out on a cycle is named.
r.GraphState = fake{
"db": node(graph.Node{FP: fp("a"), Repository: "novox/mesh-catalog", Provides: []string{"database"}}),
"web": node(graph.Node{FP: fp("b"), Repository: "novox/mesh-catalog", Requires: []string{"database", graph.BusState}, Awaits: []string{"database", graph.BusState},
Invokes: []string{"seat:x.v", "db.loop"}, LeftOut: []string{"db.loop"}}),
"lib": node(graph.Node{FP: fp("c"), Repository: "novox/mesh-catalog"}),
}.reader().State
for words, edge := range map[string]string{
graph.VerbWaitWords("seat:x.v", "web", "ace"): "verb seat:x.v",
graph.ReaderWaitWords("ace", []string{"tool-runner"}, "web", short(fp("b"))): "reader capability tool-runner",
"the provision ace.web.database of web on ace is asked, not provided": "want",
} {
r.WantsState = fake{key(delivery.WantKey("ace", "web")): want(delivery.Want{Self: true, FP: fp("f"), Newest: fp("b"), Rule: delivery.RuleProvisions, Words: words})}.reader().State
out, err := run(t, r, view.TiersTool, map[string]any{"revision": float64(9)})
got, _ := out.(view.Tiers)
if err != nil || len(got.Tiers) != 2 {
t.Fatalf("view_tiers 9 = %+v, %v", out, err)
}
web := got.Tiers[1][0]
if len(web.Waits) != 2 || web.Waits[0].Edge != edge || web.Waits[1].Edge != "provision database" ||
!reflect.DeepEqual(web.LeftOut, []string{"db.loop"}) {
t.Errorf("web held by %q = %+v, want its want named %s, no wait for a bus-state key and db.loop left out", words, web, edge)
}
}
// The graph not readable: every module in the first tier, said; past 500 modules the answer says it is cut short.
r.GraphState = view.StateRead{Keys: func() ([]string, error) { return nil, errors.New("no bucket") }, Get: r.GraphState.Get}
if out, err := run(t, r, view.TiersTool, map[string]any{"revision": float64(9)}); err != nil || len(out.(view.Tiers).Tiers) != 1 ||
+1 -1
View File
@@ -3,7 +3,7 @@ module mesh-delivery-view
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0
)
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 h1:RtAFbUJRP1qO9AIMM2ue+cXNzqj7WZ7IOlSqNvttwuU=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 h1:XEbIVImp6yGjA91rp0iZBoDdL5KfamjOuDLwWvqh0hc=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
@@ -16,9 +16,11 @@ import (
)
// The tiers of a source revision are a view, worked out at each call and never stored (novox/hq ADR 0603 §1, §6): the
// modules the revision moves, ordered by the module graph's ordering edges among them (a base, a provision awaited from
// another of them), and for each module what it waits on now: its build key, each node's want and have, and each
// provision it awaits on each node, with the rule and the words of the part that holds it. Nothing in the mesh waits on
// modules the revision moves, ordered by the module graph's ordering edges among them (a base, a provision or bus state
// awaited from another of them, a verb another of them serves whose edge the graph keeps, ADR 0614 §1), and for each module
// what it waits on now: its build key, each node's want (named by its edge: a verb, a reader capability) and have, and
// each provision it awaits on each node, with the rule and the words of the part that holds it, and the verb edges the
// graph leaves out. Nothing in the mesh waits on
// a tier; the tiers say where the revision stands. A node the watchman says is offline is named away and left out of
// what the revision waits on (ADR 0603 §7).
@@ -43,18 +45,22 @@ type Tiers struct {
Away []string `json:"away,omitempty"`
}
// TierModule is one module of a tier: its fingerprint, the modules of the same revision it waits for (After), and each
// wait of its own now; no wait is a module that reached every node present.
// TierModule is one module of a tier: its fingerprint, the modules of the same revision it waits for (After), each
// wait of its own now (no wait is a module that reached every node present), and the verbs it invokes whose edge the
// module graph leaves out on a cycle, so nothing waits on them (LeftOut, ADR 0614 §3).
type TierModule struct {
Module string `json:"module"`
FP string `json:"fp,omitempty"`
None string `json:"none,omitempty"`
After []string `json:"after,omitempty"`
Waits []TierWait `json:"waits,omitempty"`
Module string `json:"module"`
FP string `json:"fp,omitempty"`
None string `json:"none,omitempty"`
After []string `json:"after,omitempty"`
Waits []TierWait `json:"waits,omitempty"`
LeftOut []string `json:"left-out,omitempty"`
}
// TierWait is one thing a module waits on: the edge (build, want, have, or provision <name>), the node it waits on (none
// for its build), the assigner's rule for a want held back, the part's words and since when, where the part says it.
// TierWait is one thing a module waits on: the edge (build, have, provision <name>, or for a want held back want, verb
// <verb>, reader capability <capabilities> or bus state as the assigner's words name it, ADR 0614 §2, §4, §5), the node
// it waits on (none for its build), the assigner's rule for a want held back, the part's words and since when, where the
// part says it.
type TierWait struct {
Edge string `json:"edge"`
Node string `json:"node,omitempty"`
@@ -169,7 +175,7 @@ func (r Reader) Tiers(revision uint64) (Tiers, error) {
out.Tiers = append(out.Tiers, nil)
}
m := moved[n]
tm := TierModule{Module: n, FP: m.FP2, None: m.None2, After: after[n]}
tm := TierModule{Module: n, FP: m.FP2, None: m.None2, After: after[n], LeftOut: gr[n].LeftOut}
tm.Waits = r.waitsOf(n, m, gr[n], wants, provided, perr, away)
out.Tiers[t] = append(out.Tiers[t], tm)
}
@@ -234,11 +240,20 @@ func (r Reader) waitsOf(module string, m delivery.Module, g graph.Node, wants, p
if words == "" {
words = "the want names " + short(w.FP) + ", not " + short(m.FP2)
}
out = append(out, TierWait{Edge: "want", Node: node, Rule: w.Rule, Words: words, Since: r.since(r.WantsState, mustWantKey(node, module))})
edge := graph.EdgeOfWords(w.Words)
if edge == "" {
edge = "want"
}
out = append(out, TierWait{Edge: edge, Node: node, Rule: w.Rule, Words: words, Since: r.since(r.WantsState, mustWantKey(node, module))})
} else if hw, ok := r.haveWait(node, module, m.FP2); ok {
out = append(out, hw)
}
for _, a := range g.Awaits {
if a == graph.BusState {
// Bus state is offered with no credential of a consumer's own, so the provisioner keys nothing for it: its wait is
// the assigner's held want alone, named bus state from its words (ADR 0614 §5).
continue
}
out = append(out, provisionWaits(node, module, a, provided, perr)...)
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ module mesh-delivery-watchman
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0
)
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 h1:RtAFbUJRP1qO9AIMM2ue+cXNzqj7WZ7IOlSqNvttwuU=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 h1:XEbIVImp6yGjA91rp0iZBoDdL5KfamjOuDLwWvqh0hc=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E=
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
+1 -1
View File
@@ -3,7 +3,7 @@ module mesh-enrolment
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8
github.com/nats-io/nats.go v1.54.0
)
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 h1:RtAFbUJRP1qO9AIMM2ue+cXNzqj7WZ7IOlSqNvttwuU=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 h1:XEbIVImp6yGjA91rp0iZBoDdL5KfamjOuDLwWvqh0hc=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
@@ -93,6 +93,8 @@ func TestTheGrantsPartWhole(t *testing.T) {
put(grants.ReadManifests, digest, delivery.Manifest{V: delivery.Version, Module: module, JSON: raw})
put(grants.ReadModules, module, delivery.Module{V: delivery.Version, FP2: "src2:" + strings.Repeat("ab", 32), Manifest: digest,
Repository: "novox/x", Commit: "c"})
verdict, _ := delivery.EncodeManifestVerdict(delivery.ManifestVerdict{Module: module, State: delivery.VerdictAccepted, Judge: "j", At: time.Now()})
put(grants.ReadVerdicts, digest, json.RawMessage(verdict))
}
manifest("nats", `{"module":"nats","version":"1","claims":[{"name":"mesh-broker"}],"own-secrets":{"broker":"/var/lib/nats/broker"}}`)
manifest("mod-c", `{"module":"mod-c","version":"1","own-secrets":{"broker":"/var/lib/c/broker"},"consumes":["nats.done"]}`)
+1 -5
View File
@@ -3,7 +3,7 @@ module mesh-grants
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0
)
@@ -20,7 +20,3 @@ require (
golang.org/x/sys v0.48.0 // indirect
golang.org/x/time v0.15.0 // indirect
)
require git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000
replace git.novox.be/novox/mesh-delivery/sdk => ../../sdk
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 h1:RtAFbUJRP1qO9AIMM2ue+cXNzqj7WZ7IOlSqNvttwuU=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 h1:XEbIVImp6yGjA91rp0iZBoDdL5KfamjOuDLwWvqh0hc=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
+38 -22
View File
@@ -154,6 +154,12 @@ func (b *fakeBus) put(state, key string, v any) {
b.states[state] = map[string][]byte{}
}
b.states[state][key] = raw
// A modules value that carries no verdict is accepted by the indexer's verdict on its digest, as the indexer writes it;
// a row that wants the window before the judge deletes that key.
if m, ok := v.(delivery.Module); ok && state == grants.ReadModules && m.Verdict == "" && m.Manifest != "" {
verdict, _ := delivery.EncodeManifestVerdict(delivery.ManifestVerdict{Module: key, State: delivery.VerdictAccepted, Judge: "j", At: time.Now()})
b.put(grants.ReadVerdicts, m.Manifest, json.RawMessage(verdict))
}
}
func (b *fakeBus) Keys(state string) ([]string, error) {
@@ -2822,7 +2828,8 @@ func TestGrants(t *testing.T) {
t.Errorf("%s: the kept user was not said: %+v", held.name, last)
}
}
// A held module that runs no build the part can read declares nothing to anyone: the list in force is kept, said.
// A held module that runs no build the part can read declares nothing to anyone: only its own users stay as the list in
// force holds them, said unreadable; the others move (ADR 0614 §8).
delete(b.states[grants.ReadHave], "b.mod-r")
b.put(grants.ReadModules, "mod-r", delivery.Module{V: delivery.Version, FP2: "src2:" + strings.Repeat("ab", 32),
Manifest: delivery.ManifestDigest([]byte(ctl)), Repository: "novox/x", Commit: "c", Verdict: delivery.VerdictRefused,
@@ -2831,8 +2838,28 @@ func TestGrants(t *testing.T) {
wasList, _, _ := p.Sealed()
res := p.Compose()
nowList, _, _ := p.Sealed()
if nowList.Digest != wasList.Digest || res.Sealed == nil || !strings.Contains(res.Sealed.Why, "running manifest of mod-r") {
t.Errorf("a held module with no running manifest did not keep the list in force: %+v", res.Sealed)
if nowList.Digest == wasList.Digest || slices.ContainsFunc(res.Errors, func(e string) bool { return strings.Contains(e, "composed whole") }) {
t.Errorf("a module with no readable manifest held the whole list: %+v", res.Sealed)
}
if kept, _, ok, _ := b.ownGet(grants.StateUserList, grants.SealedUserKey(nowList.Digest, "b.mod-r.mod-r-tools")); !ok || string(kept) != string(before) {
t.Errorf("b.mod-r.mod-r-tools did not keep its grants of the list in force: %s", kept)
}
if last := said[len(said)-1]; last.State != asks.StateRaised || !strings.Contains(last.Summary, "the manifest of mod-r is unreadable") {
t.Errorf("the unreadable manifest was not said: %+v", last)
}
// The window before the judge: the indexer wrote a changed manifest's value and no verdict on its digest yet. Only that
// module's users stay (named unreadable); the others move.
b.manifestOf("mod-r", ctl)
delete(b.states[grants.ReadVerdicts], delivery.ManifestDigest([]byte(ctl)))
b.put(grants.ReadAccounts, "a.mod-x", map[string]any{"user": "a.mod-x", "hash": "$2a$10$gen11", "generation": 11, "fingerprint": "sha256:11"})
wasList, _, _ = p.Sealed()
res = p.Compose()
nowList, _, _ = p.Sealed()
if nowList.Digest == wasList.Digest || strings.Contains(fmt.Sprint(res.Sealed), "kept while a read") {
t.Errorf("a manifest with no verdict yet held the whole list: %+v", res.Sealed)
}
if last := said[len(said)-1]; last.State != asks.StateRaised || !strings.Contains(last.Summary, "no verdict on this manifest yet") {
t.Errorf("a manifest with no verdict yet was not said: %+v", last)
}
// Accepted again: the users compose from the manifest and the condition clears.
b.manifestOf("mod-r", ctl)
@@ -2842,38 +2869,27 @@ func TestGrants(t *testing.T) {
// ADR 0601 §4: a tool runner whose manifest does not say so yet (an older build's) keeps its user as the list in force
// holds it, known by what it runs (it says the runner is alive), and the held condition says so; never composed as a
// plain module.
const runner = `{"module":"agent-runtime","version":"1","tool-runner":true,"own-secrets":{"broker":"/var/lib/nt/broker"}}`
b.manifestOf("agent-runtime", runner)
b.assign("a", "agent-runtime")
b.put(grants.ReadAccounts, "a.agent-runtime", map[string]any{"user": "a.agent-runtime", "hash": "$2a$10$first", "generation": 1, "fingerprint": "sha256:1"})
const runner = `{"module":"node-tools","version":"1","tool-runner":true,"own-secrets":{"broker":"/var/lib/nt/broker"}}`
b.manifestOf("node-tools", runner)
b.assign("a", "node-tools")
b.put(grants.ReadAccounts, "a.node-tools", map[string]any{"user": "a.node-tools", "hash": "$2a$10$first", "generation": 1, "fingerprint": "sha256:1"})
p.Compose()
inForce, _, _ = p.Sealed()
ran, _, ok, _ := b.ownGet(grants.StateUserList, grants.SealedUserKey(inForce.Digest, "a.agent-runtime"))
ran, _, ok, _ := b.ownGet(grants.StateUserList, grants.SealedUserKey(inForce.Digest, "a.node-tools"))
if !ok || !strings.Contains(string(ran), "mesh.control.a.tools-alive") {
t.Fatalf("the list in force holds no tool runner on a: %s", ran)
}
b.manifestOf("agent-runtime", strings.Replace(runner, `"tool-runner":true,`, "", 1))
b.manifestOf("node-tools", strings.Replace(runner, `"tool-runner":true,`, "", 1))
b.put(grants.ReadAccounts, "a.mod-x", map[string]any{"user": "a.mod-x", "hash": "$2a$10$gen10", "generation": 10, "fingerprint": "sha256:10"})
p.Compose()
now, _, _ := p.Sealed()
kept, _, ok, _ := b.ownGet(grants.StateUserList, grants.SealedUserKey(now.Digest, "a.agent-runtime"))
kept, _, ok, _ := b.ownGet(grants.StateUserList, grants.SealedUserKey(now.Digest, "a.node-tools"))
if !ok || string(kept) != string(ran) {
t.Errorf("the tool runner of a was composed without what it runs: %s, was %s", kept, ran)
}
if last := said[len(said)-1]; last.State != asks.StateRaised || !strings.Contains(last.Summary, "a.agent-runtime (stays in the list") {
if last := said[len(said)-1]; last.State != asks.StateRaised || !strings.Contains(last.Summary, "a.node-tools (stays in the list") {
t.Errorf("the kept tool runner was not said: %+v", last)
}
// While node-tools' trunk manifest leaves `tool-runner` out for the running indexer, mesh-sdk's deprecated
// catalogue.RuntimeModule is the tool runner without saying so (issue 774, issue 1585 note 42): composed as one.
b.manifestOf(catalogue.RuntimeModule, strings.Replace(strings.Replace(runner, `"tool-runner":true,`, "", 1), "agent-runtime", catalogue.RuntimeModule, 1))
b.assign("c", catalogue.RuntimeModule)
b.put(grants.ReadAccounts, "c."+catalogue.RuntimeModule, map[string]any{"user": "c." + catalogue.RuntimeModule, "hash": "$2a$10$c", "generation": 1, "fingerprint": "sha256:c"})
p.Compose()
now, _, _ = p.Sealed()
if composed, _, ok, _ := b.ownGet(grants.StateUserList, grants.SealedUserKey(now.Digest, "c."+catalogue.RuntimeModule)); !ok ||
!strings.Contains(string(composed), "mesh.control.c.tools-alive") {
t.Errorf("the kept runtime module on c was not composed as the tool runner: %s", composed)
}
})
}
@@ -21,7 +21,7 @@ type Watcher interface {
//
// And the seat mesh-enrolment's keys (ADR 0587 §4, §5): a key at live composes enrol.<node>, and its end, by the holder or by
// the bus's expiry, is a change of the key that takes the user out; no event is needed beside the watch.
var Followed = []string{ReadModules, ReadResolved, ReadHave, ReadWants, ReadAccounts, ReadPlacement, ReadEnrolments,
var Followed = []string{ReadModules, ReadVerdicts, ReadResolved, ReadHave, ReadWants, ReadAccounts, ReadPlacement, ReadEnrolments,
ReadNodeLogins}
// Follower composes again on every change it hears, never on a timer. It never stops for a refusal: a watch it cannot
+7 -1
View File
@@ -47,9 +47,15 @@ func heldModuleUsers(in Inputs, candidates []string) map[string]string {
continue
}
prefix := node + "." + module
// No manifest of the module can be read at all (the trunk's is refused and no running build's is found): named
// unreadable, in the condition and in grants_user_list (ADR 0614 §8).
state := "held"
if _, readable := in.Held[module]; !readable {
state = "unreadable"
}
for _, user := range candidates {
if user == prefix || strings.HasPrefix(user, prefix+".") {
out[user] = fmt.Sprintf("the manifest of %s is held: %s", module, why)
out[user] = fmt.Sprintf("the manifest of %s is %s: %s", module, state, why)
}
}
}
+45 -10
View File
@@ -25,10 +25,13 @@ import (
const (
ReadModules = "mesh-delivery-indexer.modules"
ReadManifests = "mesh-delivery-indexer.manifests"
ReadHave = "node-engine.have"
ReadResolved = "mesh-config.resolved"
ReadWants = "mesh-delivery-assigner.wants"
ReadBuilds = "mesh-delivery-builder.builds"
// ReadVerdicts is the indexer's verdicts, one key per manifest digest: a manifest it has not judged yet has none, and is
// held like a refused one (ADR 0614 §8).
ReadVerdicts = "mesh-delivery-indexer.verdicts"
ReadHave = "node-engine.have"
ReadResolved = "mesh-config.resolved"
ReadWants = "mesh-delivery-assigner.wants"
ReadBuilds = "mesh-delivery-builder.builds"
// ReadAccounts is the vault's bcrypt hashes of the bus accounts it holds, by bus user (ADR 0547 clause 9).
ReadAccounts = "mesh-vault.bus-accounts"
// ReadPlacement is the seat mesh-placement's placement (ADR 0567 §1, §9): each node's modules and the seats
@@ -44,7 +47,7 @@ const (
// Reads is every state the part reads, in the manifest's order. Not the controller's assignments: the seat's placement
// alone places modules (novox/hq ADR 0549 §5, ADR 0571 §10; issue 774).
var Reads = []string{ReadModules, ReadManifests, ReadResolved, ReadHave, ReadWants, ReadBuilds,
var Reads = []string{ReadModules, ReadManifests, ReadVerdicts, ReadResolved, ReadHave, ReadWants, ReadBuilds,
ReadAccounts, ReadPlacement, ReadEnrolments, ReadNodeLogins}
// The bus module's tools the part calls, as its manifest's invokes name them: read-only, each.
@@ -336,7 +339,10 @@ func ReadInputs(r Reader, cache *Manifests) Inputs {
for _, module := range indexed {
raw, found, err := r.Get(ReadModules, module)
if err != nil {
in.Gaps = append(in.Gaps, Gap{Input: ReadModules + " " + module, Why: err.Error(), Failed: true})
// One module's value that does not read holds that module alone, as a refused manifest does (ADR 0614 §8).
g := Gap{Input: "manifest of " + module, Why: "its value in " + ReadModules + " does not read: " + err.Error()}
in.Refused = append(in.Refused, g)
in.Gaps = append(in.Gaps, g)
continue
}
if !found {
@@ -360,6 +366,10 @@ func ReadInputs(r Reader, cache *Manifests) Inputs {
// The indexer's refusal, or its wait for a judge that knows a field, carried in its value (ADR 0578 §3): held and named as a refused manifest.
if verdict := v.VerdictOf(module); verdict != nil && verdict.Held() {
err = errors.New(verdict.Why())
} else if verdict == nil {
// The indexer writes a changed manifest's value before it judges it, and a new digest has no verdict: until
// the verdict is written the manifest is held (ADR 0614 §8).
err = noVerdictYet(r, v.Manifest)
}
}
if err == nil {
@@ -446,8 +456,9 @@ func ReadInputs(r Reader, cache *Manifests) Inputs {
}
in.Held = heldManifests(r, in, cache)
// A held module placed on a node whose running manifest is not found declares no seat, stream or object bucket to
// anyone: composing past it would drop the grants of those who use them, so the list in force is kept (ADR 0601
// clause 3), and the gap says which module and why.
// anyone: composing past it would drop the grants of those who use them, so the users of that module alone stay as the
// list in force holds them (held.go, named unreadable; ADR 0614 §8, ADR 0601 clause 3). The gap says which module and why,
// and holds nothing else: it is not a failed read.
for _, g := range in.Refused {
module, ok := strings.CutPrefix(g.Input, "manifest of ")
if _, found := in.Held[module]; !ok || found {
@@ -455,8 +466,8 @@ func ReadInputs(r Reader, cache *Manifests) Inputs {
}
for _, assigned := range in.Assigned {
if slices.Contains(assigned, module) {
in.Gaps = append(in.Gaps, Gap{Input: "running manifest of " + module, Failed: true,
Why: "its manifest is held and no running build's manifest is found, so the seats and streams it declares cannot be composed: the list in force is kept"})
in.Gaps = append(in.Gaps, Gap{Input: "running manifest of " + module,
Why: "its manifest is unreadable (held, and no running build's manifest is found), so the seats and streams it declares cannot be composed: its users stay as the list in force holds them"})
break
}
}
@@ -650,3 +661,27 @@ func (in Inputs) declared() map[string]catalogue.Manifest {
}
return out
}
// noVerdictYet is nil when the indexer's verdicts state holds an accepted verdict on the manifest, and the reason it is held
// when it holds none yet, cannot be read, or refuses it.
func noVerdictYet(r Reader, digest string) error {
key, err := delivery.VerdictsKey(digest)
if err != nil {
return nil
}
raw, found, err := r.Get(ReadVerdicts, key)
if err != nil {
return fmt.Errorf("its verdict cannot be read: %w", err)
}
if !found {
return errors.New("the indexer has written no verdict on this manifest yet")
}
v, err := delivery.DecodeManifestVerdict(raw)
if err != nil {
return fmt.Errorf("its verdict does not read: %w", err)
}
if v.Held() {
return errors.New(v.Why())
}
return nil
}
@@ -6,7 +6,6 @@ import (
"slices"
"strings"
"git.novox.be/novox/mesh-delivery/sdk/toolrunner"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"mesh-grants/internal/buscompose"
@@ -133,7 +132,7 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio
fromModules = append(fromModules, c)
}
d := buscompose.Declared{
Module: m.Module, ToolRunner: toolrunner.Is(m), Emits: m.EmitsAll(), Consumes: fromModules, Watches: watches, Serves: m.Tools,
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),
Streams: streamsOf(m), Queues: queueOwnersOf(m), Processes: processesOf(m), Checks: catalogue.HealthChecks(m),
RunsAs: m.RunsAs, Issued: issuedTools(m),
+1
View File
@@ -8,6 +8,7 @@
"mesh-delivery-builder.builds",
"mesh-delivery-indexer.manifests",
"mesh-delivery-indexer.modules",
"mesh-delivery-indexer.verdicts",
"mesh-delivery-assigner.placement",
"node-engine.have",
"mesh-vault.bus-accounts",
+1 -1
View File
@@ -2,4 +2,4 @@ module git.novox.be/novox/mesh-delivery/sdk
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 h1:RtAFbUJRP1qO9AIMM2ue+cXNzqj7WZ7IOlSqNvttwuU=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 h1:XEbIVImp6yGjA91rp0iZBoDdL5KfamjOuDLwWvqh0hc=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
+222 -13
View File
@@ -91,6 +91,10 @@ func ParseKey(key string) (string, error) {
// build module and the toolchain it was computed with. Provides are the provisions its manifest offers (its own name is
// provided too and is not listed), Requires what it requires and does not require optionally, and Awaits those of
// Requires a want of it waits for (catalogue.Awaited, ADR 0570 §4): the provision edges that order a source revision.
// A bus-state entry of its manifest is a requirement of the provision BusState (ADR 0614 §5). Invokes are the seat verbs
// (`seat:<seat>.<verb>`) and module tools (`<module>.<tool>`) its manifest and its own bus accounts invoke, Serves those
// its claims serve and its own tools answer, in the same two forms (ADR 0614 §2), and LeftOut those of Invokes whose
// verb edge lies on a cycle and is left out of the graph, so no want waits on it (§3; LeaveOut works them out).
// Every list is sorted and each entry once; an encode sorts them.
type Node struct {
V int `json:"v"`
@@ -104,8 +108,15 @@ type Node struct {
Provides []string `json:"provides,omitempty"`
Requires []string `json:"requires,omitempty"`
Awaits []string `json:"awaits,omitempty"`
Invokes []string `json:"invokes,omitempty"`
Serves []string `json:"serves,omitempty"`
LeftOut []string `json:"left-out,omitempty"`
}
// BusState is the provision a bus-state entry of a manifest requires (ADR 0614 §5): the bus module's build that offers it
// goes before the module that asks bus state.
const BusState = "bus-state"
// Toolchain is a build edge of one language: the build module that builds it (Module) and the toolchain version
// and recipe version its fingerprint names.
type Toolchain struct {
@@ -152,9 +163,30 @@ func (g Node) check() error {
return fmt.Errorf("graph: the graph node awaits %q, which it does not require", a)
}
}
for _, list := range [][]string{g.Invokes, g.Serves} {
for _, v := range list {
if !isVerb(v) {
return fmt.Errorf("graph: the graph node names %q, which is neither seat:<seat>.<verb> nor <module>.<tool>", v)
}
}
}
for _, l := range g.LeftOut {
if !slices.Contains(g.Invokes, l) {
return fmt.Errorf("graph: the graph node leaves out %q, which it does not invoke", l)
}
}
return nil
}
// isVerb says whether v is a seat verb `seat:<seat>.<verb>` or a module tool `<module>.<tool>`.
func isVerb(v string) bool {
if _, _, ok := catalogue.SeatInvoke(v); ok {
return true
}
module, tool, ok := strings.Cut(v, ".")
return ok && module != "" && tool != "" && !strings.HasPrefix(v, "seat:")
}
func sortedOnce(in []string) []string {
if len(in) == 0 {
return nil
@@ -167,6 +199,7 @@ func sortedOnce(in []string) []string {
func (g Node) normal() Node {
g.Bases, g.Packages = sortedOnce(g.Bases), sortedOnce(g.Packages)
g.Provides, g.Requires, g.Awaits = sortedOnce(g.Provides), sortedOnce(g.Requires), sortedOnce(g.Awaits)
g.Invokes, g.Serves, g.LeftOut = sortedOnce(g.Invokes), sortedOnce(g.Serves), sortedOnce(g.LeftOut)
if len(g.Toolchains) == 0 {
g.Toolchains = nil
}
@@ -304,9 +337,22 @@ func (g Graph) Dependents(module string) []string {
return out
}
// Before are the modules name waits for, by the graph's ordering edges, sorted: its bases and, for each provision it
// awaits, every other module that provides it (ADR 0603 §1). A toolchain or package edge orders nothing (§3).
func (g Graph) Before(name string) []string {
// Servers are the modules whose Serves names invoke (a seat verb or a module tool), sorted.
func (g Graph) Servers(invoke string) []string {
var out []string
for name, n := range g {
if slices.Contains(n.Serves, invoke) {
out = append(out, name)
}
}
slices.Sort(out)
return out
}
// waits are the modules name waits for, sorted: its bases, for each provision it awaits every other module that provides
// it (ADR 0603 §1), and with verbs for each invoke the module serving it (ADR 0614 §2), those it leaves out (§3) left
// out unless all is set.
func (g Graph) waits(name string, verbs, all bool) []string {
n := g[name]
out := slices.Clone(n.Bases)
for _, a := range n.Awaits {
@@ -316,15 +362,101 @@ func (g Graph) Before(name string) []string {
}
}
}
if verbs {
for _, v := range n.Invokes {
if !all && slices.Contains(n.LeftOut, v) {
continue
}
for _, s := range g.Servers(v) {
if s != name || all {
out = append(out, s)
}
}
}
}
return sortedOnce(out)
}
// Cycle is the first cycle of the graph's ordering edges, as the modules it passes through with the first named again
// at its end (a -> b -> a), or nil for none (ADR 0603 §2: the check refuses it and names it). The ordering edges are a
// module's bases and, for each provision it awaits, every other module that provides it: modules ordered on each other
// would each wait for the other for good. A toolchain edge is no ordering edge (ADR 0603 §3: nothing waits on a build
// module), and neither is a package edge. The modules are visited in sorted order, so the same graph names the same
// cycle.
// Before are the modules name waits for, by the graph's ordering edges, sorted: its bases, for each provision it awaits
// (bus state among them, ADR 0614 §5) every other module that provides it (ADR 0603 §1), and for each verb it invokes
// and the graph keeps, every other module that serves it (ADR 0614 §2, §3). A toolchain or package edge orders nothing:
// it is a trigger, never a wait (ADR 0614 §6).
func (g Graph) Before(name string) []string { return g.waits(name, true, false) }
// LeaveOut sets each node's LeftOut (ADR 0614 §3): every invoke whose verb edge lies on a cycle of the edges a part waits
// on (bases, awaited provisions, every verb edge), whichever module the edge starts from, a module invoking what it
// serves itself included. An edge lies on a cycle when the module it reaches reaches the module it starts from: both are
// in one strongly connected part of the graph. What is left of the graph then has no cycle through a verb edge, so the
// check's refusal (Cycle) never meets one.
func (g Graph) LeaveOut() {
part := g.parts(func(name string) []string { return g.waits(name, true, true) })
for _, name := range slices.Sorted(maps.Keys(g)) {
n := g[name]
n.LeftOut = nil
for _, v := range n.Invokes {
for _, s := range g.Servers(v) {
if part[s] == part[name] {
n.LeftOut = append(n.LeftOut, v)
break
}
}
}
g[name] = n.normal()
}
}
// parts numbers each module by the strongly connected part of edges it is in (Tarjan's), so two modules have one number
// when each reaches the other.
func (g Graph) parts(edges func(string) []string) map[string]int {
index, low, part := map[string]int{}, map[string]int{}, map[string]int{}
onStack := map[string]bool{}
var stack []string
next, parts := 0, 0
var visit func(string)
visit = func(name string) {
index[name], low[name] = next, next
next++
stack = append(stack, name)
onStack[name] = true
for _, to := range edges(name) {
if _, ours := g[to]; !ours {
continue
}
if _, seen := index[to]; !seen {
visit(to)
low[name] = min(low[name], low[to])
} else if onStack[to] {
low[name] = min(low[name], index[to])
}
}
if low[name] == index[name] {
for {
top := stack[len(stack)-1]
stack = stack[:len(stack)-1]
onStack[top] = false
part[top] = parts
if top == name {
break
}
}
parts++
}
}
for _, name := range slices.Sorted(maps.Keys(g)) {
if _, seen := index[name]; !seen {
visit(name)
}
}
return part
}
// Cycle is the first cycle of the graph's ordering edges (Before), as the modules it passes through with the first named
// again at its end (a -> b -> a), or nil for none (ADR 0603 §2: the check refuses it and names it). The ordering edges
// are what a part waits on, provisions, bus state and the verb edges the graph keeps (ADR 0614 §6), and bases, since a
// module built on another's artifact can never build in a cycle of bases: modules ordered on each other would each wait
// for the other for good. A verb edge the graph leaves out (LeaveOut) is none, and neither is a toolchain edge (ADR 0603
// §3: nothing waits on a build module) or a package edge (ADR 0614 §6: a trigger). The modules are visited in sorted
// order, so the same graph names the same cycle.
func (g Graph) Cycle() []string {
edges := g.Before
const (
@@ -367,9 +499,11 @@ func (g Graph) Cycle() []string {
}
// EdgesOf is the graph node of manifest's own edges: its bases (each module its build stands on), what it provides,
// what it requires and does not require optionally, and of those what a want of it waits for (catalogue.Awaited, as the
// assigner judges it), providers answering the manifests that provide a provision. The fingerprint, place, packages and
// toolchains are the indexer's to add; this is the part a reader of manifests alone (the checker) works out the same way.
// what it requires and does not require optionally, a bus-state entry as a requirement of BusState (ADR 0614 §5), and of
// those what a want of it waits for (catalogue.Awaited, as the assigner judges it; BusState whenever a provider offers
// it), providers answering the manifests that provide a provision; and the verbs and tools it invokes and serves
// (InvokesOf, ServesOf). The fingerprint, place, packages, toolchains and the verb edges left out (LeaveOut, over the
// whole graph) are the indexer's to add; this is the part a reader of manifests alone (the checker) works out the same way.
func EdgesOf(manifest catalogue.Manifest, providers func(provision string) []catalogue.Manifest) Node {
var n Node
if manifest.Build != nil {
@@ -388,11 +522,56 @@ func EdgesOf(manifest catalogue.Manifest, providers func(provision string) []cat
}
}
n.Awaits = catalogue.Awaited(manifest, providers)
if len(manifest.BusState) > 0 {
n.Requires = append(n.Requires, BusState)
if len(providers(BusState)) > 0 {
n.Awaits = append(n.Awaits, BusState)
}
}
n.Invokes, n.Serves = InvokesOf(manifest), ServesOf(manifest)
return n.normal()
}
// InvokesOf are the seat verbs and module tools manifest invokes, its own and its processes' and tools bundles' bus
// accounts' (catalogue.Manifest.BusAccounts), sorted, each once; `*`, every tool, names no edge and is left out.
func InvokesOf(manifest catalogue.Manifest) []string {
all := slices.Clone(manifest.Invokes)
for _, a := range manifest.BusAccounts() {
all = append(all, a.Bus.Invokes...)
}
var out []string
for _, v := range all {
if isVerb(v) {
out = append(out, v)
}
}
return sortedOnce(out)
}
// ServesOf are the seat verbs manifest's claims serve (`seat:<seat>.<verb>`, catalogue.Claim.ServesFor) and its own
// tools (`<module>.<tool>`), sorted, each once.
func ServesOf(manifest catalogue.Manifest) []string {
var out []string
for _, c := range manifest.Claims {
for _, v := range c.ServesFor(manifest) {
out = append(out, "seat:"+c.Name+"."+v)
}
}
for _, t := range manifest.Tools {
out = append(out, manifest.Module+"."+t)
}
var verbs []string
for _, v := range out {
if isVerb(v) {
verbs = append(verbs, v)
}
}
return sortedOnce(verbs)
}
// OfManifests is the graph of these manifests' own edges (EdgesOf), by module, each provision's providers being
// the manifests among them that offer it: what the check works out for a change to find a cycle (ADR 0603 §2).
// the manifests among them that offer it, with the verb edges on a cycle left out (LeaveOut): what the check works out
// for a change to find a cycle (ADR 0603 §2, ADR 0614 §3).
func OfManifests(manifests map[string]catalogue.Manifest) Graph {
providers := func(provision string) []catalogue.Manifest {
var out []catalogue.Manifest
@@ -407,5 +586,35 @@ func OfManifests(manifests map[string]catalogue.Manifest) Graph {
for name, m := range manifests {
g[name] = EdgesOf(m, providers)
}
g.LeaveOut()
return g
}
// Served is whether a running build serves a seat verb or module tool (ADR 0614 §2, §7), counted from the holders' own
// haves as a provider is (ADR 0603 §5): every have running healthy, by node and module, whose build's manifest serves it
// (servesOf answers the verbs of a module's build at a fingerprint, ServesOf of its manifest, and whether it was read).
// A build whose manifest cannot be read counts what fallback answers of its module (the graph's Serves, so a build read
// late never holds its callers for good, or nothing), and is named in unread, sorted, so its reader says so.
func Served(haves map[string]map[string]delivery.Have, servesOf func(module, fp string) ([]string, bool),
fallback func(module string) []string) (served func(invoke string) bool, unread []string) {
set := map[string]bool{}
for _, node := range slices.Sorted(maps.Keys(haves)) {
for _, module := range slices.Sorted(maps.Keys(haves[node])) {
h := haves[node][module]
if h.Phase != delivery.PhaseRunning || h.Health != delivery.HealthOK {
continue
}
serves, ok := servesOf(module, h.FP)
if !ok {
unread = append(unread, module)
if fallback != nil {
serves = fallback(module)
}
}
for _, v := range serves {
set[v] = true
}
}
}
return func(v string) bool { return set[v] }, slices.Compact(slices.Sorted(slices.Values(unread)))
}
+76 -10
View File
@@ -2,6 +2,7 @@ package graph
import (
"errors"
"maps"
"reflect"
"slices"
"strings"
@@ -97,6 +98,51 @@ func TestGraphEdges(t *testing.T) {
if got := mg.Cycle(); !reflect.DeepEqual(got, []string{"db", "web", "db"}) {
t.Errorf("the cycle of manifests = %v", got)
}
// The verb and bus-state edges (ADR 0614 §2, §3, §5, §6), from manifests alone: a caller waits for the module serving
// what it invokes, a bus-state entry requires bus-state of the bus module offering it, the builder's remove and the
// holder's builder_versions close a cycle and both are left out, read from either side, a package edge gives no order,
// and a module invoking its own tool is no wait.
builder := catalogue.Manifest{Module: "builder", Tools: []string{"builder_versions"}, Invokes: []string{"seat:artifact-store.remove"}}
store := catalogue.Manifest{Module: "store", Claims: []catalogue.Claim{{Name: "artifact-store", Scope: catalogue.ScopeMesh,
Serves: []string{"remove"}}}, Invokes: []string{"builder.builder_versions", "*"}}
verbs := map[string]catalogue.Manifest{
"builder": builder, "store": store,
"bus": {Module: "bus", Provides: []catalogue.Offer{{Name: BusState, Scope: catalogue.ScopeMesh}}},
"keeper": {Module: "keeper", BusState: []catalogue.BusStateEntry{{Name: "cache", Storage: "key-value"}}},
"caller": {Module: "caller", Invokes: []string{"seat:artifact-store.remove", "caller.own", "nobody.serves"}, Tools: []string{"own"}},
}
vg := OfManifests(verbs)
for name, want := range map[string][]string{"caller": {"store"}, "keeper": {"bus"}, "builder": nil, "store": nil} {
if got := vg.Before(name); !reflect.DeepEqual(got, want) {
t.Errorf("%s waits for %v, want %v", name, got, want)
}
}
if b, s := vg["builder"], vg["store"]; !reflect.DeepEqual(b.LeftOut, []string{"seat:artifact-store.remove"}) ||
!reflect.DeepEqual(s.LeftOut, []string{"builder.builder_versions"}) ||
!reflect.DeepEqual(s.Serves, []string{"seat:artifact-store.remove"}) || !reflect.DeepEqual(s.Invokes, []string{"builder.builder_versions"}) {
t.Errorf("the builder's cycle is not left out from either side: %+v %+v", b, s)
}
if k := vg["keeper"]; !reflect.DeepEqual(k.Requires, []string{BusState}) || !reflect.DeepEqual(k.Awaits, []string{BusState}) {
t.Errorf("a bus-state entry is no requirement of bus-state: %+v", k)
}
if c := vg["caller"]; !reflect.DeepEqual(c.LeftOut, []string{"caller.own"}) || vg.Cycle() != nil {
t.Errorf("a module's own tool is a wait, or a left-out verb edge a cycle: %+v %v", c, vg.Cycle())
}
if got := OfManifests(map[string]catalogue.Manifest{"keeper": verbs["keeper"]})["keeper"]; got.Awaits != nil {
t.Errorf("bus-state with no module offering it is awaited: %+v", got)
}
// The words of a want held on a verb or a reader capability name their edge, and no other words do.
for words, want := range map[string]string{
VerbWaitWords("seat:mesh-catalog.modules", "artifact-store-tools", "g14"): "verb seat:mesh-catalog.modules",
ReaderWaitWords("g14", []string{"tool-runner", "bus-state"}, "node-tools", "src2:abcdef"): "reader capability tool-runner, bus-state",
BusStateWaitWords("keeper", "g14"): "bus state",
"the verb is served": "",
"the node-engine of g14 has not started": "",
} {
if got := EdgeOfWords(words); got != want {
t.Errorf("the edge of %q = %q, want %q", words, got, want)
}
}
for name, c := range map[string]struct {
edit func(Graph)
want []string
@@ -115,6 +161,19 @@ func TestGraphEdges(t *testing.T) {
w.Provides = []string{"site"}
g["web"] = w
}, []string{"db", "web", "db"}},
"a package cycle orders nothing": {func(g Graph) {
l := g["lib"]
l.Packages = []string{"git.novox.be/novox/mesh-catalog/modules/app"}
g["lib"] = l
}, nil},
"a verb edge with a provision, before LeaveOut leaves it out": {func(g Graph) {
d := g["db"]
d.Invokes = []string{"web.page"}
g["db"] = d
w := g["web"]
w.Serves = []string{"web.page"}
g["web"] = w
}, []string{"db", "web", "db"}},
"a requirement not awaited is no edge": {func(g Graph) {
d := g["db"]
d.Requires = []string{"site"}
@@ -132,6 +191,9 @@ func TestGraphEdges(t *testing.T) {
if got := h.Cycle(); !reflect.DeepEqual(got, c.want) {
t.Errorf("%s: cycle = %v, want %v", name, got, c.want)
}
if h.LeaveOut(); h.Cycle() != nil && slices.ContainsFunc(slices.Collect(maps.Values(h)), func(n Node) bool { return len(n.Invokes) > 0 }) {
t.Errorf("%s: a cycle through a verb edge is left once its verb edges are left out: %v", name, h.Cycle())
}
}
}
@@ -140,7 +202,9 @@ func sampleNode() Node {
return Node{FP: "src2:" + strings.Repeat("a", 64), Repository: "novox/mesh-catalog", Path: "modules/gitea",
Bases: []string{"z-base", "a-base"}, Packages: []string{"git.novox.be/novox/mesh-sdk/go"},
Toolchains: map[string]Toolchain{"go": {Module: "build-go", Version: "go1.27.1", Recipe: "3"}},
Provides: []string{"forge"}, Requires: []string{"postgres-database", "dns"}, Awaits: []string{"postgres-database"}}
Provides: []string{"forge"}, Requires: []string{"postgres-database", "dns"}, Awaits: []string{"postgres-database"},
Invokes: []string{"seat:mesh-catalog.modules", "builder.builder_versions"}, Serves: []string{"seat:forge.pulls"},
LeftOut: []string{"builder.builder_versions"}}
}
// nodeValues: a node round-trips with its lists sorted, an unknown version is refused, a node that does not hold is
@@ -169,15 +233,17 @@ func nodeValues(t *testing.T) {
change func(*Node)
word string
}{
"both": {func(g *Node) { g.None = "why" }, "both"},
"neither": {func(g *Node) { g.FP = "" }, "neither"},
"an old form": {func(g *Node) { g.FP = "src1:x" }, "src2:"},
"a dotted base": {func(g *Node) { g.Bases = []string{"a.b"} }, "a.b"},
"a foreign package": {func(g *Node) { g.Packages = []string{"example.com/x"} }, "example.com/x"},
"a toolchain of no module": {func(g *Node) { g.Toolchains = map[string]Toolchain{"go": {Version: "go1", Recipe: "1"}} }, "build module"},
"a toolchain of no recipe": {func(g *Node) { g.Toolchains = map[string]Toolchain{"go": {Module: "b", Version: "go1"}} }, "recipe"},
"awaiting what it does not require": {func(g *Node) { g.Awaits = []string{"other"} }, "other"},
"no repository": {func(g *Node) { g.Repository = "" }, "repositor"},
"both": {func(g *Node) { g.None = "why" }, "both"},
"neither": {func(g *Node) { g.FP = "" }, "neither"},
"an old form": {func(g *Node) { g.FP = "src1:x" }, "src2:"},
"a dotted base": {func(g *Node) { g.Bases = []string{"a.b"} }, "a.b"},
"a foreign package": {func(g *Node) { g.Packages = []string{"example.com/x"} }, "example.com/x"},
"a toolchain of no module": {func(g *Node) { g.Toolchains = map[string]Toolchain{"go": {Version: "go1", Recipe: "1"}} }, "build module"},
"a toolchain of no recipe": {func(g *Node) { g.Toolchains = map[string]Toolchain{"go": {Module: "b", Version: "go1"}} }, "recipe"},
"awaiting what it does not require": {func(g *Node) { g.Awaits = []string{"other"} }, "other"},
"no repository": {func(g *Node) { g.Repository = "" }, "repositor"},
"leaving out what it does not invoke": {func(g *Node) { g.LeftOut = []string{"x.y"} }, "x.y"},
"an invoke of no verb": {func(g *Node) { g.Invokes = []string{"seat:nothing"} }, "seat:nothing"},
} {
g := sampleNode()
c.change(&g)
+61
View File
@@ -0,0 +1,61 @@
package graph
import (
"fmt"
"strings"
)
// The words of a want held on an edge of ADR 0614 (novox/hq ADR 0614 §2, §4, §5): no reader knows a rule number of its own for
// either yet, so the assigner holds them under the provisions' and the composition's rules, and these words are the one
// place they are written and read. The assigner writes them (VerbWaitWords, ReaderWaitWords, BusStateWaitWords); the delivery view names
// the edge a want waits on from them (EdgeOfWords).
const (
verbOpen = "the verb "
verbClose = " invokes is served by no running build"
readerOpen = "the node-engine of "
readerMid = " does not read "
readerTail = ", which the manifest of "
busOpen = "bus state: "
)
// BusStateWaitWords are the words of a want of module on node held until a build of a module offering bus-state runs
// healthy on a node present (ADR 0614 §5, through ADR 0603 §5's count of the provider's own have).
func BusStateWaitWords(module, node string) string {
return fmt.Sprintf("%s%s asks bus state, and no build of a module offering %s runs healthy on a node present: its want on %s waits for one (ADR 0614 §5)",
busOpen, module, BusState, node)
}
// VerbWaitWords are the words of a want of module on node held until a build serving verb runs healthy (ADR 0614 §2).
func VerbWaitWords(verb, module, node string) string {
return fmt.Sprintf("%s%s %s%s: its want on %s waits until a build serving it runs healthy (ADR 0614 §2)", verbOpen, verb,
module, verbClose, node)
}
// ReaderWaitWords are the words of a want of module at fp held on node until its node-engine states the reader
// capabilities missing (ADR 0614 §4).
func ReaderWaitWords(node string, missing []string, module, fp string) string {
return fmt.Sprintf("%s%s%s%s%s%s at %s needs: the want moves once that engine states it", readerOpen, node, readerMid,
strings.Join(missing, ", "), readerTail, module, fp)
}
// EdgeOfWords is the edge a held want's words name: `verb <verb>` for a verb wait, `reader capability <capabilities>` for
// a reader capability's hold, `bus state` for a bus-state wait, or "" for words of none of them.
func EdgeOfWords(words string) string {
if strings.HasPrefix(words, busOpen) {
return "bus state"
}
if rest, ok := strings.CutPrefix(words, verbOpen); ok {
if verb, _, ok := strings.Cut(rest, " "); ok && strings.Contains(rest, verbClose) {
return "verb " + verb
}
}
if rest, ok := strings.CutPrefix(words, readerOpen); ok {
if _, rest, ok := strings.Cut(rest, readerMid); ok {
if missing, _, ok := strings.Cut(rest, readerTail); ok {
return "reader capability " + missing
}
}
}
return ""
}
-16
View File
@@ -1,16 +0,0 @@
// Package toolrunner says whether a manifest is the node's tool runner, for the parts of mesh-delivery that judge or
// compose one (the indexer's judge, mesh-grants).
package toolrunner
import "git.novox.be/novox/mesh-sdk/go/catalogue"
// Is says whether a manifest is the node's tool runner: it says so (`tool-runner`, catalogue.IsToolRunner), or its module
// is mesh-sdk's deprecated catalogue.RuntimeModule.
//
// The second clause is kept only while node-tools' manifest on mesh-tools' trunk does not say `tool-runner`: the running
// indexer refuses that word, so node-tools' trunk manifest leaves it out until the indexer that knows it is delivered
// (issue 774, issue 1585 note 42). It goes, with this helper's use of catalogue.RuntimeModule, once `tool-runner` is
// back on node-tools' trunk manifest; then every caller asks catalogue.IsToolRunner alone.
func Is(m catalogue.Manifest) bool {
return catalogue.IsToolRunner(m) || m.Module == catalogue.RuntimeModule
}