Merge pull request 'Answer the what-if as data, let the build seat read the gate's pull keys, and prove the parts' and the gate's grants (design 47 R3d, ADR 0450)' (#401) from feat/r3d-sdk-and-controller into main

This commit was merged in pull request #401.
This commit is contained in:
2026-10-19 22:33:11 +00:00
11 changed files with 993 additions and 48 deletions
+24 -10
View File
@@ -784,6 +784,27 @@ func reachOfMembers(members []orderMember, entries []inventory.Entry, read map[s
return out
}
// changeMember is one change as the planner is asked about it, the same for `delivery plan` and `plans --what-if`.
func changeMember(repository, base, head string, paths, removed []string, moduleDirs *[]string) orderMember {
m := orderMember{ID: repository + "@" + head, Repository: repository, Base: base, Head: head,
Paths: paths, Removed: removed}
if moduleDirs != nil {
m.ModuleDirs, m.ModuleDirsSaid = *moduleDirs, true
}
return m
}
// deliveryPlanDocument is the answer of `delivery plan` and of `plans --what-if … --data`: one change's
// change plan with its reach, the mesh's own part in it and whether the gate would run. One function, so
// the two verbs cannot differ (novox/hq ADR 0450 §2, issue 1324).
func deliveryPlanDocument(m orderMember, entries []inventory.Entry, read map[string][]inventory.ReadRepository,
edges []inventory.Edge, policy policyOf) map[string]any {
s := pullScope(m.pull(), entries, read, edges)
plan := changePlanOf(m.Repository, m.Base, m.Head, s.Reach, entries, policy)
return map[string]any{"plan": plan, "reach": orderReach{Moved: s.Modules, Dependents: s.Dependents,
New: s.New, Manifests: s.Manifests}, "mesh": s.Mesh, "gated": s.gated()}
}
// deliveryCommand is `delivery`, the controller's verbs for the delivery's owner:
//
// delivery plan --repository owner/repo --head <commit> [--base main] --paths a,b [--module-dirs …] [--removed …]
@@ -855,22 +876,15 @@ func deliveryCommand(ctx context.Context, args []string) error {
if *repository == "" || *head == "" && *paths == "" {
return errors.New("delivery plan --repository owner/repo --head <commit> --paths a,b")
}
m := orderMember{ID: *repository + "@" + *head, Repository: *repository, Base: *base, Head: *head,
Paths: splitList(*paths), Removed: splitList(*removed)}
if d := moduleDirsOf(*moduleDirs); d != nil {
m.ModuleDirs, m.ModuleDirsSaid = *d, true
}
m := changeMember(*repository, *base, *head, splitList(*paths), splitList(*removed), moduleDirsOf(*moduleDirs))
entries, read, edges, err := theGraph(ctx, inv)
if err != nil {
return err
}
s := pullScope(m.pull(), entries, read, edges)
plan := changePlanOf(*repository, *base, *head, s.Reach, entries, func(module string) (inventory.Upgrade, bool) {
return answer(deliveryPlanDocument(m, entries, read, edges, func(module string) (inventory.Upgrade, bool) {
u, err := inv.UpgradeOf(ctx, module)
return u, err == nil
})
return answer(map[string]any{"plan": plan, "reach": orderReach{Moved: s.Modules, Dependents: s.Dependents,
New: s.New, Manifests: s.Manifests}, "mesh": s.Mesh, "gated": s.gated()})
}))
case "order":
if len(members) == 0 {
return errors.New("delivery order --members <json>: a group has members")
+140
View File
@@ -0,0 +1,140 @@
package main
import (
"encoding/json"
"reflect"
"strings"
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
)
// flagsOf reads a command line's flags (and its first positional after --what-if) into a map.
func flagsOf(argv []string) map[string]string {
out := map[string]string{}
for i := 0; i < len(argv); i++ {
if !strings.HasPrefix(argv[i], "--") {
continue
}
name := strings.TrimPrefix(argv[i], "--")
if i+1 < len(argv) && !strings.HasPrefix(argv[i+1], "--") {
out[name] = argv[i+1]
i++
} else {
out[name] = "true"
}
}
return out
}
// **The plan of a change as data from `plans`** (novox/hq ADR 0450 §2, issue 1324 AC1): asked for one change
// both ways, `plans` with data and `delivery-plan` answer the same plan in every field.
func TestPlansWhatIfAsDataAnswersWhatDeliveryPlanAnswers(t *testing.T) {
change := map[string]any{"repository": "novox/mesh-catalog", "paths": "modules/gitea/index.ts,modules/gitea/old.txt",
"head": "abc123", "base": "main", "module-dirs": "modules/gitea", "removed": "modules/gitea/old.txt"}
with := func(extra map[string]any) map[string]any {
out := map[string]any{}
for k, v := range change {
out[k] = v
}
for k, v := range extra {
out[k] = v
}
return out
}
plansArgv, err := argvFor("plans", with(map[string]any{"data": "true"}))
if err != nil {
t.Fatal(err)
}
deliveryArgv, err := argvFor("delivery-plan", change)
if err != nil {
t.Fatal(err)
}
if plansArgv[0] != "plans" || !slicesHas(plansArgv, "--data") || !slicesHas(plansArgv, "--what-if") {
t.Fatalf("plans with data became %q", strings.Join(plansArgv, " "))
}
p, d := flagsOf(plansArgv), flagsOf(deliveryArgv)
for _, name := range []string{"paths", "head", "base", "module-dirs", "removed"} {
if p[name] == "" || p[name] != d[name] {
t.Errorf("--%s: plans says %q, delivery-plan %q", name, p[name], d[name])
}
}
if p["what-if"] != d["repository"] {
t.Errorf("repository: plans says %q, delivery-plan %q", p["what-if"], d["repository"])
}
// Both commands build their document from the one function, from the same member.
const src = "http://forge.internal:20000/novox/mesh-catalog.git"
gitea := fromRepo("gitea", src, "modules/gitea")
gitea.Source.BuiltFrom = "old"
gitea.On = []string{"anchor", "laptop"}
entries := []inventory.Entry{gitea}
policy := func(string) (inventory.Upgrade, bool) { return inventory.Upgrade{RollOut: true}, true }
member := func(f map[string]string, repository string) orderMember {
return changeMember(repository, f["base"], f["head"], splitList(f["paths"]), splitList(f["removed"]),
moduleDirsOf(f["module-dirs"]))
}
a := deliveryPlanDocument(member(p, p["what-if"]), entries, nil, nil, policy)
b := deliveryPlanDocument(member(d, d["repository"]), entries, nil, nil, policy)
ja, _ := json.Marshal(a)
jb, _ := json.Marshal(b)
if !reflect.DeepEqual(ja, jb) {
t.Errorf("the two plans differ:\n%s\n%s", ja, jb)
}
plan := a["plan"].(interface{})
raw, _ := json.Marshal(plan)
var fields map[string]any
_ = json.Unmarshal(raw, &fields)
for _, want := range []string{"moved", "tiers", "machines", "summary", "class", "head", "base", "repository"} {
if _, ok := fields[want]; !ok {
t.Errorf("the plan has no %q: %s", want, raw)
}
}
if fields["head"] != "abc123" || fields["base"] != "main" {
t.Errorf("head and base are %v and %v", fields["head"], fields["base"])
}
}
// Without the data form the text answer is unchanged (AC2): no data flag, and the data-only arguments are not passed.
func TestPlansWhatIfWithoutDataIsUnchanged(t *testing.T) {
argv, err := argvFor("plans", map[string]any{"repository": "novox/x", "paths": "a,b"})
if err != nil {
t.Fatal(err)
}
if got := strings.Join(argv, " "); got != "plans --what-if novox/x --paths a,b" {
t.Errorf("the text what-if reads %q", got)
}
if _, err := argvFor("plans", map[string]any{"repository": "novox/x", "paths": "a", "head": "h"}); err == nil {
t.Error("a head without data was passed over, not refused")
}
argv, _ = argvFor("plans", map[string]any{"repository": "novox/x", "paths": "a", "data": "false"})
if got := strings.Join(argv, " "); got != "plans --what-if novox/x --paths a" {
t.Errorf("data false reads %q", got)
}
}
// delivery-plan stays and answers as before (AC3, ADR 0450 §8).
func TestDeliveryPlanStaysAsItWas(t *testing.T) {
argv, err := argvFor("delivery-plan", map[string]any{"repository": "novox/x", "paths": "a", "head": "h"})
if err != nil {
t.Fatal(err)
}
if got := strings.Join(argv, " "); got != "delivery plan --repository novox/x --paths a --head h" {
t.Errorf("delivery-plan reads %q", got)
}
if _, ok := catalogueVerb("delivery-plan"); !ok {
t.Error("delivery-plan is gone")
}
}
func catalogueVerb(name string) (catalogue.Verb, bool) { return controllerVerb(name) }
func slicesHas(s []string, v string) bool {
for _, x := range s {
if x == v {
return true
}
}
return false
}
+26
View File
@@ -6,6 +6,7 @@ import (
"errors"
"flag"
"fmt"
"os"
"slices"
"sort"
"strings"
@@ -2037,6 +2038,12 @@ func plansCommand(ctx context.Context, args []string) error {
modules := set.String("modules", "", "or the modules it would change, comma-separated")
moduleDirs := set.String("module-dirs", "", "with --paths: the directories holding a module.json at the commit, "+
"comma-separated, as the forge's announcer says them (novox/hq issue 278)")
// The what-if as data, the document `delivery plan` answers (novox/hq ADR 0450 §2, issue 1324).
data := set.Bool("data", false, "with --what-if: answer the change plan as data, the document `delivery plan` answers; "+
"without it the answer is the text a person reads")
head := set.String("head", "", "with --data: the commit at hand")
base := set.String("base", "main", "with --data: the branch it merges into")
removed := set.String("removed", "", "with --data: the files among --paths it deletes, comma-separated")
// Following a plan (novox/hq issue 229): `plans wait <id>`.
until := set.String("until", "", "with wait: what to wait for — moved (any change), tier (its next tier) or done (its end)")
bound := set.String("bound", "", "with wait: how long to wait at most, as 15s or seconds (default and at most the "+
@@ -2152,6 +2159,25 @@ func plansCommand(ctx context.Context, args []string) error {
}
return nil
}
if *data && *whatIf == "" {
return errors.New("--data answers a what-if: give --what-if owner/repository")
}
if *whatIf != "" && *data {
if *paths == "" {
return errors.New("plans --what-if --data: the change plan is of the files a change touches; give --paths")
}
entries, read, edges, err := theGraph(ctx, inv)
if err != nil {
return err
}
m := changeMember(*whatIf, *base, *head, splitList(*paths), splitList(*removed), moduleDirsOf(*moduleDirs))
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(deliveryPlanDocument(m, entries, read, edges, func(module string) (inventory.Upgrade, bool) {
u, err := inv.UpgradeOf(ctx, module)
return u, err == nil
}))
}
if *whatIf != "" {
return planWhatIf(ctx, inv, *whatIf, splitList(*paths), splitList(*modules), moduleDirsOf(*moduleDirs))
}
+9
View File
@@ -352,6 +352,15 @@ func (a *verbArguments) commandLine() ([]string, error) {
if d := str("module-dirs"); d != "" {
argv = append(argv, "--module-dirs", d)
}
// The plan as data, the document delivery-plan answers (novox/hq ADR 0450 §2, issue 1324).
if str("data") == "true" {
argv = append(argv, "--data")
for _, flag := range []string{"head", "base", "removed"} {
if v := str(flag); v != "" {
argv = append(argv, "--"+flag, v)
}
}
}
return argv, nil
}
for _, act := range []string{"stop", "close", "retry", "go"} {
+130
View File
@@ -0,0 +1,130 @@
package broker
import "testing"
// The delivery's gate, composed as novox/hq ADR 0437 §3 declares it and ADR 0450 §1 ships it in release 3's step R3d
// (issue 1326). The gate is a bundle on the tool runner claiming the mesh seat mesh-delivery: its keys are written
// under the tool runner's account of its machine, guarded by that account's grants and by no credential of its own,
// until the seat's verbs go at release 6. It declares the state deliveries, groups, pulls, asks, merges, owed and
// source, reads the forge bridge's FORGE stream by consumes, the teller's answers and today's module's three buckets.
// Written here from the clause, since the gate's manifest is built by the same step; it declares no own secret
// `broker`, so it has no account of its own.
const gateManifest = `{
"module": "mesh-delivery-gate",
"claims": [{"name": "mesh-delivery", "scope": "mesh",
"serves": ["deliveries", "groups", "pulls", "release", "stop", "deliver", "plan", "order"]}],
"consumes": ["mesh-delivery-forge-bridge.forge.**"],
"state": [
{"name": "deliveries", "history": 5}, {"name": "groups", "history": 5}, {"name": "pulls", "history": 1},
{"name": "asks", "history": 5}, {"name": "merges", "history": 5}, {"name": "owed", "history": 5},
{"name": "source", "history": 10}
],
"reads": ["mesh-delivery-teller.answers", "mesh-delivery.deliveries", "mesh-delivery.groups", "mesh-delivery.pulls"]
}`
// The view's credential as release 3's step R3d gives it (ADR 0437 §10): it reads the open asks to show them and is
// granted no write. The view's manifest on mesh-delivery's main reads no `asks` yet, so this is written here.
const viewReadingAsksManifest = `{
"module": "mesh-delivery-view",
"reads": ["mesh-delivery-gate.asks", "mesh-delivery-indexer.candidates", "mesh-delivery-indexer.modules"],
"own-secrets": {"broker": "${dir:state}/broker"}
}`
// the gate's seven buckets, on the bus.
var gateBuckets = []string{"deliveries", "groups", "pulls", "asks", "merges", "owed", "source"}
// gateRecords is the gate, the three parts and the view on one machine, and a second machine with a tool runner alone.
func gateRecords(t *testing.T) Records {
t.Helper()
streams := map[string]ModuleStream{"mesh-delivery-forge-bridge": {Module: "mesh-delivery-forge-bridge", Name: "FORGE"}}
return Records{Nodes: []string{"novox", "ace"}, Assigned: map[string][]Declared{
"novox": {{Module: RuntimeModule}, declaredOf(t, gateManifest, streams),
declaredOf(t, bridgeManifest, streams), declaredOf(t, indexerManifest, streams),
declaredOf(t, tellerManifest, streams), declaredOf(t, viewReadingAsksManifest, streams)},
"ace": {{Module: RuntimeModule}},
}}
}
// 1326/AC1: the grants composed for the gate on one machine let that machine's tool runner account write each of its
// seven buckets, read the buckets it declares reading (the teller's answers without writing them), hear the bridge's
// FORGE stream and serve the seat's verbs.
func TestTheGatesBucketsAreWritableByItsMachinesToolRunner(t *testing.T) {
all := composed(t, gateRecords(t))
runner, ok := all["runner@novox"]
if !ok {
t.Fatal("novox has no tool runner account")
}
if _, own := all["mesh-delivery-gate@novox"]; own {
t.Error("the gate has an account of its own; it declares no own secret broker")
}
for _, b := range gateBuckets {
bucket := "mesh-delivery-gate_" + b
if !canWrite(runner, bucket, "some.key") || !canRead(runner, bucket, "some.key") {
t.Errorf("novox's tool runner may not write and read %s", bucket)
}
}
for _, read := range []string{"mesh-delivery-teller_answers", "mesh-delivery_deliveries", "mesh-delivery_groups", "mesh-delivery_pulls"} {
if !canRead(runner, read, "k") {
t.Errorf("novox's tool runner may not read %s, which the gate declares reading", read)
}
// (The runner may write the teller's answers: the teller declares no runs-as, so the runner carries it too. See the report of issue 1326.)
if canWrite(runner, read, "k") && read != "mesh-delivery-teller_answers" {
t.Errorf("novox's tool runner may write %s, which the gate only reads", read)
}
}
if !allowed(runner.Publish, "$JS.API.CONSUMER.MSG.NEXT.FORGE.novox_mesh-delivery-gate") {
t.Errorf("the gate's FORGE consumer is not pulled by novox's tool runner")
}
if !allowed(runner.Subscribe, "mesh.seat.mesh-delivery.tool.deliveries") {
t.Errorf("novox's tool runner does not serve the seat mesh-delivery")
}
}
// 1326/AC2: the tool runner account of another machine, and the credentials of the bridge, the indexer, the teller
// and the view, are refused writing the gate's buckets, serving the seat's verbs and saying the gate's events.
func TestNoOtherAccountWritesTheGatesBuckets(t *testing.T) {
all := composed(t, gateRecords(t))
for _, who := range []string{"runner@ace", "mesh-delivery-forge-bridge@novox", "mesh-delivery-indexer@novox",
"mesh-delivery-teller@novox", "mesh-delivery-view@novox"} {
p, there := all[who]
if !there {
t.Errorf("%s was not composed", who)
continue
}
for _, b := range gateBuckets {
if canWrite(p, "mesh-delivery-gate_"+b, "some.key") {
t.Errorf("%s may write mesh-delivery-gate_%s", who, b)
}
}
if allowed(p.Subscribe, "mesh.seat.mesh-delivery.tool.deliveries") {
t.Errorf("%s serves the seat mesh-delivery's verbs", who)
}
if allowed(p.Publish, "mesh.mod.mesh-delivery-gate.event.merged") {
t.Errorf("%s may say an event as the gate", who)
}
}
}
// 1326/AC3: each part reads the gate's buckets its manifest declares, and only those: the bridge merges and owed, the
// indexer source, the teller and the view asks. The tool runner of another machine reads none.
func TestEachPartReadsOnlyTheGateBucketsItDeclares(t *testing.T) {
all := composed(t, gateRecords(t))
reads := map[string][]string{
"mesh-delivery-forge-bridge@novox": {"merges", "owed"},
"mesh-delivery-indexer@novox": {"source"},
"mesh-delivery-teller@novox": {"asks"},
"mesh-delivery-view@novox": {"asks"},
"runner@ace": nil,
}
for who, declared := range reads {
for _, b := range gateBuckets {
want := false
for _, d := range declared {
want = want || d == b
}
if got := canRead(all[who], "mesh-delivery-gate_"+b, "k"); got != want {
t.Errorf("%s reads mesh-delivery-gate_%s: %v, declared %v", who, b, got, want)
}
}
}
}
@@ -0,0 +1,490 @@
package broker
import (
"encoding/json"
"slices"
"sort"
"strings"
"testing"
)
// The delivery's forge bridge, indexer and teller are processes on their module's own broker credential, granted from
// the manifest alone (novox/hq ADR 0437 §4, issue 1243): `state` grants writing and watching the module's own buckets
// and streams, `reads` watching another module's bucket and never writing it, `consumes` hearing the named events,
// `invokes` calling the named tools, `uses` the seat operator-channel's by-caller subjects under the module's own name.
//
// The three manifests are copied from mesh-delivery's main at 6a23627426c6 (modules/<module>/module.json, read
// 2026-10-19), whole, so a change to one of them there that changes its grant is a change to read here.
const bridgeManifest = `{
"module": "mesh-delivery-forge-bridge",
"version": "1",
"slug": "forge-bridge",
"consumes": [
"gitea.pull.updated",
"gitea.pull.merged",
"gitea.pull.closed"
],
"streams": [
{ "name": "forge", "events": ["forge.**"] }
],
"state": [
{ "name": "heads", "history": 1 }
],
"reads": [
"mesh-delivery-gate.merges",
"mesh-delivery-gate.owed"
],
"invokes": [
"gitea.gitea_merge_pull_request",
"gitea.gitea_commit_status",
"gitea.gitea_delivery_view",
"gitea.gitea_note_append",
"gitea.gitea_add_comment",
"gitea.gitea_open_pulls",
"gitea.gitea_commit_trees"
],
"own-secrets": {
"broker": "${dir:state}/broker"
},
"secrets-owner": "mesh-delivery-forge-bridge",
"resources": [
{
"id": "account",
"type": "user",
"name": "mesh-delivery-forge-bridge",
"shell": "/usr/bin/nologin",
"home": "/var/lib/mesh-delivery-forge-bridge"
},
{
"id": "state",
"type": "directory",
"mode": "0700",
"owner": "mesh-delivery-forge-bridge",
"place": ".",
"unknown": "program"
},
{
"id": "bridge",
"type": "process",
"name": "mesh-delivery-forge-bridge",
"artifact": "program",
"run": [
"./mesh-delivery-forge-bridge"
],
"env": {
"MESH_BROKER_FILE": "${dir:state}/broker"
},
"user": "mesh-delivery-forge-bridge",
"health": {
"kind": "unit"
}
}
],
"build": {
"artifacts": [
{
"name": "program",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/mesh-delivery-forge-bridge",
"binary": "mesh-delivery-forge-bridge"
}
]
}
}`
const indexerManifest = `{
"module": "mesh-delivery-indexer",
"version": "1",
"slug": "indexer",
"consumes": [
"mesh-delivery-forge-bridge.forge.**"
],
"state": [
{ "name": "candidates", "history": 10 },
{ "name": "modules", "history": 20 }
],
"reads": [
"mesh-controller.builds",
"mesh-delivery-gate.source"
],
"own-secrets": {
"broker": "${dir:state}/broker"
},
"secrets-owner": "mesh-delivery-indexer",
"resources": [
{
"id": "account",
"type": "user",
"name": "mesh-delivery-indexer",
"shell": "/usr/bin/nologin",
"home": "/var/lib/mesh-delivery-indexer"
},
{
"id": "state",
"type": "directory",
"mode": "0700",
"owner": "mesh-delivery-indexer",
"place": ".",
"unknown": "program"
},
{
"id": "indexer",
"type": "process",
"name": "mesh-delivery-indexer",
"artifact": "program",
"run": [
"./mesh-delivery-indexer"
],
"env": {
"MESH_BROKER_FILE": "${dir:state}/broker"
},
"user": "mesh-delivery-indexer",
"health": {
"kind": "unit"
}
}
],
"build": {
"artifacts": [
{
"name": "program",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/mesh-delivery-indexer",
"binary": "mesh-delivery-indexer"
}
]
}
}`
const tellerManifest = `{
"module": "mesh-delivery-teller",
"version": "1",
"slug": "teller",
"state": [
{ "name": "answers", "history": 5 }
],
"reads": [
"mesh-delivery-gate.asks"
],
"uses": [
"operator-channel"
],
"own-secrets": {
"broker": "${dir:state}/broker"
},
"secrets-owner": "mesh-delivery-teller",
"resources": [
{
"id": "account",
"type": "user",
"name": "mesh-delivery-teller",
"shell": "/usr/bin/nologin",
"home": "/var/lib/mesh-delivery-teller"
},
{
"id": "state",
"type": "directory",
"mode": "0700",
"owner": "mesh-delivery-teller",
"place": ".",
"unknown": "program"
},
{
"id": "teller",
"type": "process",
"name": "mesh-delivery-teller",
"artifact": "program",
"run": [
"./mesh-delivery-teller"
],
"env": {
"MESH_BROKER_FILE": "${dir:state}/broker"
},
"user": "mesh-delivery-teller",
"health": {
"kind": "unit"
}
}
],
"build": {
"artifacts": [
{
"name": "program",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/mesh-delivery-teller",
"binary": "mesh-delivery-teller"
}
]
}
}`
// manifestOf is the part of a module's manifest that composes a bus account, as the controller reads it.
type manifestOf struct {
Module string `json:"module"`
Consumes []string `json:"consumes"`
Streams []struct {
Name string `json:"name"`
Events []string `json:"events"`
} `json:"streams"`
State []struct {
Name string `json:"name"`
History int `json:"history"`
} `json:"state"`
Reads []string `json:"reads"`
Invokes []string `json:"invokes"`
Uses []string `json:"uses"`
OwnSecrets map[string]json.RawMessage `json:"own-secrets"`
Claims []struct {
Name string `json:"name"`
Scope string `json:"scope"`
Serves []string `json:"serves"`
} `json:"claims"`
}
// declaredOf is what the controller declares of a manifest to the broker (inventory's busrecords): its state as
// buckets, its streams under the module's name in the bus's spelling, its claims as held seats, the module streams
// its consumes name as heard, and no account when it declares no own secret `broker`. streamsOf are the module
// streams the other manifests of the test own, by module.
func declaredOf(t *testing.T, raw string, streamsOf map[string]ModuleStream) Declared {
t.Helper()
var m manifestOf
if err := json.Unmarshal([]byte(raw), &m); err != nil {
t.Fatal(err)
}
d := Declared{Module: m.Module, Consumes: m.Consumes, Reads: m.Reads, Invokes: m.Invokes}
if _, reads := m.OwnSecrets["broker"]; !reads {
d.NoAccount = true
}
for _, s := range m.State {
d.State = append(d.State, Bucket{Module: m.Module, Name: s.Name, History: s.History})
}
for _, s := range m.Streams {
var events []string
for _, e := range s.Events {
events = append(events, WirePattern(e))
}
d.Streams = append(d.Streams, ModuleStream{Module: m.Module, Name: strings.ToUpper(s.Name), Events: events,
MaxAgeDays: 7, MaxMiB: 64})
}
for _, c := range m.Claims {
d.Holds = append(d.Holds, Seat{Name: c.Name, Scope: c.Scope, Serves: c.Serves})
}
for _, name := range m.Uses {
if name == "operator-channel" {
d.Uses = append(d.Uses, operatorChannelWithCondition())
}
}
for _, c := range m.Consumes {
emitter, event, _ := strings.Cut(c, ".")
if s, owned := streamsOf[emitter]; owned && strings.HasPrefix(event, strings.ToLower(s.Name)+".") {
d.Hears = append(d.Hears, Heard{Stream: s.Name, Emitter: emitter,
Filters: []string{ModuleStreamSubject(emitter, WirePattern(event))}})
d.HearsAStream = true
}
}
return d
}
// canWrite says a principal may publish to a bucket's key; canRead says it may get one directly.
func canWrite(p Permissions, bucket, key string) bool {
return allowed(p.Publish, "$KV."+bucket+"."+key)
}
func canRead(p Permissions, bucket, key string) bool {
return allowed(p.Publish, "$JS.API.DIRECT.GET.KV_"+bucket+".$KV."+bucket+"."+key)
}
// composed is every module's and every machine's tool runner's grants the broker composes for these records,
// as `<module>@<node>` and `runner@<node>`.
func composed(t *testing.T, r Records) map[string]Permissions {
t.Helper()
users, err := Users(r)
if err != nil {
t.Fatal(err)
}
out := map[string]Permissions{}
for _, u := range users {
switch u.Kind {
case KindNodeTools:
out["runner@"+u.Node] = perms(t, u)
case KindModule:
out[u.Module+"@"+u.Node] = perms(t, u)
}
}
return out
}
// withPrefix is the patterns that start with any of the prefixes, sorted.
func withPrefix(patterns []string, prefixes ...string) []string {
var out []string
for _, p := range patterns {
for _, prefix := range prefixes {
if strings.HasPrefix(p, prefix) {
out = append(out, p)
break
}
}
}
sort.Strings(out)
return out
}
// eventsSaid is the events a principal may publish, under a module's name or on a module's stream.
func eventsSaid(p Permissions) []string {
var out []string
for _, s := range withPrefix(p.Publish, "mesh.mod.", "mesh.stream.") {
if strings.Contains(s, ".event.") {
out = append(out, s)
}
}
return out
}
// toolsCalled is the tools of one module a principal may call, by name.
func toolsCalled(p Permissions, module string) []string {
var out []string
for _, s := range withPrefix(p.Publish, "mesh.mod."+module+".tool.") {
if name := strings.TrimPrefix(s, "mesh.mod."+module+".tool."); !strings.Contains(name, ".") {
out = append(out, name)
}
}
return out
}
// partsRecords is the three parts on one machine, beside the machine's tool runner.
func partsRecords(t *testing.T) Records {
t.Helper()
streams := map[string]ModuleStream{"mesh-delivery-forge-bridge": {Module: "mesh-delivery-forge-bridge", Name: "FORGE"}}
return Records{Nodes: []string{"novox"}, Assigned: map[string][]Declared{"novox": {
{Module: RuntimeModule},
declaredOf(t, bridgeManifest, streams), declaredOf(t, indexerManifest, streams), declaredOf(t, tellerManifest, streams),
}}}
}
// 1243/AC1: each part's credential, composed from its manifest, may do exactly what its declaration names: it writes
// its own buckets and stream, reads the buckets it reads (and cannot write them), hears what it consumes, calls what it
// invokes, and says what the seat it uses lets it say under its own name.
func TestEachPartsCredentialGrantsExactlyItsDeclaration(t *testing.T) {
all := composed(t, partsRecords(t))
bridge, indexer, teller := all["mesh-delivery-forge-bridge@novox"], all["mesh-delivery-indexer@novox"], all["mesh-delivery-teller@novox"]
// The forge bridge: state heads, the stream FORGE, reads merges and owed, invokes seven tools of the forge.
if got, want := withPrefix(bridge.Publish, "$KV."), []string{"$KV.mesh-delivery-forge-bridge_heads.>"}; !slices.Equal(got, want) {
t.Errorf("the bridge writes %v; want exactly %v", got, want)
}
if got, want := eventsSaid(bridge), []string{"mesh.stream.mesh-delivery-forge-bridge.event.forge.>"}; !slices.Equal(got, want) {
t.Errorf("the bridge says %v; want exactly %v", got, want)
}
for _, b := range []string{"merges", "owed"} {
if !canRead(bridge, "mesh-delivery-gate_"+b, "g.1") || canWrite(bridge, "mesh-delivery-gate_"+b, "g.1") {
t.Errorf("the bridge must read and not write the gate's %s", b)
}
}
for _, event := range []string{"pull.updated", "pull.merged", "pull.closed"} {
if !allowed(bridge.Subscribe, "mesh.mod.gitea.event."+event) {
t.Errorf("the bridge does not hear gitea's %s", event)
}
}
if allowed(bridge.Subscribe, "mesh.mod.gitea.event.pull.opened") {
t.Error("the bridge hears an event of gitea's it does not consume")
}
called := []string{"gitea_add_comment", "gitea_commit_status", "gitea_commit_trees", "gitea_delivery_view",
"gitea_merge_pull_request", "gitea_note_append", "gitea_open_pulls"}
if got := toolsCalled(bridge, "gitea"); !slices.Equal(got, called) {
t.Errorf("the bridge calls %v; want exactly %v", got, called)
}
if allowed(bridge.Publish, "mesh.mod.gitea.tool.gitea_delete_repo") {
t.Error("the bridge may call a tool of gitea's it does not invoke")
}
// The indexer: state candidates and modules, reads the controller's builds and the gate's source, hears FORGE.
if got, want := withPrefix(indexer.Publish, "$KV."), []string{"$KV.mesh-delivery-indexer_candidates.>", "$KV.mesh-delivery-indexer_modules.>"}; !slices.Equal(got, want) {
t.Errorf("the indexer writes %v; want exactly %v", got, want)
}
if got := eventsSaid(indexer); len(got) != 0 {
t.Errorf("the indexer says %v; it declares no event", got)
}
for _, read := range [][2]string{{"mesh-controller_builds", "b.1"}, {"mesh-delivery-gate_source", "main"}} {
if !canRead(indexer, read[0], read[1]) || canWrite(indexer, read[0], read[1]) {
t.Errorf("the indexer must read and not write %s", read[0])
}
}
if !allowed(indexer.Subscribe, "mesh.mod.mesh-delivery-forge-bridge.event.forge.merged") ||
!allowed(indexer.Publish, "$JS.API.CONSUMER.MSG.NEXT.FORGE.novox_mesh-delivery-indexer") {
t.Errorf("the indexer does not hear FORGE through its own consumer: %v / %v", indexer.Subscribe, indexer.Publish)
}
if got := withPrefix(indexer.Publish, "mesh.seat."); len(got) != 0 {
t.Errorf("the indexer reaches the seats %v; it uses none", got)
}
// The teller: state answers, reads the gate's asks, uses operator-channel under its own name.
if got, want := withPrefix(teller.Publish, "$KV."), []string{"$KV.mesh-delivery-teller_answers.>"}; !slices.Equal(got, want) {
t.Errorf("the teller writes %v; want exactly %v", got, want)
}
if !canRead(teller, "mesh-delivery-gate_asks", "merge.g.h") || canWrite(teller, "mesh-delivery-gate_asks", "merge.g.h") {
t.Error("the teller must read and not write the gate's asks")
}
for _, verb := range []string{"ask", "cancel", "condition"} {
if !allowed(teller.Publish, "mesh.seat.operator-channel.accept."+verb+".mesh-delivery-teller") {
t.Errorf("the teller may not say %s as itself", verb)
}
}
if !allowed(teller.Subscribe, "mesh.seat.operator-channel.event.decided.mesh-delivery-teller") {
t.Errorf("the teller does not hear its own decided events: %v", teller.Subscribe)
}
}
// 1243/AC2: each part's credential is refused writing another module's bucket, publishing under another module's name
// and answering a verb subject of a seat.
func TestNoPartsCredentialReachesBeyondItsDeclaration(t *testing.T) {
all := composed(t, partsRecords(t))
parts := []string{"mesh-delivery-forge-bridge", "mesh-delivery-indexer", "mesh-delivery-teller"}
buckets := map[string][]string{
"mesh-delivery-forge-bridge": {"heads"}, "mesh-delivery-indexer": {"candidates", "modules"},
"mesh-delivery-teller": {"answers"}, "mesh-delivery-gate": {"merges", "owed", "source", "asks", "deliveries"},
"mesh-controller": {"builds", "plans"},
}
for _, part := range parts {
p := all[part+"@novox"]
for owner, names := range buckets {
if owner == part {
continue
}
for _, name := range names {
if canWrite(p, owner+"_"+name, "some.key") {
t.Errorf("%s may write %s_%s", part, owner, name)
}
}
}
for _, other := range append(append([]string(nil), parts...), "mesh-delivery-gate", "mesh-controller", "gitea", "mesh-delivery") {
if other == part {
continue
}
for _, subject := range []string{"mesh.mod." + other + ".event.merged", "mesh.stream." + other + ".event.forge.merged"} {
if allowed(p.Publish, subject) {
t.Errorf("%s may publish %s, under %s's name", part, subject, other)
}
}
}
for _, verb := range []string{"mesh.seat.mesh-delivery.tool.deliveries", "mesh.seat.mesh-delivery.tool.release",
"mesh.seat.mesh-delivery.tool.stop", "mesh.seat.operator-channel.tool.tell"} {
if allowed(p.Subscribe, verb) {
t.Errorf("%s answers the verb subject %s", part, verb)
}
}
}
// The teller says what it says as itself alone.
teller := all["mesh-delivery-teller@novox"]
for _, subject := range []string{"mesh.seat.operator-channel.accept.ask.mesh-delivery-gate",
"mesh.seat.operator-channel.accept.condition.mesh-delivery-indexer", "mesh.seat.operator-channel.accept.ask.*",
"mesh.seat.operator-channel.accept.ask.>"} {
if allowed(teller.Publish, subject) {
t.Errorf("the teller may publish %s", subject)
}
}
}
+7 -1
View File
@@ -135,10 +135,16 @@ var ControllerVerbs = []Verb{
"modules": "with repository: or the modules it would change, comma-separated",
"module-dirs": "with repository and paths: the directories holding a module.json at the commit, comma-separated, " +
"as the forge's announcer says them (novox/hq issue 278); without it, a directory is a module only when the paths hold its manifest",
"data": "with repository and paths: true answers the change plan as data, the document delivery-plan answers " +
"(moved, dependents, new, tiers, machines with what each receives and waits for, steps, summary, class, head, base); " +
"without it the answer is text (novox/hq ADR 0450 §2)",
"head": "with data: the commit at hand",
"base": "with data: the branch it merges into (default main)",
"removed": "with data: the files among paths the change deletes, comma-separated",
"limit": "how many plans to list (default 10); only when listing",
"why": "with stop, close or go: why it is done by hand — required, and recorded in the hand-act log (novox/hq to-be 45 §7)",
"cause": "with stop, close or go: the cause in a word, or a condition's kind (optional)",
}, nil)},
}, nil, "data")},
// The verbs the delivery's owner asks with (novox/hq ADR 0239): the planner's answer, a group's order and
// composed check, and the start and end of a walk. Callable by a person too; mesh-delivery is who needs them.
{Name: "delivery-plan", Description: "The delivery plan of a diffset, as the planner computes it and keeping " +
+45 -16
View File
@@ -8,6 +8,7 @@ import (
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/nats-io/nats.go"
@@ -180,22 +181,49 @@ func WatchPull(ctx context.Context, conn *nats.Conn, r BuildRequest, moved func(
if err != nil {
return nil, err
}
var kv jetstream.KeyValue
_, err = firstBucketThatExists(ctx, PullBucketsOf(c), func(ctx context.Context, bucket string) error {
var err error
kv, err = api.KeyValue(ctx, bucket)
return err
})
return watchPullIn(ctx, PullBucketsOf(c), func(ctx context.Context, bucket string) (pullWatcher, error) {
kv, err := api.KeyValue(ctx, bucket)
if err != nil {
return nil, fmt.Errorf("the pull requests' keys: %w", err)
}
watching, end := context.WithCancel(ctx)
w, err := kv.Watch(watching, PullKeyOf(c.Owner, c.Repo, c.Number), jetstream.UpdatesOnly())
if err != nil {
end()
return nil, err
}
go func() {
return kv.Watch(ctx, PullKeyOf(c.Owner, c.Repo, c.Number), jetstream.UpdatesOnly())
}, r, moved)
}
// pullWatcher is what a watch of one bucket's key gives: the updates, and a stop.
type pullWatcher interface {
Updates() <-chan jetstream.KeyValueEntry
Stop() error
}
// watchPullIn is WatchPull over buckets watched by open: the key is watched in every bucket that exists, since
// the gate writes its own bucket and mesh-delivery's stops only when the gate takes over (novox/hq ADR 0450 §1,
// issue 1325); a move in any of them stops the check, once. None existing is an error.
func watchPullIn(ctx context.Context, buckets []string, open func(ctx context.Context, bucket string) (pullWatcher, error),
r BuildRequest, moved func(PullKey)) (stop func(), err error) {
watching, end := context.WithCancel(ctx)
var watchers []pullWatcher
for _, bucket := range buckets {
w, err := open(watching, bucket)
if errors.Is(err, jetstream.ErrBucketNotFound) {
continue
}
if err != nil {
end()
for _, held := range watchers {
_ = held.Stop()
}
return nil, fmt.Errorf("the pull requests' keys in %s: %w", bucket, err)
}
watchers = append(watchers, w)
}
if len(watchers) == 0 {
end()
return nil, fmt.Errorf("the pull requests' keys: none of the buckets %v exists", buckets)
}
var once sync.Once
for _, w := range watchers {
go func(w pullWatcher) {
defer w.Stop()
for {
select {
@@ -212,13 +240,14 @@ func WatchPull(ctx context.Context, conn *nats.Conn, r BuildRequest, moved func(
if json.Unmarshal(e.Value(), &key) != nil {
continue
}
if key.Moved(r.Ref, c.Trunk) {
moved(key)
if key.Moved(r.Ref, r.Check.Trunk) {
once.Do(func() { moved(key) })
return
}
}
}
}()
}(w)
}
return end, nil
}
+85
View File
@@ -7,6 +7,7 @@ import (
"reflect"
"strings"
"testing"
"time"
"github.com/nats-io/nats.go/jetstream"
@@ -111,3 +112,87 @@ func mustJSON(t *testing.T, v any) string {
}
func contains(s, sub string) bool { return strings.Contains(s, sub) }
// fakeWatch is one bucket's watch of the key, fed by the test.
type fakeWatch struct {
updates chan jetstream.KeyValueEntry
stopped bool
}
func (f *fakeWatch) Updates() <-chan jetstream.KeyValueEntry { return f.updates }
func (f *fakeWatch) Stop() error { f.stopped = true; return nil }
type fakeEntry struct {
jetstream.KeyValueEntry
value string
}
func (e fakeEntry) Operation() jetstream.KeyValueOp { return jetstream.KeyValuePut }
func (e fakeEntry) Value() []byte { return []byte(e.value) }
// 1325/AC2: a running check's watch hears the key move in the gate's bucket when it is there, in today's when only
// it is there, and in either when both are; a bucket that does not exist is passed over, and none is an error.
func TestARunningChecksWatchFindsItsKeyInTheGatesBucketOrTodays(t *testing.T) {
req := BuildRequest{Ref: "old", Check: &CheckRequest{Owner: "novox", Repo: "a", Number: 7, Trunk: "t",
Pulls: []string{"mesh-delivery-gate_pulls", "mesh-delivery_pulls"}}}
run := func(exist ...string) (map[string]*fakeWatch, chan PullKey, func(), error) {
watches := map[string]*fakeWatch{}
for _, b := range exist {
watches[b] = &fakeWatch{updates: make(chan jetstream.KeyValueEntry, 1)}
}
moved := make(chan PullKey, 2)
stop, err := watchPullIn(t.Context(), req.Check.Pulls, func(_ context.Context, bucket string) (pullWatcher, error) {
if w, ok := watches[bucket]; ok {
return w, nil
}
return nil, jetstream.ErrBucketNotFound
}, req, func(k PullKey) { moved <- k })
return watches, moved, stop, err
}
wait := func(moved chan PullKey) (PullKey, bool) {
select {
case k := <-moved:
return k, true
case <-time.After(2 * time.Second):
return PullKey{}, false
}
}
for name, c := range map[string]struct {
exist []string
feed string
}{
"only the gate's": {[]string{"mesh-delivery-gate_pulls"}, "mesh-delivery-gate_pulls"},
"only today's": {[]string{"mesh-delivery_pulls"}, "mesh-delivery_pulls"},
"both, the gate's": {[]string{"mesh-delivery-gate_pulls", "mesh-delivery_pulls"}, "mesh-delivery-gate_pulls"},
"both, today's": {[]string{"mesh-delivery-gate_pulls", "mesh-delivery_pulls"}, "mesh-delivery_pulls"},
} {
watches, moved, stop, err := run(c.exist...)
if err != nil {
t.Fatalf("%s: %v", name, err)
}
watches[c.feed].updates <- fakeEntry{value: `{"head":"newer","trunk":"t"}`}
if k, ok := wait(moved); !ok || k.Head != "newer" {
t.Errorf("%s: the move was not heard (%+v, %v)", name, k, ok)
}
stop()
}
// A key that only gains its verdict is no move, and a move is told once.
watches, moved, stop, err := run("mesh-delivery-gate_pulls", "mesh-delivery_pulls")
if err != nil {
t.Fatal(err)
}
watches["mesh-delivery-gate_pulls"].updates <- fakeEntry{value: `{"head":"old","trunk":"t","verdict":"pass"}`}
if _, ok := wait(moved); ok {
t.Error("a verdict alone moved the check")
}
watches["mesh-delivery-gate_pulls"].updates <- fakeEntry{value: `{"head":"newer","trunk":"t"}`}
watches["mesh-delivery_pulls"].updates <- fakeEntry{value: `{"head":"newer","trunk":"t"}`}
wait(moved)
if _, ok := wait(moved); ok {
t.Error("a move was told twice")
}
stop()
if _, _, _, err := run(); err == nil {
t.Error("no bucket existing was not an error")
}
}
+15
View File
@@ -13,6 +13,7 @@ import (
)
type manifest struct {
Reads []string `json:"reads"`
Requires []json.RawMessage `json:"requires"`
Binds map[string]string `json:"binds"`
Secrets map[string]string `json:"secrets"`
@@ -172,3 +173,17 @@ func TestTheJudgeCacheIsADirectoryOfItsOwnMountedAtTheSamePath(t *testing.T) {
t.Fatalf("the judge cache: directory %v, data %v, env %v, mounted at the same path %v", dir, data, env, mounted)
}
}
// A running check's watch reads the pull requests' keys in the gate's bucket and in mesh-delivery's, so it
// stays sighted whichever of them the keys are written to (novox/hq ADR 0450 §1, issue 1325 AC1).
func TestTheAgentReadsTheGatesPullKeysAndTodaysBoth(t *testing.T) {
got := map[string]bool{}
for _, r := range read(t).Reads {
got[r] = true
}
for _, want := range []string{"mesh-delivery-gate.pulls", "mesh-delivery.pulls"} {
if !got[want] {
t.Errorf("the build agent does not read %s: %v", want, read(t).Reads)
}
}
}
+1
View File
@@ -13,6 +13,7 @@
}
],
"reads": [
"mesh-delivery-gate.pulls",
"mesh-delivery.pulls"
],
"requires": [