Compare commits
60
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43788bbfde | ||
|
|
d6c87ee1a7 | ||
|
|
c459f75730 | ||
|
|
036266daff | ||
|
|
abf5eace77 | ||
|
|
f569924037 | ||
|
|
c94f30d398 | ||
|
|
729a86b623 | ||
|
|
fb13f3cc7e | ||
|
|
66232896f3 | ||
|
|
8757567a79 | ||
|
|
dcb2e6e966 | ||
|
|
ba1e90fb2d | ||
|
|
e240e01d07 | ||
|
|
7b034e1db0 | ||
|
|
49b307a46d | ||
|
|
8295207077 | ||
|
|
4a4fac090e | ||
|
|
dff0ce2abb | ||
|
|
c816666f83 | ||
|
|
2d717777b1 | ||
|
|
f913918a60 | ||
|
|
38a0bbca44 | ||
|
|
79ad8b539c | ||
|
|
90a5fc5797 | ||
|
|
362dddae40 | ||
|
|
9169830210 | ||
|
|
8c2ce57eee | ||
|
|
69270a30d5 | ||
|
|
d7a00e1f26 | ||
|
|
2ee75954ad | ||
|
|
959f57294a | ||
|
|
59b4c7910b | ||
|
|
5758e9fb57 | ||
|
|
c21d8fd969 | ||
|
|
bbfda29b49 | ||
|
|
3ed41f02d5 | ||
|
|
09a5eb5783 | ||
|
|
a4ed4e6742 | ||
|
|
d7a008bee5 | ||
|
|
feadebd331 | ||
|
|
6f483725e9 | ||
|
|
fdbb3d5784 | ||
|
|
ea8c50136c | ||
|
|
b2a27ed5ce | ||
|
|
5d26c658f5 | ||
|
|
621984614d | ||
|
|
0ef7c0caa4 | ||
|
|
67354f8e01 | ||
|
|
71be15f530 | ||
|
|
a6c6d936d5 | ||
|
|
cad8250aaa | ||
|
|
790d1c82d0 | ||
|
|
8696aa9ba4 | ||
|
|
db4eb3bbf4 | ||
|
|
da41823855 | ||
|
|
8af6e72669 | ||
|
|
62cddf4efc | ||
|
|
19c204273b | ||
|
|
a20bdf7952 |
@@ -24,15 +24,12 @@ const Event = "states"
|
||||
// account may already publish to and the events stream keeps.
|
||||
func Subject(module string) string { return "mesh.mod." + module + ".event." + Event }
|
||||
|
||||
// SubjectAll is every module's announcement, what the controller consumes.
|
||||
// SubjectAll is every module's announcement.
|
||||
const SubjectAll = "mesh.mod.*.event." + Event
|
||||
|
||||
// KeptBucket is the controller's bucket of the last announcement of each module, by Key.
|
||||
const KeptBucket = "mesh-controller_states-announced"
|
||||
|
||||
var name = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
|
||||
|
||||
// Key is module's key in KeptBucket: the module itself, which must be one plain name, since a module's name is part of
|
||||
// Key is module's key where the last announcement of each module is kept: the module itself, which must be one plain name, since a module's name is part of
|
||||
// its buckets' names (the indexer's judge, novox/hq ADR 0578 §1).
|
||||
func Key(module string) (string, error) {
|
||||
if !name.MatchString(module) {
|
||||
|
||||
@@ -42,9 +42,6 @@ func TestAnAnnouncementRoundTripsSortedAndSaysWhatIsReadable(t *testing.T) {
|
||||
if Subject("mesh-controller") != "mesh.mod.mesh-controller.event.states" || SubjectAll != "mesh.mod.*.event.states" {
|
||||
t.Fatalf("the subjects are %q and %q", Subject("mesh-controller"), SubjectAll)
|
||||
}
|
||||
if KeptBucket != "mesh-controller_states-announced" {
|
||||
t.Fatalf("the kept bucket is %q", KeptBucket)
|
||||
}
|
||||
if k, err := Key("mesh-delivery-builder"); err != nil || k != "mesh-delivery-builder" {
|
||||
t.Fatalf("Key = %q, %v", k, err)
|
||||
}
|
||||
|
||||
+53
-28
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"path"
|
||||
"slices"
|
||||
"sort"
|
||||
@@ -24,7 +25,7 @@ type StepFunc func(ctx context.Context, args map[string]any) (any, error)
|
||||
// AskSteps are the step tools a build module runs an ask with. Copy is the image module's alone; Digest the Go module's.
|
||||
type AskSteps struct {
|
||||
Fetch, Build, Copy, Digest, Test StepFunc
|
||||
// Toolchain is the toolchain the module builds with now; a build ask naming another is refused. Nil checks nothing
|
||||
// Toolchain is the toolchain the module builds with now; a build ask naming another is declined (Declining). Nil checks nothing
|
||||
// (an image or an archive names none).
|
||||
Toolchain func(ctx context.Context) (delivery.ToolchainUsed, error)
|
||||
// Kept looks up a kept verdict by level and digest (the checker's kept bucket, delivery.KeptKey): whether one is
|
||||
@@ -42,14 +43,30 @@ type AskSteps struct {
|
||||
// RunAsk runs ask's job with steps and answers a delivery.BuildAnswer or a delivery.CheckAnswer.
|
||||
func RunAsk(ctx context.Context, steps AskSteps, ask delivery.StepAsk) (any, error) {
|
||||
if ask.Build != nil {
|
||||
return steps.build(ctx, *ask.Build)
|
||||
job := *ask.Build
|
||||
job.Fetch = withPackages(job.Fetch, ask.Packages)
|
||||
return steps.build(ctx, job)
|
||||
}
|
||||
if ask.Check != nil {
|
||||
return steps.check(ctx, *ask.Check)
|
||||
job := *ask.Check
|
||||
job.Fetch = withPackages(job.Fetch, ask.Packages)
|
||||
return steps.check(ctx, job)
|
||||
}
|
||||
return nil, Refuse("the ask %s holds no job", ask.ID)
|
||||
}
|
||||
|
||||
// withPackages is the fetch's arguments with the ask's package versions (novox/hq ADR 0573 §2), which the build module
|
||||
// writes into the workspace it fetches; a copy, so the ask is left as it was. An ask with none, or a copy alone that
|
||||
// fetches nothing, is left as it is.
|
||||
func withPackages(fetch map[string]any, packages delivery.PackageVersions) map[string]any {
|
||||
if len(packages) == 0 || fetch == nil {
|
||||
return fetch
|
||||
}
|
||||
out := maps.Clone(fetch)
|
||||
out["packages"] = maps.Clone(packages)
|
||||
return out
|
||||
}
|
||||
|
||||
func raw(v any) (json.RawMessage, error) {
|
||||
if r, ok := v.(json.RawMessage); ok {
|
||||
return r, nil
|
||||
@@ -90,19 +107,32 @@ func (s AskSteps) fetch(ctx context.Context, args map[string]any) (json.RawMessa
|
||||
return r, f, nil
|
||||
}
|
||||
|
||||
// onToolchain is the toolchain this node builds with when the ask names one and this module reports its own, nil when
|
||||
// either is absent. A node on another toolchain than the ask names answers a Declining: it is not on the agreed one and
|
||||
// sits out, for a node that is to take the ask (novox/hq ADR 0584 clause 2).
|
||||
func (s AskSteps) onToolchain(ctx context.Context, want *delivery.ToolchainUsed) (*delivery.ToolchainUsed, error) {
|
||||
if want == nil || s.Toolchain == nil {
|
||||
return nil, nil
|
||||
}
|
||||
tc, err := s.Toolchain(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tc.Version != want.Version || tc.Recipe != want.Recipe {
|
||||
return nil, &Declining{Lacks: []string{fmt.Sprintf("the agreed toolchain %s (recipe %s)", want.Version, want.Recipe)},
|
||||
Text: fmt.Sprintf("the ask is for %s (recipe %s) and this node builds with %s (recipe %s): it sits out, and the ask is left to a node on the agreed toolchain",
|
||||
want.Version, want.Recipe, tc.Version, tc.Recipe)}
|
||||
}
|
||||
return &tc, nil
|
||||
}
|
||||
|
||||
func (s AskSteps) build(ctx context.Context, job delivery.BuildJob) (any, error) {
|
||||
answer := delivery.BuildAnswer{}
|
||||
if job.Toolchain != nil && s.Toolchain != nil {
|
||||
tc, err := s.Toolchain(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tc.Version != job.Toolchain.Version || tc.Recipe != job.Toolchain.Recipe {
|
||||
return nil, Refuse("the ask is for %s (recipe %s) and this node builds with %s (recipe %s)",
|
||||
job.Toolchain.Version, job.Toolchain.Recipe, tc.Version, tc.Recipe)
|
||||
}
|
||||
answer.Toolchain = &tc
|
||||
tc, err := s.onToolchain(ctx, job.Toolchain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
answer.Toolchain = tc
|
||||
var f fetched
|
||||
defer func() { s.release(f.Workspace) }()
|
||||
for _, a := range job.Artifacts {
|
||||
@@ -164,6 +194,9 @@ func (s AskSteps) build(ctx context.Context, job delivery.BuildJob) (any, error)
|
||||
}
|
||||
|
||||
func (s AskSteps) check(ctx context.Context, job delivery.CheckJob) (any, error) {
|
||||
if _, err := s.onToolchain(ctx, job.Toolchain); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r, f, err := s.fetch(ctx, job.Fetch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -180,16 +213,15 @@ func (s AskSteps) check(ctx context.Context, job delivery.CheckJob) (any, error)
|
||||
if job.Runner != "" && (job.RunnerWhen == "" || changesUnder(f.Changed, job.RunnerWhen)) {
|
||||
runner = job.Runner
|
||||
}
|
||||
// Every level runs, also after one that did not pass: one check names every failure, not only the first (the operator,
|
||||
// 2026-11-02: "A failed check shows only its first fault").
|
||||
for _, level := range job.Levels {
|
||||
la := delivery.LevelAnswer{Level: level}
|
||||
passed, err := s.level(ctx, job, level, f, runner, &la)
|
||||
_, err := s.level(ctx, job, level, f, runner, &la)
|
||||
answer.Levels = append(answer.Levels, la)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !passed {
|
||||
return answer, nil // the first level that did not pass ends the check (ADR 0484 clause 13)
|
||||
}
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
@@ -228,10 +260,9 @@ func (s AskSteps) level(ctx context.Context, job delivery.CheckJob, level string
|
||||
_ = json.Unmarshal(r, &t)
|
||||
return t.Passed, nil
|
||||
}
|
||||
// The packages (or Node directories) whose tests the change touches run first, and a failure among them ends the level
|
||||
// there, so a change's own failing test is answered without the rest of the suite (issue 1289; ADR 0444). Past them every
|
||||
// test step of the level runs, also after one that did not pass, so one check names every failing test of the rest of
|
||||
// the level and not only the first (issue 1921); the level passes only when each of its steps did.
|
||||
// The packages (or Node directories) whose tests the change touches run first (issue 1289; ADR 0444), and then every
|
||||
// other test step of the level, also after one that did not pass, so one check names every failing test of the level
|
||||
// and not only the first (issue 1921; the operator, 2026-11-02); the level passes only when each of its steps did.
|
||||
all := true
|
||||
if s.Digest == nil {
|
||||
// A module with no digest (Node) tests each module directory of the tree, those whose tests changed first.
|
||||
@@ -246,9 +277,6 @@ func (s AskSteps) level(ctx context.Context, job delivery.CheckJob, level string
|
||||
}
|
||||
all = all && passed
|
||||
}
|
||||
if !all {
|
||||
return false, nil // the change's own tests failed: the others are not run (issue 1289)
|
||||
}
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
@@ -284,10 +312,7 @@ func (s AskSteps) level(ctx context.Context, job delivery.CheckJob, level string
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("the %s level: %w", level, err)
|
||||
}
|
||||
all = all && passed // past the change's own packages the others still run (issue 1921)
|
||||
if priority && !all {
|
||||
return false, nil // the change's own tests failed: the others are not run (issue 1289)
|
||||
}
|
||||
all = all && passed // the others still run after a failure (issue 1921)
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
+44
-35
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -74,15 +75,21 @@ func TestRunAskBuilds(t *testing.T) {
|
||||
steps := r.steps()
|
||||
var released []string
|
||||
steps.Release = func(ws string) error { released = append(released, ws); return nil }
|
||||
out, err := RunAsk(context.Background(), steps, ask(delivery.BuildJob{Module: "m", Path: "modules/m", Publish: true,
|
||||
// Issue 1585: the ask's package versions reach the fetch, and the ask's own fetch arguments stay as they were.
|
||||
a := ask(delivery.BuildJob{Module: "m", Path: "modules/m", Publish: true,
|
||||
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit},
|
||||
Artifacts: []delivery.AskArtifact{{Artifact: "base", From: "registry/b@sha256:00"}, {Artifact: "bundle"}, {Artifact: "lib"}}}))
|
||||
Artifacts: []delivery.AskArtifact{{Artifact: "base", From: "registry/b@sha256:00"}, {Artifact: "bundle"}, {Artifact: "lib"}}})
|
||||
a.Packages = delivery.PackageVersions{"git.novox.be/novox/mesh-sdk/go": runCommit}
|
||||
out, err := RunAsk(context.Background(), steps, a)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, put := a.Build.Fetch["packages"]; put {
|
||||
t.Error("the ask's fetch arguments were changed")
|
||||
}
|
||||
got := strings.Join(r.calls, "\n")
|
||||
want := `copy {"artifact":"base","from":"registry/b@sha256:00","module":"m"}
|
||||
fetch {"commit":"` + runCommit + `","repository":"novox/x"}
|
||||
fetch {"commit":"` + runCommit + `","packages":{"git.novox.be/novox/mesh-sdk/go":"` + runCommit + `"},"repository":"novox/x"}
|
||||
build {"artifact":"bundle","module":"modules/m","publish":true,"workspace":"/w/1"}
|
||||
build {"artifact":"lib","module":"modules/m","publish":true,"workspace":"/w/1"}`
|
||||
if got != want {
|
||||
@@ -91,13 +98,13 @@ build {"artifact":"lib","module":"modules/m","publish":true,"workspace":"/w/1"}`
|
||||
if len(released) != 1 || released[0] != "/w/1" {
|
||||
t.Errorf("released %v, want the one workspace", released)
|
||||
}
|
||||
a := out.(delivery.BuildAnswer)
|
||||
if len(a.Artifacts) != 3 || a.Artifacts[1].Reference != "artifact-store://bundle" {
|
||||
t.Fatalf("answer %+v", a)
|
||||
answer := out.(delivery.BuildAnswer)
|
||||
if len(answer.Artifacts) != 3 || answer.Artifacts[1].Reference != "artifact-store://bundle" {
|
||||
t.Fatalf("answer %+v", answer)
|
||||
}
|
||||
}
|
||||
|
||||
// A build ask for another toolchain is refused, so its step ends refused and the asker reads why.
|
||||
// A build ask for another toolchain is declined (ADR 0584 clause 2), so the node sits out and the asker puts it again.
|
||||
func TestRunAskRefusesAnotherToolchain(t *testing.T) {
|
||||
r := &recorder{fetch: map[string]any{"workspace": "/w"}}
|
||||
s := r.steps()
|
||||
@@ -106,15 +113,21 @@ func TestRunAskRefusesAnotherToolchain(t *testing.T) {
|
||||
}
|
||||
_, err := RunAsk(context.Background(), s, ask(delivery.BuildJob{Module: "m", Path: ".", Fetch: map[string]any{"repository": "novox/x", "commit": runCommit},
|
||||
Artifacts: []delivery.AskArtifact{{Artifact: "bundle"}}, Toolchain: &delivery.ToolchainUsed{Version: "go1.26", Recipe: "3"}}))
|
||||
var ref *Refusal
|
||||
if !errors.As(err, &ref) || len(r.calls) != 0 {
|
||||
var dec *Declining
|
||||
if !errors.As(err, &dec) || len(dec.Lacks) != 1 || len(r.calls) != 0 {
|
||||
t.Fatalf("err %v, calls %v", err, r.calls)
|
||||
}
|
||||
// A check ask names the toolchain too (ADR 0584), and a node on another declines it before it fetches.
|
||||
_, err = RunAsk(context.Background(), s, ask(delivery.CheckJob{Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}, Levels: []string{delivery.LevelUnit},
|
||||
Toolchain: &delivery.ToolchainUsed{Version: "go1.26", Recipe: "3"}}))
|
||||
if !errors.As(err, &dec) || len(r.calls) != 0 {
|
||||
t.Fatalf("a check ask: err %v, calls %v", err, r.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// 1887/A3, A5a: a check ask runs the levels of one check after one fetch: it digests the unit level, takes the kept
|
||||
// verdicts, tests the packages whose tests changed first and, when one of them fails, no other (issue 1289), and runs no
|
||||
// level after one that did not pass (ADR 0484 clause 13).
|
||||
// verdicts, tests the packages whose tests changed first and then the others, also after one of them failed, and runs
|
||||
// every level, also after one that did not pass, so one check names every failure (the operator, 2026-11-02).
|
||||
func TestRunAskChecksTheLevelsOfOneCheck(t *testing.T) {
|
||||
r := &recorder{
|
||||
fetch: map[string]any{"workspace": "/w", "changed": []string{"pkg/b/b_test.go"}},
|
||||
@@ -129,16 +142,20 @@ func TestRunAskChecksTheLevelsOfOneCheck(t *testing.T) {
|
||||
got := strings.Join(r.calls, "\n")
|
||||
want := `fetch {"base":"` + runCommit + `","commit":"` + runCommit + `","repository":"novox/x"}
|
||||
digest {"level":"unit","workspace":"/w"}
|
||||
test {"level":"unit","packages":["x/pkg/b"],"workspace":"/w"}`
|
||||
test {"level":"unit","packages":["x/pkg/b"],"workspace":"/w"}
|
||||
test {"level":"unit","packages":["x/pkg/a"],"workspace":"/w"}
|
||||
digest {"level":"integration","workspace":"/w"}
|
||||
test {"level":"integration","packages":["x/pkg/b"],"workspace":"/w"}
|
||||
test {"level":"integration","packages":["x/pkg/a"],"workspace":"/w"}`
|
||||
if got != want {
|
||||
t.Fatalf("calls:\n%s\nwant:\n%s", got, want)
|
||||
}
|
||||
a := out.(delivery.CheckAnswer)
|
||||
if len(a.Levels) != 1 {
|
||||
t.Fatalf("levels %+v, want the unit level alone", a.Levels)
|
||||
if len(a.Levels) != 2 {
|
||||
t.Fatalf("levels %+v, want both levels", a.Levels)
|
||||
}
|
||||
u := a.Levels[0]
|
||||
if u.Level != delivery.LevelUnit || len(u.Kept) != 1 || u.Kept[0].Package != "x/pkg/k" || u.Kept[0].Tests != 7 || len(u.Tests) != 1 || u.Digest == nil {
|
||||
if u.Level != delivery.LevelUnit || len(u.Kept) != 1 || u.Kept[0].Package != "x/pkg/k" || u.Kept[0].Tests != 7 || len(u.Tests) != 2 || u.Digest == nil {
|
||||
t.Fatalf("the unit level %+v", u)
|
||||
}
|
||||
|
||||
@@ -253,17 +270,17 @@ func TestRunAskChecksNodeModulesAndConflicts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1289: a failure in the packages whose tests changed ends the level there, the others are not run, and no later level
|
||||
// runs. Issue 1921: past the change's own packages every package still runs, so two failing packages none of which the
|
||||
// change touched are two test results of the one level. A Node check does the same with its module directories.
|
||||
func TestRunAskStopsAtTheChangesOwnFailureAndRunsTheRestAfterOthers(t *testing.T) {
|
||||
// Issue 1289: the packages whose tests changed run first. Since 2026-11-02 (the operator: "A failed check shows only its
|
||||
// first fault") a failure among them ends nothing: every package of the level and every level still runs, so one check
|
||||
// names every failing test (issue 1921). A Node check does the same with its module directories.
|
||||
func TestRunAskRunsEveryPackageAndLevelAfterAFailure(t *testing.T) {
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
changed []string
|
||||
tests map[string]bool
|
||||
want []string // the packages tested, in order
|
||||
want []string // the packages tested at each level, in order
|
||||
}{
|
||||
{"the change's own test fails: nothing else runs", []string{"pkg/b/b_test.go"}, map[string]bool{"x/pkg/a": false, "x/pkg/b": false}, []string{"x/pkg/b"}},
|
||||
{"the change's own test fails: the rest still runs", []string{"pkg/b/b_test.go"}, map[string]bool{"x/pkg/a": false, "x/pkg/b": false}, []string{"x/pkg/b", "x/pkg/a"}},
|
||||
{"the change's own test passes: the rest runs, also after a failure", []string{"pkg/b/b_test.go"}, map[string]bool{"x/pkg/a": false, "x/pkg/b": true, "x/pkg/c": false}, []string{"x/pkg/b", "x/pkg/a x/pkg/c"}},
|
||||
{"no test changed: every failure is named", nil, map[string]bool{"x/pkg/a": false, "x/pkg/b": false}, []string{"x/pkg/a x/pkg/b"}},
|
||||
} {
|
||||
@@ -285,19 +302,11 @@ func TestRunAskStopsAtTheChangesOwnFailureAndRunsTheRestAfterOthers(t *testing.T
|
||||
got = append(got, strings.Join(a.Packages, " "))
|
||||
}
|
||||
}
|
||||
// the passing row goes on to the integration level, which tests the same packages again; the first level is what is judged
|
||||
if n := len(row.want); len(got) < n || fmt.Sprint(got[:n]) != fmt.Sprint(row.want) {
|
||||
t.Errorf("%s: tested %v, want %v first", row.name, got, row.want)
|
||||
if want := append(slices.Clone(row.want), row.want...); fmt.Sprint(got) != fmt.Sprint(want) {
|
||||
t.Errorf("%s: tested %v, want %v", row.name, got, want)
|
||||
}
|
||||
a := out.(delivery.CheckAnswer)
|
||||
failed := false
|
||||
for _, raw := range a.Levels[0].Tests {
|
||||
var res struct{ Passed bool }
|
||||
_ = json.Unmarshal(raw, &res)
|
||||
failed = failed || !res.Passed
|
||||
}
|
||||
if failed && (len(a.Levels) != 1 || len(got) != len(row.want)) {
|
||||
t.Errorf("%s: a failed level went on: levels %d, tests %v", row.name, len(a.Levels), got)
|
||||
if a := out.(delivery.CheckAnswer); len(a.Levels) != 2 || len(a.Levels[0].Tests) != len(row.want) {
|
||||
t.Errorf("%s: levels %+v, want both, each with every test result", row.name, a.Levels)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,7 +319,7 @@ func TestRunAskStopsAtTheChangesOwnFailureAndRunsTheRestAfterOthers(t *testing.T
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a := out.(delivery.CheckAnswer); len(r.calls) != 2 || len(a.Levels) != 1 || len(a.Levels[0].Tests) != 1 {
|
||||
t.Fatalf("calls %v, levels %+v: want the changed directory b alone and no integration level", r.calls, a.Levels)
|
||||
if a := out.(delivery.CheckAnswer); len(r.calls) != 5 || len(a.Levels) != 2 || len(a.Levels[0].Tests) != 2 {
|
||||
t.Fatalf("calls %v, levels %+v: want b then a at each of the two levels", r.calls, a.Levels)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -538,6 +538,28 @@ func (p *Puller) decline(msg *Pulled, ask delivery.StepAsk, lacks []string) {
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
// declineRun ends the step of an ask that began and then found this node should not run it (Declining), as decline does
|
||||
// before it begins: refused as declined, the message dropped for its asker to put again, and the worker it came from left
|
||||
// alone until the next pull's wait.
|
||||
func (p *Puller) declineRun(msg *Pulled, ask delivery.StepAsk, key string, step delivery.Step, d *Declining) {
|
||||
result, _ := json.Marshal(delivery.StepDeclined{Declined: true, Needs: ask.Needs, Lacks: d.Lacks})
|
||||
step.Ended = now().UTC()
|
||||
step.State, step.Reason, step.Result = delivery.StepRefused, d.Text, result
|
||||
if err := writeEnd(p.Put, key, step); err != nil {
|
||||
p.logf("the ask %s cannot be declined on %s, so it is given back: %v", ask.ID, p.Node, err)
|
||||
_ = msg.Nak()
|
||||
return
|
||||
}
|
||||
p.logf("the ask %s is declined on %s: %s", ask.ID, p.Node, d.Text)
|
||||
_ = msg.Term()
|
||||
p.mu.Lock()
|
||||
if p.declined == nil {
|
||||
p.declined = map[string]bool{}
|
||||
}
|
||||
p.declined[msg.Kind] = true
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
func withKind(msg *Pulled, kind string) *Pulled {
|
||||
if msg != nil {
|
||||
msg.Kind = kind
|
||||
@@ -625,6 +647,11 @@ func (p *Puller) run(ctx context.Context, msg *Pulled, ask delivery.StepAsk, key
|
||||
})
|
||||
step = ph.end() // its end keeps the phase it ended in
|
||||
close(done)
|
||||
var dec *Declining
|
||||
if errors.As(err, &dec) && ctx.Err() == nil {
|
||||
p.declineRun(msg, ask, key, step, dec)
|
||||
return
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
// The process stops: the ask is neither ended nor acknowledged here, so the stream gives it to a node that runs.
|
||||
return
|
||||
|
||||
@@ -230,6 +230,21 @@ func TestPullDropsWhatIsNoAsk(t *testing.T) {
|
||||
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { return nil, nil }}
|
||||
go q.Pull(ctx)
|
||||
waitFor(t, "the placed ask acked where its need is met", func() bool { a, _, _ := has.Fates(); return len(a) == 1 })
|
||||
// A run that finds its node is not on the agreed toolchain declines the ask after it began (ADR 0584 clause 2); a node on it builds.
|
||||
sit, sitSteps := NewFakeAsks(), newSteps()
|
||||
toolchain := testAsk(delivery.AskDelivery)
|
||||
_ = sit.PublishAsk(toolchain)
|
||||
s2 := &Puller{Source: sit, Language: delivery.AskGo, Node: "ace", Put: sitSteps.put, Capacity: func() int { return 1 }, PullWait: 10 * time.Millisecond,
|
||||
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) {
|
||||
return nil, &Declining{Text: "the ask is for 26.13.0 and this node builds with 26.12.1", Lacks: []string{"the agreed toolchain 26.13.0 (recipe 3)"}}
|
||||
}}
|
||||
go s2.Pull(ctx)
|
||||
waitFor(t, "the ask termed on the node that sits out", func() bool { _, tm, _ := sit.Fates(); return len(tm) == 1 })
|
||||
sk, _ := delivery.AskStepKey("ace", toolchain.ID)
|
||||
ss, _ := sitSteps.get(sk)
|
||||
if d, ok := delivery.DeclinedOf(ss); !ok || len(d.Lacks) != 1 || !strings.Contains(ss.Reason, "26.12.1") {
|
||||
t.Fatalf("the sitting-out step = %+v", ss)
|
||||
}
|
||||
}
|
||||
|
||||
// load reads the recorded steps state's raw values: the pause key is not a step, so it is kept apart.
|
||||
|
||||
@@ -16,3 +16,14 @@ func (r *Refusal) Error() string { return r.Text }
|
||||
|
||||
// Refuse is a Refusal with its text formatted.
|
||||
func Refuse(format string, args ...any) *Refusal { return &Refusal{Text: fmt.Sprintf(format, args...)} }
|
||||
|
||||
// Declining is a step a node does not run because another node should: it ends refused as declined (delivery.StepDeclined),
|
||||
// the ask's message is dropped, and the asker puts the ask again under a new id (issue 890). A build ask naming a
|
||||
// toolchain other than this node's is one (novox/hq ADR 0584 clause 2): a node that is not in the toolchain majority
|
||||
// sits out and never fails a build.
|
||||
type Declining struct {
|
||||
Text string
|
||||
Lacks []string // what this node lacks, in words the operator reads
|
||||
}
|
||||
|
||||
func (d *Declining) Error() string { return d.Text }
|
||||
|
||||
@@ -2,9 +2,12 @@ package buildstep
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -27,6 +30,10 @@ type FetchRequest struct {
|
||||
Base string `json:"base,omitempty"`
|
||||
Dir string `json:"dir,omitempty"`
|
||||
Paths []string `json:"paths,omitempty"`
|
||||
// Packages are the version of each mesh package the build or the check builds against (delivery.StepAsk.Packages,
|
||||
// novox/hq ADR 0573 §2, §4): the build module writes each into the workspace's go.mod and go.sum, whatever version
|
||||
// the repository names, checksum-verified, and names them in its log.
|
||||
Packages delivery.PackageVersions `json:"packages,omitempty"`
|
||||
}
|
||||
|
||||
// FetchRequestProperties are the JSON schema properties of FetchRequest's dir and paths, for a fetch tool's Input.
|
||||
@@ -35,6 +42,8 @@ func FetchRequestProperties() map[string]any {
|
||||
"dir": map[string]any{"type": "string", "description": "the module's directory in the repository (\".\" for its root), with paths"},
|
||||
"paths": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
|
||||
"description": "the paths the module names, relative to dir, from the indexer's kept inputs: the build sees only these, the manifest and its lock files"},
|
||||
"packages": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "string"},
|
||||
"description": "optional: the version (a full commit) of each mesh package to build against, by its module path, whatever version go.mod names (novox/hq ADR 0573 §2); the builder and the checker fill it"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +58,12 @@ func (r FetchRequest) Args() map[string]any {
|
||||
if len(r.Paths) > 0 {
|
||||
args["paths"] = append([]string(nil), r.Paths...)
|
||||
}
|
||||
if len(r.Packages) > 0 {
|
||||
args["packages"] = map[string]any{}
|
||||
for p, v := range r.Packages {
|
||||
args["packages"].(map[string]any)[p] = v
|
||||
}
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
@@ -78,9 +93,42 @@ func ParseFetchRequest(args map[string]any) (FetchRequest, error) {
|
||||
default:
|
||||
return FetchRequest{}, fmt.Errorf("paths is a list of strings")
|
||||
}
|
||||
packages, err := PackagesArg(args["packages"])
|
||||
if err != nil {
|
||||
return FetchRequest{}, err
|
||||
}
|
||||
r.Packages = packages
|
||||
return r, r.Check()
|
||||
}
|
||||
|
||||
// PackagesArg reads a tool call's packages argument, an object of strings (as JSON gives it) or the map an ask gives;
|
||||
// nil for none.
|
||||
func PackagesArg(v any) (delivery.PackageVersions, error) {
|
||||
out := delivery.PackageVersions{}
|
||||
switch v := v.(type) {
|
||||
case nil:
|
||||
return nil, nil
|
||||
case delivery.PackageVersions:
|
||||
maps.Copy(out, v)
|
||||
case map[string]string:
|
||||
maps.Copy(out, v)
|
||||
case map[string]any:
|
||||
for p, version := range v {
|
||||
s, ok := version.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("packages is an object of versions (strings), by package")
|
||||
}
|
||||
out[p] = s
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("packages is an object of versions (strings), by package")
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return out, out.Check()
|
||||
}
|
||||
|
||||
// Check is what ParseFetchRequest refuses.
|
||||
func (r FetchRequest) Check() error {
|
||||
if !requestRepositoryOK.MatchString(r.Repository) || strings.Contains(r.Repository, "..") {
|
||||
|
||||
@@ -10,11 +10,15 @@ import (
|
||||
// from the indexer's kept inputs and the build module reads them from the call, never from the manifest (ADR 0495
|
||||
// clause 1). The request survives the trip through a tool call's arguments.
|
||||
func TestAFetchRequestCarriesTheNamedPathsThroughTheToolCall(t *testing.T) {
|
||||
// And the versions of the mesh packages to build against (novox/hq ADR 0573 §2; issue 1585 S4).
|
||||
req := FetchRequest{Repository: "novox/mesh-sdk", Commit: strings.Repeat("a", 40), Ref: "refs/pull/7/head",
|
||||
Dir: ".", Paths: []string{"package.json", "src"}}
|
||||
Dir: ".", Paths: []string{"package.json", "src"}, Packages: map[string]string{"git.novox.be/novox/mesh-sdk/go": strings.Repeat("d", 40)}}
|
||||
args := req.Args()
|
||||
// A tool call's arguments arrive as JSON: lists of any.
|
||||
// A tool call's arguments arrive as JSON: lists of any, objects of any.
|
||||
args["paths"] = []any{"package.json", "src"}
|
||||
if p, ok := args["packages"].(map[string]any); !ok || p["git.novox.be/novox/mesh-sdk/go"] != strings.Repeat("d", 40) {
|
||||
t.Fatalf("the packages are not in the call's arguments: %v", args["packages"])
|
||||
}
|
||||
got, err := ParseFetchRequest(args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -42,6 +46,9 @@ func TestAFetchRequestCarriesTheNamedPathsThroughTheToolCall(t *testing.T) {
|
||||
{map[string]any{"dir": "../a", "paths": []any{"src"}}, "leaves the repository"},
|
||||
{map[string]any{"dir": "modules/a", "paths": []any{3}}, "paths is a list of strings"},
|
||||
{map[string]any{"dir": "modules/a"}, "names a module directory and no path"},
|
||||
{map[string]any{"packages": map[string]any{"git.novox.be/novox/mesh-sdk/go": "v0.1.7"}}, "no full commit"},
|
||||
{map[string]any{"packages": map[string]any{"a b": strings.Repeat("d", 40)}}, "no module path"},
|
||||
{map[string]any{"packages": []any{"x"}}, "packages is an object"},
|
||||
} {
|
||||
args := map[string]any{}
|
||||
for k, v := range ok {
|
||||
|
||||
@@ -25,6 +25,8 @@ type ModuleTree struct {
|
||||
// Paths are, of a module whose every artifact names its paths, each named path's git object id at the commit; Tree
|
||||
// is then their delivery.NamedTree with Manifest (novox/hq ADR 0499 clause 2). Absent otherwise.
|
||||
Paths map[string]string `json:"paths,omitempty"`
|
||||
// Packages are the mesh packages the directory's go.sum names (delivery.MeshPackagesOf, novox/hq ADR 0573 §2).
|
||||
Packages []string `json:"packages,omitempty"`
|
||||
}
|
||||
|
||||
var lockFiles = []string{"go.sum", "package-lock.json"}
|
||||
@@ -67,6 +69,9 @@ func ModuleTrees(ctx context.Context, run forgekey.Runner, env []string, dir, co
|
||||
mt.Locks = map[string]string{}
|
||||
}
|
||||
mt.Locks[lock] = sha256Hex(raw)
|
||||
if lock == "go.sum" {
|
||||
mt.Packages = delivery.MeshPackagesOf(raw) // the mesh packages it uses (novox/hq ADR 0573 §2)
|
||||
}
|
||||
}
|
||||
}
|
||||
trees[mdir] = mt
|
||||
|
||||
@@ -33,7 +33,8 @@ func TestModuleTreesAnswersTreeManifestAndLocksPerModuleDirectory(t *testing.T)
|
||||
writeTreeFile(t, root, "module.json", `{"root":1}`)
|
||||
writeTreeFile(t, root, "go.sum", "rootsum")
|
||||
writeTreeFile(t, root, "modules/a/module.json", `{"a":1}`)
|
||||
writeTreeFile(t, root, "modules/a/go.sum", "asum")
|
||||
asum := "git.novox.be/novox/mesh-sdk/go v0.1.7 h1:x=\ngit.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:y=\ngithub.com/nats-io/nats.go v1.0.0 h1:z=\n"
|
||||
writeTreeFile(t, root, "modules/a/go.sum", asum)
|
||||
writeTreeFile(t, root, "modules/a/package-lock.json", "alock")
|
||||
writeTreeFile(t, root, "modules/b/module.json", `{"b":1}`)
|
||||
merge := strings.Repeat("c", 40)
|
||||
@@ -55,8 +56,9 @@ func TestModuleTreesAnswersTreeManifestAndLocksPerModuleDirectory(t *testing.T)
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]ModuleTree{
|
||||
".": {Tree: "tree-of-" + merge + "^{tree}", Manifest: hexOf(`{"root":1}`), Locks: map[string]string{"go.sum": hexOf("rootsum")}},
|
||||
"modules/a": {Tree: "tree-of-" + merge + ":modules/a", Manifest: hexOf(`{"a":1}`), Locks: map[string]string{"go.sum": hexOf("asum"), "package-lock.json": hexOf("alock")}},
|
||||
".": {Tree: "tree-of-" + merge + "^{tree}", Manifest: hexOf(`{"root":1}`), Locks: map[string]string{"go.sum": hexOf("rootsum")}},
|
||||
"modules/a": {Tree: "tree-of-" + merge + ":modules/a", Manifest: hexOf(`{"a":1}`), Locks: map[string]string{"go.sum": hexOf(asum), "package-lock.json": hexOf("alock")},
|
||||
Packages: []string{"git.novox.be/novox/mesh-sdk/go"}}, // issue 1585: the mesh packages its go.sum names
|
||||
"modules/b": {Tree: "tree-of-" + merge + ":modules/b", Manifest: hexOf(`{"b":1}`)},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
|
||||
@@ -186,64 +186,3 @@ func HeldCredential(w WantedAccount, password string) ([]byte, error) {
|
||||
}{URL: w.URL, Fingerprint: w.Fingerprint, Node: w.Node, Module: w.Module, User: w.User, Password: password,
|
||||
Claims: w.Claims})
|
||||
}
|
||||
|
||||
// **The lease write** (novox/hq ADR 0561 §3, §6). The controller's lease is the one key `holder` of its bucket
|
||||
// (LeaseBucket). The operator's credential hands it away by writing a value that names its new holder in `handed-to`;
|
||||
// while the key holds that, the grants part creates the durable consumers an account needs, the assigner reads the grants
|
||||
// part's grants (delivery.GrantSource), and the vault retires and rotates accounts. A controller's value, a key deleted or
|
||||
// expired, or one that cannot be read or parsed, turn those off. No clock and no setting turns it.
|
||||
//
|
||||
// **Minting is no longer on the lease** (issue 774, research 072's gap "vault mints after the lease"): the vault is the
|
||||
// one issuer of every account the grants part wants, whoever holds the lease, and the controller mints none once a vault
|
||||
// is installed (it forwards `module issue` to the vault's issue-account). One issuer at every moment holds by who mints,
|
||||
// not by the lease: an account whose value the vault holds is never minted anew, and a value the controller hands or
|
||||
// moves never replaces one the vault minted.
|
||||
|
||||
// ControllerLease is the lease's bucket as a reader names it; the vault and the grants part alone read it
|
||||
// (NamedReaders), the controller's announcement marking it readable by nobody.
|
||||
const ControllerLease = ControllerSeat + ".lease"
|
||||
|
||||
// ControllerLeaseKey is the lease's one key.
|
||||
const ControllerLeaseKey = leaseKey
|
||||
|
||||
// LeaseHolder is the lease key's value: a controller instance's (mesh-controller internal/lease Holder), or, once handed
|
||||
// away, one naming its holder in HandedTo.
|
||||
type LeaseHolder struct {
|
||||
Instance string `json:"instance,omitempty"`
|
||||
Host string `json:"host,omitempty"`
|
||||
Epoch uint64 `json:"epoch,omitempty"`
|
||||
Taken time.Time `json:"taken,omitzero"`
|
||||
Renewed time.Time `json:"renewed,omitzero"`
|
||||
// HandedTo is who holds the lease once the controller handed it away: never a controller instance.
|
||||
HandedTo string `json:"handed-to,omitempty"`
|
||||
// HandedBy is the credential that wrote it (the operator's), and Why its reason.
|
||||
HandedBy string `json:"handed-by,omitempty"`
|
||||
Why string `json:"why,omitempty"`
|
||||
}
|
||||
|
||||
// LeaseHandedAway reads the lease key as a getter answered it (its value, whether it was there, the read's error) and
|
||||
// says whether it is handed to a holder that is no controller, with why either way.
|
||||
func LeaseHandedAway(raw []byte, found bool, readErr error) (bool, string) {
|
||||
switch {
|
||||
case readErr != nil:
|
||||
return false, "the controller's lease cannot be read (" + readErr.Error() + "), so it is not handed away"
|
||||
case !found || len(raw) == 0 || string(raw) == "null":
|
||||
return false, "no lease is written (expired, deleted, or never taken): a controller may take it, so it is not handed away"
|
||||
}
|
||||
var h LeaseHolder
|
||||
if err := json.Unmarshal(raw, &h); err != nil {
|
||||
return false, "the controller's lease is not readable as a holder (" + err.Error() + "), so it is not handed away"
|
||||
}
|
||||
if strings.TrimSpace(h.HandedTo) == "" {
|
||||
who := h.Instance
|
||||
if who == "" {
|
||||
who = "an instance that names itself not"
|
||||
}
|
||||
return false, "the controller holds the lease (" + who + "), so it is not handed away (ADR 0561 §3)"
|
||||
}
|
||||
if h.Instance != "" {
|
||||
return false, "the lease names both a controller instance (" + h.Instance + ") and a holder it is handed to (" +
|
||||
h.HandedTo + "), so it is not handed away"
|
||||
}
|
||||
return true, "the lease is handed to " + h.HandedTo + " (ADR 0561 §3)"
|
||||
}
|
||||
|
||||
@@ -9,39 +9,11 @@ import (
|
||||
)
|
||||
|
||||
// Who may read a module's state is what the module announced (novox/hq ADR 0495 clauses 2 and 3, ADR 0513 §3; issue
|
||||
// 1790). A module says on the bus which states it keeps and which others may read (announce.Subject); the controller
|
||||
// keeps the last word of each (announce.KeptBucket) and grants a reader's `reads: <owner>.<name>` only when the owner
|
||||
// 1790). A module says on the bus which states it keeps and which others may read (announce.Subject); the composer is
|
||||
// given the last word of each (Records.Announced) and grants a reader's `reads: <owner>.<name>` only when the owner
|
||||
// announced that state readable. An owner that has announced nothing yet is read as before, from the reader's `reads`
|
||||
// (the transition, until every module announces), and is named as not yet announced.
|
||||
|
||||
// StatesAnnouncedBucket is the controller's bucket of the last announcement of each module (announce.KeptBucket).
|
||||
const StatesAnnouncedBucket = announce.KeptBucket
|
||||
|
||||
// controllerReadable are the controller's states others may read: the record mirrors (the packages each node declares
|
||||
// among them, which the pacman module reads, ADR 0507 §5), the chain's buckets, and the grants issued and the
|
||||
// compositions' digests, which the assigner reads (ADR 0528 clause 2, ADR 0539 clause 6).
|
||||
var controllerReadable = []string{MirrorAssignments, MirrorDeclaredConfig, MirrorPredefined, ChainBuilds, ChainRollouts,
|
||||
MirrorDeclaredPackages, ControllerGrants, ControllerComposed}
|
||||
|
||||
// ControllerAnnouncement is what the controller announces it keeps (ADR 0495 clause 2): every bucket of its own by its local
|
||||
// name, the ones others may read marked readable. It announces itself, as any module does from its code.
|
||||
func ControllerAnnouncement(node string) announce.States {
|
||||
s := announce.States{Module: ControllerSeat, Node: node}
|
||||
readable := map[string]bool{}
|
||||
for _, n := range controllerReadable {
|
||||
readable[n] = true
|
||||
}
|
||||
for _, bucket := range ControllerBuckets() {
|
||||
local, mine := strings.CutPrefix(bucket, ControllerSeat+"_")
|
||||
if !mine {
|
||||
continue
|
||||
}
|
||||
s.States = append(s.States, announce.State{Name: local, Readable: readable[local]})
|
||||
}
|
||||
sort.Slice(s.States, func(i, j int) bool { return s.States[i].Name < s.States[j].Name })
|
||||
return s
|
||||
}
|
||||
|
||||
// ReadsAnnouncedBy splits one reader module's `reads` (`<owner>.<name>`) by what each owner announced: granted when the
|
||||
// owner announced the state readable (by this reader, where it named its readers: announce.State.Readers), when the
|
||||
// composer names the reader among the state's readers (NamedReaders, ADR 0547 clause 9), or when the owner announced
|
||||
@@ -56,8 +28,7 @@ func ReadsAnnouncedBy(reader string, reads []string, announced map[string]announ
|
||||
case reader != "" && !ReaderMay(reader, r):
|
||||
refused = append(refused, r)
|
||||
case reader != "" && namedReader(reader, r):
|
||||
// A state the composer names its readers for is theirs whatever its owner announced (ADR 0561 §3: the
|
||||
// controller's lease, which its announcement marks readable by nobody).
|
||||
// A state the composer names its readers for is theirs whatever its owner announced.
|
||||
granted = append(granted, r)
|
||||
case reader != "" && ok && said && s.Keeps(name):
|
||||
if s.ReadableBy(name, reader) {
|
||||
|
||||
+239
-328
@@ -2,11 +2,10 @@ package buscompose
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -15,173 +14,31 @@ import (
|
||||
"git.novox.be/novox/mesh-sdk/go/announce"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/membership"
|
||||
"git.novox.be/novox/mesh-sdk/go/provisions"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// Defends issue 1377 and novox/hq ADR 0547 §5: this is the controller's bus composer copied, and it composes byte for
|
||||
// byte what the controller's composes. testdata/controller-user-lists.json was written by the controller's own
|
||||
// internal/broker (mesh-controller branch fix/1945-mesh-grants-publishes-memberships, and fix/1790-grants-create-buckets for the grants part creating declared streams), lists and memberships, from fixtures_test.go compiled there unchanged; composed.conf is the
|
||||
// controller's golden of the whole server configuration. A row that differs is the copy drifting from the controller,
|
||||
// which is a fault while both compose (until the controller loses its lease, ADR 0547 §13), but for the grants the copy
|
||||
// gives beyond it on purpose (beyondTheControllers): the copy grants every user everything the controller's grants, and
|
||||
// only those more.
|
||||
// createdAdvisory is one subscription to a bucket's creation in a composed list, as stateCreatedHeard composes it.
|
||||
var createdAdvisory = regexp.MustCompile(`"\$JS\.EVENT\.ADVISORY\.STREAM\.CREATED\.KV_[^"]*", `)
|
||||
// Issue 1126 B3a (novox/hq ADR 0590): the composed user lists and the server configuration are golden files this
|
||||
// composer wrote (testdata/user-lists.json, testdata/composed.conf), first from the lists the ended controller composed
|
||||
// with its user and the lease's grants taken out. A change to what any user is granted shows here as a diff to read;
|
||||
// MESH_SDK_UPDATE_GOLDEN=1 writes them anew.
|
||||
var updateGolden = os.Getenv("MESH_SDK_UPDATE_GOLDEN") == "1"
|
||||
|
||||
// beyondTheControllers is what the copy grants beyond the controller's on purpose (novox/hq ADR 0547 §2, ADR 0555 §3,
|
||||
// issue 1126), by user: a node's account reads every node's own facts, `<node>.node` of the have bucket where the node's
|
||||
// record rides, and what it composes its own modules from (NodeComposeReads). After the take-over (ADR 0547 §8) the
|
||||
// grants part's list is the one the bus loads; a hand-back loads the controller's, which lacks these reads, and the nodes
|
||||
// keep applying without composing their own until the next take-over. Each principal's hearing of its buckets' creation
|
||||
// (issue 1790) is the other row beyond, matched by createdAdvisory.
|
||||
//
|
||||
// And the watchman's dead letters and watchdogs (novox/hq ADR 0564 clauses 4, 5, issue 1952 S5): its own consumer on
|
||||
// DEAD_LETTER_NOTICES, a read of DEAD_LETTERS, and the words its watchdogs hear, the controller's jobs until the lease
|
||||
// write.
|
||||
func beyondTheControllers(user string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
if strings.HasSuffix(user, "."+WatchmanModule) {
|
||||
for _, s := range append(WatchmanDeadLetterGrants(nil), WatchmanHears()...) {
|
||||
out[s] = true
|
||||
}
|
||||
}
|
||||
node, ok := strings.CutPrefix(user, "node.")
|
||||
if !ok {
|
||||
return out
|
||||
}
|
||||
for _, s := range append(append(readGrants("node-engine_have", "*.node"), NodeComposeReads(node)...), NodeComposeAdvisories()...) {
|
||||
out[s] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mayBeyondTheControllers is what the copy may grant beyond the controller's where the records allow it, and need not
|
||||
// where they do not: a node's ask of the vault's `own` while the vault serves it (issue 1126, ADR 0550 §5).
|
||||
func mayBeyondTheControllers(user string) map[string]bool {
|
||||
// A runtime's read of its membership from the grants part's state (novox/hq ADR 0571 clause 7): its own key, or the
|
||||
// tool runner's node's.
|
||||
if parts := strings.Split(user, "."); len(parts) == 2 && parts[0] != "node" {
|
||||
key := user
|
||||
if parts[1] == RuntimeModule {
|
||||
key = parts[0] + ".*"
|
||||
}
|
||||
out := map[string]bool{}
|
||||
if membership.GrantsBucket != GrantsMembershipsBucket || membership.GrantsKey("a", "b") != MembershipKey("a", "b") {
|
||||
panic("the membership package's grants bucket does not agree with buscompose's") // FollowMembership reads it
|
||||
}
|
||||
for _, s := range readGrants(GrantsMembershipsBucket, key) {
|
||||
out[s] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
if _, ok := strings.CutPrefix(user, "node."); !ok {
|
||||
return nil
|
||||
}
|
||||
return map[string]bool{VaultOwnCall(Principal{Kind: KindNode, Node: strings.TrimPrefix(user, "node.")}): true,
|
||||
VaultSecretChanged: true}
|
||||
}
|
||||
|
||||
// engineBeyond is a node's engine holding its node seat and asking the operator in its own name (novox/hq ADR 0563 §1,
|
||||
// §3), beyond the controller's on purpose: its own node's verbs, events and discovery, the bus's discovery asked, and
|
||||
// the router's seat traffic under node-engine-<node>.
|
||||
func engineBeyond(user, g string) bool {
|
||||
node, ok := strings.CutPrefix(user, "node.")
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(g, "mesh.seat."+EngineSeat+".") && strings.Contains(g, "."+node) ||
|
||||
strings.HasPrefix(g, "$SRV.") || strings.Contains(g, ".node-engine-"+node) ||
|
||||
strings.Contains(g, "KV_"+EngineActsBucket) || strings.HasPrefix(g, "$KV."+EngineActsBucket+"."+node+".") ||
|
||||
// And its node's declared packages (novox/hq ADR 0565 clause 6).
|
||||
strings.Contains(g, "KV_"+EnginePackagesBucket) || g == "$KV."+EnginePackagesBucket+"."+node ||
|
||||
// And its own health report (issue 774).
|
||||
strings.Contains(g, "KV_"+delivery.NodeHealthBucket) || g == "$KV."+delivery.NodeHealthBucket+"."+node ||
|
||||
// And the provisions of its node's modules, read (novox/hq ADR 0570 clause 6).
|
||||
strings.Contains(g, "KV_"+provisions.ProvisionsBucket) ||
|
||||
// And its node's placed modules' data items, its own node's keys (novox/hq ADR 0577 clause 1).
|
||||
strings.Contains(g, "KV_"+EngineDataBucket) || strings.HasPrefix(g, "$KV."+EngineDataBucket+"."+node+".")
|
||||
}
|
||||
|
||||
// createsAnAccountsConsumer is the grants part's create and read of a durable consumer an account needs, by its stream
|
||||
// and name (novox/hq ADR 0561 §6): beyond the controller's on purpose, since the controller made them alone.
|
||||
func createsAnAccountsConsumer(user, g string) bool {
|
||||
return strings.HasSuffix(user, "."+GrantsModule) &&
|
||||
(strings.HasPrefix(g, "$JS.API.CONSUMER.CREATE.") || strings.HasPrefix(g, "$JS.API.CONSUMER.INFO."))
|
||||
}
|
||||
|
||||
// quotedToken is a quoted token of a composed list.
|
||||
var quotedToken = regexp.MustCompile(`"[^"]*"`)
|
||||
|
||||
// grantsByUser splits a composed list into the text outside its users and each user's quoted tokens (its name, its
|
||||
// password's hash, every subject it may publish or subscribe to), by user.
|
||||
func grantsByUser(list string) (string, map[string]map[string]bool) {
|
||||
var outside strings.Builder
|
||||
users := map[string]map[string]bool{}
|
||||
user := ""
|
||||
for _, line := range strings.SplitAfter(list, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if name, ok := strings.CutPrefix(trimmed, `{ user: "`); ok {
|
||||
user, _, _ = strings.Cut(name, `"`)
|
||||
users[user] = map[string]bool{}
|
||||
} else if user != "" && trimmed == "]" {
|
||||
user = ""
|
||||
}
|
||||
if user == "" {
|
||||
outside.WriteString(line)
|
||||
continue
|
||||
}
|
||||
for _, q := range quotedToken.FindAllString(line, -1) {
|
||||
users[user][strings.Trim(q, `"`)] = true
|
||||
}
|
||||
}
|
||||
return outside.String(), users
|
||||
}
|
||||
|
||||
// grantsAsTheControllers fails t unless the copy's list is the controller's, user for user, but for the grants it
|
||||
// names on purpose (beyondTheControllers, the creation advisories): every grant of the controller's is the copy's, and
|
||||
// the copy's beyond them are exactly those, every one of them there.
|
||||
func grantsAsTheControllers(t *testing.T, got, want string) {
|
||||
// golden compares got with the golden file name, or writes it.
|
||||
func golden(t *testing.T, name string, got []byte) {
|
||||
t.Helper()
|
||||
gotOutside, gotUsers := grantsByUser(got)
|
||||
wantOutside, wantUsers := grantsByUser(want)
|
||||
if gotOutside != wantOutside {
|
||||
t.Errorf("the list differs from the controller's outside its users:\n%s", gotOutside)
|
||||
path := filepath.Join("testdata", name)
|
||||
if updateGolden {
|
||||
if err := os.WriteFile(path, got, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
for user, was := range wantUsers {
|
||||
now, ok := gotUsers[user]
|
||||
if !ok {
|
||||
t.Errorf("the copy has no user %s", user)
|
||||
continue
|
||||
}
|
||||
intended := beyondTheControllers(user)
|
||||
for g := range was {
|
||||
// A runtime's direct get of its membership from the assignments stream went with the stream (research 072
|
||||
// row 33): it reads the grants part's state memberships instead (ADR 0571 clause 7).
|
||||
if !now[g] && !strings.HasPrefix(g, "$JS.API.DIRECT.GET."+AssignmentsStream+".mesh.assignment."+strings.SplitN(user, ".", 2)[0]+".") {
|
||||
t.Errorf("%s: the copy does not grant %s, which the controller's does", user, g)
|
||||
}
|
||||
}
|
||||
for g := range now {
|
||||
if strings.HasPrefix(g, "$JS.API.DIRECT.GET."+AssignmentsStream+".") && !strings.HasSuffix(user, "."+GrantsModule) {
|
||||
t.Errorf("%s: the copy grants %s, a read of the assignments stream, which goes at the lease write", user, g)
|
||||
}
|
||||
if !was[g] && !intended[g] && !mayBeyondTheControllers(user)[g] && !createdAdvisory.MatchString(`"`+g+`", `) &&
|
||||
!createsAnAccountsConsumer(user, g) && !engineBeyond(user, g) {
|
||||
t.Errorf("%s: the copy grants %s beyond the controller's, and no decision names it", user, g)
|
||||
}
|
||||
}
|
||||
for g := range intended {
|
||||
if !now[g] {
|
||||
t.Errorf("%s: the copy does not grant %s, which a node composing its own modules reads", user, g)
|
||||
}
|
||||
}
|
||||
want, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for user := range gotUsers {
|
||||
if _, ok := wantUsers[user]; !ok {
|
||||
t.Errorf("the copy has a user %s the controller's has not", user)
|
||||
}
|
||||
if string(got) != string(want) {
|
||||
t.Errorf("%s differs from what is composed now; read the diff and, if it is meant, write it with MESH_SDK_UPDATE_GOLDEN=1", path)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,63 +59,33 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
|
||||
got[1] != "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_candidates" {
|
||||
t.Errorf("a node hears the creation of %v", got)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join("testdata", "controller-user-lists.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var want []struct {
|
||||
type list struct {
|
||||
Name, List string
|
||||
Missing []string
|
||||
Err string
|
||||
Memberships json.RawMessage
|
||||
}
|
||||
if err := json.Unmarshal(raw, &want); err != nil {
|
||||
var lists []list
|
||||
for _, f := range UserListFixtures() {
|
||||
l, missing, err := ComposedFor(f)
|
||||
row := list{Name: f.Name, List: l, Missing: missing}
|
||||
if err != nil {
|
||||
row.Err = err.Error()
|
||||
}
|
||||
if row.Memberships, err = json.Marshal(MembershipsFor(f)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// B3a/AC1: no list holds a user for the ended controller.
|
||||
if strings.Contains(l, `user: "controller"`) {
|
||||
t.Errorf("%s: the list holds the controller's user", f.Name)
|
||||
}
|
||||
lists = append(lists, row)
|
||||
}
|
||||
raw, err := json.MarshalIndent(lists, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fixtures := UserListFixtures()
|
||||
if len(want) != len(fixtures) {
|
||||
t.Fatalf("%d fixtures, %d lists the controller composed", len(fixtures), len(want))
|
||||
}
|
||||
for i, f := range fixtures {
|
||||
t.Run(f.Name, func(t *testing.T) {
|
||||
if want[i].Name != f.Name {
|
||||
t.Fatalf("fixture %q against the controller's %q", f.Name, want[i].Name)
|
||||
}
|
||||
list, missing, err := ComposedFor(f)
|
||||
got := ""
|
||||
if err != nil {
|
||||
got = err.Error()
|
||||
}
|
||||
if got != want[i].Err {
|
||||
t.Fatalf("error %q, the controller's %q", got, want[i].Err)
|
||||
}
|
||||
if strings.Join(missing, ",") != strings.Join(want[i].Missing, ",") {
|
||||
t.Errorf("left out %v, the controller %v", missing, want[i].Missing)
|
||||
}
|
||||
// The one row the copy composes beyond the controller's, on purpose (novox/hq issue 1790, 2026-10-26, after
|
||||
// the take-over of ADR 0547 milestone 2): each principal hears its own and read buckets created, so a state
|
||||
// missing at its start is bound when it appears. The controller's composer is frozen and its list no longer
|
||||
// loaded; while it is not, this row is left out of the comparison and nothing else.
|
||||
grantsAsTheControllers(t, list, want[i].List)
|
||||
// And every membership the records issue, as the controller's MembershipFor makes it (ADR 0547 §5): the
|
||||
// grants part publishes them.
|
||||
raw, err := json.Marshal(MembershipsFor(f))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var gotM, wasM any
|
||||
if err := json.Unmarshal(raw, &gotM); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(want[i].Memberships, &wasM); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
heldEventsBeyond(gotM, wasM)
|
||||
if !reflect.DeepEqual(gotM, wasM) {
|
||||
t.Errorf("the memberships differ from the controller's:\n%s", raw)
|
||||
}
|
||||
})
|
||||
}
|
||||
golden(t, "user-lists.json", append(raw, '\n'))
|
||||
// An asker on a seat pulled by kind hears what that seat's workers gave up on, and no other stream's dead letters
|
||||
// (novox/hq issue 734: a check ask given up during a bus reload left its try running for ever).
|
||||
t.Run("an asker reads its seat's dead letters", func(t *testing.T) {
|
||||
@@ -373,13 +200,12 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
|
||||
t.Errorf("node one does not hear the vault's secret-changed: %v", perms.Subscribe)
|
||||
}
|
||||
// L3 s6 (ADR 0550 §2): while the vault serves `sign`, a node asks it in its own name alone, and reads the vault's
|
||||
// `signers` and the lease key, never writing either.
|
||||
// `signers`, never writing it; the ended controller's lease it reads no more (ADR 0590).
|
||||
signs, _ := PermissionsFor(Principal{Kind: KindNode, Node: "one", AsksTheVaultToSign: true})
|
||||
signersGet := "$JS.API.DIRECT.GET.KV_" + SignersBucketName + ".$KV." + SignersBucketName + ".key"
|
||||
leaseGet := "$JS.API.DIRECT.GET.KV_" + LeaseBucket + ".$KV." + LeaseBucket + ".holder"
|
||||
for subject, want := range map[string]bool{VaultSignCall(one): true, signersGet: true, leaseGet: true,
|
||||
VaultSignCall(Principal{Kind: KindNode, Node: "two"}): false, "$KV." + SignersBucketName + ".key": false,
|
||||
"$KV." + LeaseBucket + ".holder": false} {
|
||||
for subject, want := range map[string]bool{VaultSignCall(one): true, signersGet: true,
|
||||
"$JS.API.DIRECT.GET.KV_mesh-controller_lease.$KV.mesh-controller_lease.holder": false,
|
||||
VaultSignCall(Principal{Kind: KindNode, Node: "two"}): false, "$KV." + SignersBucketName + ".key": false} {
|
||||
if MayPublish(signs, subject) != want {
|
||||
t.Errorf("a node asking sign may publish %s: %v, want %v", subject, !want, want)
|
||||
}
|
||||
@@ -413,14 +239,159 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
})
|
||||
t.Run("the controller's golden server configuration", func(t *testing.T) {
|
||||
// 774/S39 (ADR 0583 §1): while a node rotates, both its logins are users with the node's permissions and its one
|
||||
// inbox, each calling in its own name; every node reads its own node-logins key alone; with no rotation the list
|
||||
// is the one above (AC2: the controller's golden lists, unchanged).
|
||||
t.Run("a node rotating has two logins of one node", func(t *testing.T) {
|
||||
vault := Seat{Name: VaultSeat, Serves: []string{"rotate-node", "node-account-confirm", "sign", "own"}}
|
||||
since := time.Date(2026, 11, 2, 1, 0, 0, 0, time.UTC)
|
||||
users, err := Users(Records{Nodes: []string{"ace", "g14"},
|
||||
Assigned: map[string][]Declared{"ace": {{Module: "mesh-vault", Holds: []Seat{vault}}}},
|
||||
NodeLogins: map[string]delivery.NodeLogin{
|
||||
"g14": {Node: "g14", Login: "node.g14", Rotating: "node.g14--b", Stage: delivery.RotationBegun, Since: since},
|
||||
// A node that rotated before and rotates no more logs in as its second login alone.
|
||||
"ace": {Node: "ace", Login: "node.ace--b", Since: since}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
perms := map[string]Permissions{}
|
||||
for _, u := range users {
|
||||
if u.Kind == KindNode {
|
||||
p, err := PermissionsFor(u)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
perms[u.Username()] = p
|
||||
}
|
||||
}
|
||||
if len(perms) != 3 || perms["node.g14"].Publish == nil || perms["node.g14--b"].Publish == nil || perms["node.ace--b"].Publish == nil {
|
||||
t.Fatalf("the node users are %v", slices.Sorted(maps.Keys(perms)))
|
||||
}
|
||||
// Equal, but for the caller each names in its calls (the bus's proof of which login called, ADR 0583 §3).
|
||||
own := func(p Permissions, login string) Permissions {
|
||||
swap := func(in []string) []string {
|
||||
out := make([]string, len(in))
|
||||
for i, s := range in {
|
||||
out[i] = strings.ReplaceAll(s, CallerToken(login), "<caller>")
|
||||
}
|
||||
return out
|
||||
}
|
||||
return Permissions{Publish: swap(p.Publish), PublishDeny: swap(p.PublishDeny), Subscribe: swap(p.Subscribe),
|
||||
AllowResponses: p.AllowResponses}
|
||||
}
|
||||
if a, b := own(perms["node.g14"], "node.g14"), own(perms["node.g14--b"], "node.g14--b"); !reflect.DeepEqual(a, b) {
|
||||
t.Errorf("the two logins' permissions differ:\n%v\n%v", a, b)
|
||||
}
|
||||
for login, p := range perms {
|
||||
node, _ := delivery.LoginNode(login)
|
||||
if !slices.Contains(p.Subscribe, "_INBOX.node."+node+".>") || slices.Contains(p.Subscribe, "_INBOX."+login+".>") && login != "node."+node {
|
||||
t.Errorf("%s's inbox: %v", login, p.Subscribe)
|
||||
}
|
||||
}
|
||||
// AC3: g14 reads node-logins key g14 and not ace, and writes neither; it confirms in its own login's name.
|
||||
get := func(key string) string {
|
||||
return "$JS.API.DIRECT.GET.KV_" + delivery.NodeLoginsBucket + ".$KV." + delivery.NodeLoginsBucket + "." + key
|
||||
}
|
||||
confirm := func(login string) string {
|
||||
return "mesh.seat.mesh-vault.call.node-account-confirm." + CallerToken(login)
|
||||
}
|
||||
for subject, want := range map[string]bool{get("g14"): true, get("ace"): false, "$KV." + delivery.NodeLoginsBucket + ".g14": false,
|
||||
confirm("node.g14--b"): true, confirm("node.g14"): false} {
|
||||
if MayPublish(perms["node.g14--b"], subject) != want {
|
||||
t.Errorf("node.g14--b may publish %s: %v, want %v", subject, !want, want)
|
||||
}
|
||||
}
|
||||
if plain, _ := PermissionsFor(Principal{Kind: KindNode, Node: "g14"}); MayPublish(plain, get("g14")) {
|
||||
t.Error("a node reads node-logins while the vault does not rotate")
|
||||
}
|
||||
// And nothing but the grants part names node-logins among its reads.
|
||||
if !ReaderMay(GrantsModule, delivery.NodeLoginsRead) || ReaderMay("shop", delivery.NodeLoginsRead) {
|
||||
t.Error("node-logins' named readers")
|
||||
}
|
||||
})
|
||||
// 774/S40/AC1 (ADR 0587 §4, §6, ADR 0590): an enrolling node's user may publish the redeem call in its own name and
|
||||
// nothing else, and subscribe its own inbox alone.
|
||||
t.Run("an enrolling node redeems at the seat alone", func(t *testing.T) {
|
||||
holder := Seat{Name: catalogue.EnrolmentSeat, Serves: []string{"issue", "redeem", "confirm", "revoke", "tokens", "again"}}
|
||||
{
|
||||
users, err := Users(Records{Nodes: []string{"ace"}, Enrolling: []string{"lab"},
|
||||
Assigned: map[string][]Declared{"ace": {{Module: "mesh-enrolment", Holds: []Seat{holder}}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var lab *Principal
|
||||
for i := range users {
|
||||
if users[i].Username() == "enrol.lab" {
|
||||
lab = &users[i]
|
||||
}
|
||||
}
|
||||
if lab == nil {
|
||||
t.Fatal("no user enrol.lab")
|
||||
}
|
||||
perms, err := PermissionsFor(*lab)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"mesh.seat.mesh-enrolment.call.redeem.enrol~lab"}
|
||||
if !reflect.DeepEqual(perms.Publish, want) || !reflect.DeepEqual(perms.Subscribe, []string{"_INBOX.enrol.lab.>"}) ||
|
||||
perms.AllowResponses {
|
||||
t.Errorf("enrol.lab publishes %v, subscribes %v, answers %v", perms.Publish, perms.Subscribe, perms.AllowResponses)
|
||||
}
|
||||
}
|
||||
// No holder serving redeem: the user may enrol nowhere, refused by name.
|
||||
if _, err := PermissionsFor(Principal{Kind: KindEnrolment, Node: "lab"}); err == nil {
|
||||
t.Error("an enrolment user that may enrol nowhere was composed")
|
||||
}
|
||||
})
|
||||
// 774/S41/AC1 (ADR 0587 §1-§3, ADR 0561 §5): mesh-enrolment writes its bucket (with per-key expiry) and calls the
|
||||
// vault's enrolment-token, node-account and sign in its own name; no other module or runtime may call those two,
|
||||
// however wide its grant; and its bucket is read by the grants part alone besides it.
|
||||
t.Run("mesh-enrolment alone asks the vault to enrol", func(t *testing.T) {
|
||||
vault := Seat{Name: VaultSeat, Serves: []string{"sign", "enrolment-token", "node-account"}}
|
||||
holder := Seat{Name: catalogue.EnrolmentSeat, Serves: []string{"issue", "redeem", "confirm", "revoke", "tokens", "again"}}
|
||||
calls := []string{"seat:mesh-vault.enrolment-token", "seat:mesh-vault.node-account", "seat:mesh-vault.sign"}
|
||||
users, err := Users(Records{Nodes: []string{"ace"}, Assigned: map[string][]Declared{"ace": {
|
||||
{Module: "mesh-vault", Holds: []Seat{vault}},
|
||||
// One process with a bus account of its own (ADR 0587 §1): never carried by the tool runner.
|
||||
{Module: "mesh-enrolment", RunsAs: "mesh-enrolment", Holds: []Seat{holder}, Invokes: calls,
|
||||
State: []Bucket{{Module: "mesh-enrolment", Name: "enrolments", KeyTTL: true}}},
|
||||
{Module: "shop", Invokes: calls[:2]},
|
||||
{Module: RuntimeModule}, {Module: "agent-tools", Invokes: []string{"*"}}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
call := func(verb, user string) string { return "mesh.seat.mesh-vault.call." + verb + "." + CallerToken(user) }
|
||||
for _, u := range users {
|
||||
perms, err := PermissionsFor(u)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
name := u.Username()
|
||||
for _, verb := range []string{"enrolment-token", "node-account"} {
|
||||
enrols := name == "ace.mesh-enrolment"
|
||||
if MayPublish(perms, call(verb, name)) != enrols || (!enrols && MayPublish(perms, "mesh.seat.mesh-vault.tool."+verb)) {
|
||||
t.Errorf("%s may call %s: %v", name, verb, !enrols)
|
||||
}
|
||||
}
|
||||
if name == "ace.mesh-enrolment" && (!MayPublish(perms, call("sign", name)) ||
|
||||
!MayPublish(perms, "$KV."+delivery.EnrolmentsBucket+".node.lab") || !MayPublish(perms, "$KV."+delivery.EnrolmentsBucket+".address.10.0.0.7")) {
|
||||
t.Errorf("mesh-enrolment does not sign or write its bucket: %v", perms.Publish)
|
||||
}
|
||||
if name != "ace.mesh-enrolment" && MayPublish(perms, "$KV."+delivery.EnrolmentsBucket+".node.lab") {
|
||||
t.Errorf("%s writes the enrolments", name)
|
||||
}
|
||||
}
|
||||
if !ReaderMay(GrantsModule, delivery.EnrolmentsRead) || ReaderMay("shop", delivery.EnrolmentsRead) {
|
||||
t.Error("the enrolments' named readers")
|
||||
}
|
||||
})
|
||||
t.Run("the golden server configuration", func(t *testing.T) {
|
||||
seat := Seat{Name: "telegram-sender", Accepts: []string{"send"}, Emits: []string{"delivered", "failed"}}
|
||||
got, err := Compose(
|
||||
Server{ClientPort: 4222, MonitoringPort: 8222, StoreDir: "/data",
|
||||
TLSCert: "/tls/tls.crt", TLSKey: "/tls/tls.key", TLSCA: "/tls/ca.crt"},
|
||||
[]Principal{
|
||||
{Kind: KindController, PasswordHash: "$2a$11$cccccccccccccccccccccc"},
|
||||
{Kind: KindEnrolment, Node: "one", PasswordHash: "$2a$11$eeeeeeeeeeeeeeeeeeeeee"},
|
||||
{Kind: KindEnrolment, Node: "one", Redeems: true, PasswordHash: "$2a$11$eeeeeeeeeeeeeeeeeeeeee"},
|
||||
{Kind: KindNode, Node: "one", PasswordHash: "$2a$11$nnnnnnnnnnnnnnnnnnnnnn"},
|
||||
{Kind: KindModule, Node: "one", Module: "telegram", Holds: []Seat{seat},
|
||||
Serves: []string{"status"}, PasswordHash: "$2a$11$tttttttttttttttttttttt"},
|
||||
@@ -435,11 +406,7 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want, err := os.ReadFile(filepath.Join("testdata", "composed.conf"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grantsAsTheControllers(t, got, string(want))
|
||||
golden(t, "composed.conf", []byte(got))
|
||||
})
|
||||
// 774/AC2 (novox/hq ADR 0561 §2): a wanted account round-trips through the grants part's state `accounts`, keyed by
|
||||
// its user, at the subject `$KV.mesh-grants_accounts.<user>`, read by the vault alone; and the vault's account-issued
|
||||
@@ -475,7 +442,7 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
|
||||
{User: "ace.x", Kind: KindModule, Node: "ace", Module: "y", URL: "nats://a:1"},
|
||||
{User: "ace.x.p", Kind: KindProcess, Node: "ace", Module: "x", URL: "nats://a:1"},
|
||||
{User: "node.ace", Kind: KindNode, Node: "ace", URL: "nats://a:1"},
|
||||
{User: "controller", Kind: KindController, URL: "nats://a:1"},
|
||||
{User: "controller", Kind: "controller", URL: "nats://a:1"},
|
||||
{User: "ace.x", Kind: KindModule, Node: "ace", Module: "x"},
|
||||
} {
|
||||
if bad.Valid() == nil {
|
||||
@@ -545,49 +512,18 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
|
||||
t.Error("an account was held with no password")
|
||||
}
|
||||
})
|
||||
// 774 subtasks 6 and 8 (novox/hq ADR 0561 §3, §6): minting and the grants part's consumers turn on only while the
|
||||
// lease is handed to a holder that is no controller; the vault and the grants part alone read the lease, whatever the
|
||||
// controller announced; a module's consumer is created as the controller's EnsureConsumer creates it.
|
||||
t.Run("the lease turns minting on only when handed away; a consumer is created as the controller's", func(t *testing.T) {
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
raw string
|
||||
found bool
|
||||
err error
|
||||
on bool
|
||||
}{
|
||||
{"a controller holds it", `{"instance":"novox/1234/2026-10-27T20:00:00Z","epoch":57}`, true, nil, false},
|
||||
{"handed away", `{"handed-to":"the operator's hand-over","handed-by":"person.jochen"}`, true, nil, true},
|
||||
{"expired or deleted", ``, false, nil, false},
|
||||
{"unreadable", ``, false, errors.New("refused"), false},
|
||||
{"not a holder", `[1]`, true, nil, false},
|
||||
{"both a controller and a handed holder", `{"instance":"novox/1","handed-to":"x"}`, true, nil, false},
|
||||
} {
|
||||
if on, why := LeaseHandedAway([]byte(row.raw), row.found, row.err); on != row.on || why == "" {
|
||||
t.Errorf("%s: on %v (%s), want %v", row.name, on, why, row.on)
|
||||
}
|
||||
// The assigner's grants source follows it: the grants part's bucket only for a handed-away lease (issue 774).
|
||||
on, _ := LeaseHandedAway([]byte(row.raw), row.found, row.err)
|
||||
if bucket, _ := delivery.GrantSource(on); (bucket == delivery.PartGrantsBucket) != row.on {
|
||||
t.Errorf("%s: the grants source is %s", row.name, bucket)
|
||||
}
|
||||
}
|
||||
announced := map[string]announce.States{ControllerSeat: ControllerAnnouncement("novox")}
|
||||
for reader, want := range map[string]bool{VaultSeat: true, GrantsModule: true, "mesh-delivery-assigner": true, "mesh-delivery-view": false} {
|
||||
granted, _ := ReadsAnnouncedBy(reader, []string{ControllerLease}, announced)
|
||||
if (len(granted) == 1) != want {
|
||||
t.Errorf("%s reads the lease: %v, want %v", reader, granted, want)
|
||||
}
|
||||
}
|
||||
// Issue 774 (sweep item 2): Users judges every read for its reader again, whatever split the caller made: the
|
||||
// controller's own reader-less copy refused the vault the lease and the grants part's accounts it is named for.
|
||||
// 774 subtask 8 (novox/hq ADR 0561 §6): a module's consumer is created as the grants part creates it, and every read is
|
||||
// judged for its reader.
|
||||
t.Run("every read is judged for its reader; a consumer is created as the grants part creates it", func(t *testing.T) {
|
||||
announced := map[string]announce.States{}
|
||||
// Issue 774 (sweep item 2): Users judges every read for its reader again, whatever split the caller made: a
|
||||
// reader-less split refused the vault the grants part's accounts it is named for.
|
||||
announced["mesh-grants"] = announce.States{Module: "mesh-grants", States: []announce.State{{Name: "accounts", Readers: []string{VaultSeat}}}}
|
||||
announced["disk-load"] = announce.States{Module: "disk-load", States: []announce.State{{Name: "readings"}}}
|
||||
judged, err := Users(Records{Nodes: []string{"a"}, Announced: announced, Assigned: map[string][]Declared{"a": {
|
||||
{Module: VaultSeat, ReadsRefused: []string{ControllerLease, GrantsAccounts}},
|
||||
{Module: "mesh-config", ReadsRefused: []string{ControllerLease}},
|
||||
{Module: VaultSeat, ReadsRefused: []string{GrantsAccounts}},
|
||||
{Module: "restic", ReadsRefused: []string{"disk-load.readings"}},
|
||||
{Module: "messenger", Reads: []string{ControllerLease, GrantsAccounts}}}}})
|
||||
{Module: "messenger", Reads: []string{GrantsAccounts}}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -597,7 +533,7 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
|
||||
reads[u.Module] = strings.Join(u.Reads, ",")
|
||||
}
|
||||
}
|
||||
if reads[VaultSeat] != ControllerLease+","+GrantsAccounts || reads["mesh-config"] != ControllerLease || reads["restic"] != "" || reads["messenger"] != "" {
|
||||
if reads[VaultSeat] != GrantsAccounts || reads["restic"] != "" || reads["messenger"] != "" {
|
||||
t.Errorf("the reads granted for their readers: %v", reads)
|
||||
}
|
||||
// AccountsEntry is the very lines ComposeAccounts writes for a user, so a writer of kept users repeats none.
|
||||
@@ -646,18 +582,12 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
|
||||
// healthChecks are the health holders' credential cases (ADR 0564 clauses 2, 5, 8; Limits 2, 4, 8; issue 1952 S5).
|
||||
func healthChecks(t *testing.T) {
|
||||
t.Helper()
|
||||
announced := map[string]announce.States{ControllerSeat: ControllerAnnouncement("novox"),
|
||||
announced := map[string]announce.States{
|
||||
"mesh-healers": {Module: "mesh-healers", States: []announce.State{{Name: "attempts", Readable: true}, {Name: "would", Readable: true}}}}
|
||||
for _, c := range []struct {
|
||||
reader, read string
|
||||
may bool
|
||||
}{
|
||||
{"mesh-conditions", ControllerLease, true}, {"mesh-healers", ControllerLease, true}, {WatchmanModule, ControllerLease, true},
|
||||
{"mesh-self-check", ControllerLease, false}, {"messenger", ControllerLease, false}, {ArtifactStoreTools, ControllerLease, true},
|
||||
{"mesh-conditions", ControllerConditions, true}, {"mesh-healers", ControllerConditions, false}, {"messenger", ControllerConditions, false},
|
||||
{"mesh-healers", ControllerHandActs, true}, {"mesh-conditions", ControllerHandActs, false},
|
||||
// ADR 0565 clauses 10, 11 (issue 1953 L5-S4): mesh-hand-acts reads the controller's log beside its own.
|
||||
{"mesh-hand-acts", ControllerHandActs, true},
|
||||
{"mesh-conditions", "mesh-healers.attempts", true}, {"mesh-conditions", HealersWould, false}, {"mesh-delivery-view", HealersWould, false},
|
||||
// ADR 0577 clause 1: the node-engine's data items are disk-load's alone to read.
|
||||
{"disk-load", delivery.EngineDataRead, true}, {"restic", delivery.EngineDataRead, false},
|
||||
@@ -670,7 +600,7 @@ func healthChecks(t *testing.T) {
|
||||
bucket := func(m, n string) Bucket { return Bucket{Module: m, Name: n} }
|
||||
users, err := Users(Records{Nodes: []string{"a"}, Assigned: map[string][]Declared{"a": {
|
||||
{Module: "mesh-conditions", State: []Bucket{bucket("mesh-conditions", "conditions"), bucket("mesh-conditions", "takeover")},
|
||||
Reads: []string{"mesh-self-check.findings", "mesh-healers.attempts", ControllerConditions}},
|
||||
Reads: []string{"mesh-self-check.findings", "mesh-healers.attempts"}},
|
||||
{Module: "mesh-self-check", State: []Bucket{bucket("mesh-self-check", "findings")}, Reads: []string{"restic.good"}},
|
||||
// Issue 1955 L7: the grants part keeps the hearings it withholds; the self-check reads them.
|
||||
{Module: GrantsModule, State: []Bucket{bucket(GrantsModule, GrantsWithheldState)}},
|
||||
@@ -701,7 +631,7 @@ func healthChecks(t *testing.T) {
|
||||
{Module: "telegram", Holds: []Seat{{Name: "telegram-sender", Accepts: []string{"send"}}},
|
||||
Processes: []ProcessPrincipal{{Process: "w", Queues: []QueueOwner{{Process: "w", Stream: "M_WORK"}, {Process: "w", Stream: "KV_x"}}}}},
|
||||
{Module: "nats", SnapshotsTheBus: true, Processes: []ProcessPrincipal{{Process: "nats-tools",
|
||||
Removes: []string{catalogue.RemovesControllerDeadLetterConsumer, catalogue.RemovesEngineActs, catalogue.RemovesAssignmentsStream}}, {Process: "other"}}},
|
||||
Removes: []string{catalogue.RemovesEngineActs, catalogue.RemovesControllerLeftovers}}, {Process: "other"}}},
|
||||
{Module: "shop", Processes: []ProcessPrincipal{{Process: "nats-tools", Removes: []string{catalogue.RemovesEngineActs}}}},
|
||||
}}})
|
||||
if err != nil {
|
||||
@@ -740,7 +670,18 @@ func healthChecks(t *testing.T) {
|
||||
"a.nats.nats-tools": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$JS.API.CONSUMER.INFO.DEAD_LETTER_NOTICES.controller",
|
||||
"$KV.mesh-host_acts.g14.a1", "$JS.API.STREAM.INFO.KV_mesh-host_acts",
|
||||
// Research 072 row 33: the ASSIGNMENTS stream is read and removed at the lease write.
|
||||
"$JS.API.STREAM.INFO.ASSIGNMENTS", "$JS.API.STREAM.DELETE.ASSIGNMENTS"},
|
||||
"$JS.API.STREAM.INFO.ASSIGNMENTS", "$JS.API.STREAM.DELETE.ASSIGNMENTS",
|
||||
// Issue 1126 B5 (ADR 0590 clause 6): every leftover of the ended controller, each by its name, read and deleted.
|
||||
"$JS.API.STREAM.INFO.KV_mesh-controller_lease", "$JS.API.STREAM.DELETE.KV_mesh-controller_lease",
|
||||
"$JS.API.STREAM.DELETE.KV_mesh-controller_rollouts", "$JS.API.STREAM.DELETE.KV_mesh-controller_states-announced",
|
||||
"$JS.API.STREAM.DELETE.CONTROL", "$JS.API.STREAM.DELETE.OBJ_declaration-files-a",
|
||||
"$JS.API.CONSUMER.DELETE.EVENTS.controller", "$JS.API.CONSUMER.DELETE.EVENTS.controller-merges",
|
||||
"$JS.API.CONSUMER.DELETE.EVENTS.novox_mesh-delivery", "$JS.API.CONSUMER.DELETE.CONTROL.controller",
|
||||
// And the consumers no part composes that the controller made before it ended: mesh-catalog's of the
|
||||
// controller's built-before, and each node's engine's of restic's backed-up (issue 1126).
|
||||
"$JS.API.CONSUMER.INFO.EVENTS.novox_mesh-catalog", "$JS.API.CONSUMER.DELETE.EVENTS.novox_mesh-catalog",
|
||||
"$JS.API.CONSUMER.INFO.EVENTS.a_mesh-host", "$JS.API.CONSUMER.DELETE.EVENTS.a_mesh-host",
|
||||
"$JS.API.CONSUMER.INFO.CONTROL.controller"},
|
||||
}
|
||||
mayNot := map[string][]string{
|
||||
// The keeper writes no findings and no attempts; no observer writes the keeper's buckets (clause 2).
|
||||
@@ -783,8 +724,14 @@ func healthChecks(t *testing.T) {
|
||||
// outside the bus module).
|
||||
"a.nats.nats-tools": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.other", "$JS.API.CONSUMER.DELETE.EVENTS.a_m",
|
||||
"$KV.mesh-conditions_conditions.x", "$JS.API.STREAM.DELETE.KV_mesh-host_acts", "$JS.API.STREAM.PURGE.KV_mesh-host_acts",
|
||||
"$JS.API.STREAM.DELETE.EVENTS", "$JS.API.STREAM.PURGE.ASSIGNMENTS", "$JS.API.STREAM.DELETE.ASSIGNMENTS_X"},
|
||||
"a.nats.other": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$KV.mesh-host_acts.g14.a1"},
|
||||
"$JS.API.STREAM.DELETE.EVENTS", "$JS.API.STREAM.PURGE.ASSIGNMENTS", "$JS.API.STREAM.DELETE.ASSIGNMENTS_X",
|
||||
// It updates nothing, purges nothing, deletes no stream but the leftovers' and writes no key of them (ADR 0590).
|
||||
"$JS.API.STREAM.UPDATE.KV_mesh-host_acts", "$JS.API.STREAM.UPDATE.KV_mesh-controller_calls", "$JS.API.STREAM.UPDATE.EVENTS",
|
||||
"$JS.API.STREAM.UPDATE.KV_mesh-controller_lease", "$JS.API.STREAM.PURGE.KV_mesh-controller_lease",
|
||||
"$JS.API.STREAM.DELETE.KV_mesh-controller_other", "$JS.API.STREAM.DELETE.OBJ_declaration-files-zz",
|
||||
"$JS.API.CONSUMER.DELETE.EVENTS.a_mesh-delivery", "$KV.mesh-controller_lease.holder"},
|
||||
"a.nats.other": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$KV.mesh-host_acts.g14.a1",
|
||||
"$JS.API.STREAM.UPDATE.KV_mesh-controller_lease"},
|
||||
"a.nats": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$KV.mesh-host_acts.g14.a1"},
|
||||
"a.shop.nats-tools": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$KV.mesh-conditions_conditions.x", "$JS.API.STREAM.DELETE.ASSIGNMENTS"},
|
||||
}
|
||||
@@ -815,12 +762,25 @@ func healthChecks(t *testing.T) {
|
||||
MayPublish(perms, "$KV.mesh-host_health.b")) {
|
||||
t.Errorf("the watchman reads the health reports %v, %v", MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.b"), perms.Publish)
|
||||
}
|
||||
if u.Kind == KindNode && (!MayPublish(perms, "$KV.mesh-host_packages.a") || MayPublish(perms, "$KV.mesh-host_packages.b")) {
|
||||
// ADR 0565 clause 6, ADR 0590: and the ended controller's mirror it reads no more.
|
||||
if u.Kind == KindNode && (!MayPublish(perms, "$KV.mesh-host_packages.a") || MayPublish(perms, "$KV.mesh-host_packages.b") ||
|
||||
MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-controller_declared-packages.$KV.mesh-controller_declared-packages.a")) {
|
||||
t.Errorf("node %s writes the packages keys %v", u.Node, perms.Publish)
|
||||
}
|
||||
if u.Kind == KindModule && MayPublish(perms, "$KV.mesh-delivery-assigner_policies.x") {
|
||||
t.Errorf("%s writes the assigner's policies", u.Username())
|
||||
}
|
||||
// ADR 0571 clause 10 (issue 774, 774/S5/AC2): nobody hears memberships on mesh.assignment; they are read from
|
||||
// the grants part's state.
|
||||
if MaySubscribe(perms, "mesh.assignment.a.restic") {
|
||||
t.Errorf("%s hears mesh.assignment", u.Username())
|
||||
}
|
||||
}
|
||||
// Nor the runtime carrying the node's modules, which reads each one's membership from the state.
|
||||
if perms, err := PermissionsFor(Principal{Kind: KindNodeTools, Node: "a", Module: RuntimeModule,
|
||||
Carries: []Declared{{Module: "restic"}}}); err != nil || MaySubscribe(perms, "mesh.assignment.a.restic") ||
|
||||
!MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.a.restic") {
|
||||
t.Errorf("the runtime hears mesh.assignment or reads no membership: %v", err)
|
||||
}
|
||||
// 1953 S-age (ADR 0565 §10): a log that says an age keeps its entries that long and no longer; one that says none, as
|
||||
// long as the log (ADR 0297).
|
||||
@@ -918,52 +878,3 @@ func healthChecks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// heldEventsBeyond takes out of the copy's memberships the one row they list beyond the controller's on purpose (issue
|
||||
// 1952): a held seat's own events in its holder's seat traffic, so a module the tool runner carries may say them. The
|
||||
// controller's composer is frozen; any other difference is still a fault.
|
||||
func heldEventsBeyond(got, was any) {
|
||||
gotAll, _ := got.(map[string]any)
|
||||
wasAll, _ := was.(map[string]any)
|
||||
for key, m := range gotAll {
|
||||
gm, _ := m.(map[string]any)
|
||||
traffic, _ := gm["seat-traffic"].(map[string]any)
|
||||
if traffic == nil {
|
||||
continue
|
||||
}
|
||||
listedBefore := map[string]bool{}
|
||||
if wm, _ := wasAll[key].(map[string]any); wm != nil {
|
||||
if wt, _ := wm["seat-traffic"].(map[string]any); wt != nil {
|
||||
for _, p := range asStrings(wt["publish"]) {
|
||||
listedBefore[p] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
var kept []any
|
||||
for _, p := range asStrings(traffic["publish"]) {
|
||||
if parts := strings.Split(p, "."); !listedBefore[p] && len(parts) == 5 && parts[0] == "mesh" && parts[1] == "seat" && parts[3] == "event" {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, p)
|
||||
}
|
||||
if kept == nil {
|
||||
delete(traffic, "publish")
|
||||
} else {
|
||||
traffic["publish"] = kept
|
||||
}
|
||||
if len(traffic) == 0 {
|
||||
delete(gm, "seat-traffic")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func asStrings(v any) []string {
|
||||
list, _ := v.([]any)
|
||||
out := make([]string, 0, len(list))
|
||||
for _, x := range list {
|
||||
if s, ok := x.(string); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
+3
-128
@@ -1,91 +1,14 @@
|
||||
package buscompose
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// The compositions' buckets (novox/hq ADR 0539 clauses 2 and 4): one per node, `composed-<node>`, holding that node's
|
||||
// compositions, which only that node's node-engine reads, since a composition can carry a sealed value (ADR 0488 §2's
|
||||
// reason); and `mesh-controller_composed`, holding each composition's digest and the judgement of its move, which the
|
||||
// assigner reads. The controller writes both, by compare-and-set while it holds the lease; each keeps five revisions
|
||||
// of a key. The names and layouts are mesh-sdk's (delivery.ComposedBucketFor, ControllerComposedBucket, ComposedKey).
|
||||
|
||||
// ControllerComposed is the local name of the compositions' digests, as a reader names it in `reads` after
|
||||
// `mesh-controller.`: delivery.ControllerComposedBucket.
|
||||
const ControllerComposed = "composed"
|
||||
|
||||
// ComposedValueBytes is the most a composition may hold: a file over 64 KiB travels by digest, so a module's
|
||||
// composition is far smaller; just under the server's largest message, which also carries the subject and headers.
|
||||
const ComposedValueBytes = 1<<20 - 8<<10
|
||||
|
||||
// ComposedBucketBytes is a node's composition bucket's size: five revisions of every module's compositions of the node.
|
||||
const ComposedBucketBytes = 256 << 20
|
||||
|
||||
// ComposedDigestsBytes is the size of the bucket of digests: a value of a few hundred bytes per node, module and
|
||||
// fingerprint kept.
|
||||
const ComposedDigestsBytes = 64 << 20
|
||||
|
||||
// ComposedConfig is a node's composition bucket as the controller asserts it.
|
||||
func ComposedConfig(node string) (jetstream.KeyValueConfig, error) {
|
||||
bucket, err := delivery.ComposedBucketFor(node)
|
||||
if err != nil {
|
||||
return jetstream.KeyValueConfig{}, err
|
||||
}
|
||||
return jetstream.KeyValueConfig{
|
||||
Bucket: bucket,
|
||||
Description: "the compositions of " + node + " (novox/hq ADR 0539): each module's resources for this node at one " +
|
||||
"fingerprint, and the node's own, signed; written by the controller alone, read by this node's node-engine " +
|
||||
"alone; deleted when outside the module's five newest fingerprints and named by no want",
|
||||
History: delivery.ComposedHistory,
|
||||
MaxValueSize: ComposedValueBytes,
|
||||
MaxBytes: ComposedBucketBytes,
|
||||
Storage: jetstream.FileStorage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ComposedDigestsConfig is the bucket of the compositions' digests as the controller asserts it.
|
||||
func ComposedDigestsConfig() jetstream.KeyValueConfig {
|
||||
return jetstream.KeyValueConfig{
|
||||
Bucket: delivery.ControllerComposedBucket,
|
||||
Description: "each composition's digest and the judgement of its move from what the node runs (novox/hq ADR 0539 " +
|
||||
"clauses 2 and 14): written by the controller alone, read by the assigner",
|
||||
History: delivery.ControllerComposedHistory,
|
||||
MaxValueSize: 4 << 10,
|
||||
MaxBytes: ComposedDigestsBytes,
|
||||
Storage: jetstream.FileStorage,
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureComposed creates the bucket of digests and each named node's composition bucket if absent and brings their
|
||||
// options to match. An update, never a delete.
|
||||
func EnsureComposed(ctx context.Context, js jetstream.JetStream, nodes ...string) error {
|
||||
if _, err := js.CreateOrUpdateKeyValue(ctx, ComposedDigestsConfig()); err != nil {
|
||||
return fmt.Errorf("asserting bucket %s: %w", delivery.ControllerComposedBucket, err)
|
||||
}
|
||||
for _, node := range nodes {
|
||||
cfg, err := ComposedConfig(node)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := js.CreateOrUpdateKeyValue(ctx, cfg); err != nil {
|
||||
return fmt.Errorf("asserting bucket %s: %w", cfg.Bucket, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ComposedWrites is what the controller publishes to write a node's compositions: the bucket's own subjects.
|
||||
func ComposedWrites(node string) (string, error) {
|
||||
bucket, err := delivery.ComposedBucketFor(node)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "$KV." + bucket + ".>", nil
|
||||
}
|
||||
// The compositions' buckets (novox/hq ADR 0539 clauses 2 and 4): one per node, `composed-<node>`, holding the
|
||||
// compositions the ended controller made for that node, which only that node's node-engine reads. Nothing writes them
|
||||
// since the controller ended (ADR 0590); a node composes its own modules (ADR 0547 §2).
|
||||
|
||||
// ComposedReads is what a node's node-engine publishes to follow its wants and compositions (ADR 0539 clauses 1, 2 and
|
||||
// 9): every key of its own composition bucket, and its own wants (`want.<node>.<module>` and `want.<node>.node`) in the
|
||||
@@ -126,42 +49,6 @@ var NodeAgentHears = []string{"mesh.mod." + BackupHolderModule + ".event.backed-
|
||||
// A second holder of the seat is a change of this grant.
|
||||
const BackupHolderModule = "restic"
|
||||
|
||||
// administers says a person may invoke every tool (`*`): the operator.
|
||||
func administers(invokes []string) bool {
|
||||
for _, i := range invokes {
|
||||
if i == "*" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HandPathWrites is what the operator's account publishes for release 6's hand path (ADR 0539 clauses 2 and 10): a want
|
||||
// of any node, written and read back, in the assigner's bucket; each named node's compositions; the digests; and each
|
||||
// node's object bucket for a file over 64 KiB, whose put reads an object's metadata and purges a replaced one's chunks.
|
||||
func HandPathWrites(nodes []string) ([]string, error) {
|
||||
out := append([]string{"$KV." + delivery.AssignerWantsBucket + ".want.>"}, readGrants(delivery.AssignerWantsBucket, "want.>")...)
|
||||
out = append(out, "$KV."+delivery.ControllerComposedBucket+".>")
|
||||
out = append(out, readGrants(delivery.ControllerComposedBucket, "")...)
|
||||
for _, node := range nodes {
|
||||
w, err := ComposedWrites(node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bucket, _ := delivery.ComposedBucketFor(node)
|
||||
out = append(out, w)
|
||||
out = append(out, readGrants(bucket, "")...)
|
||||
files, err := DeclarationFilesWrites(node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stream := "OBJ_" + DeclarationFilesBucket + node
|
||||
out = append(out, files, "$JS.API.STREAM.INFO."+stream, "$JS.API.DIRECT.GET."+stream+".>",
|
||||
"$JS.API.STREAM.MSG.GET."+stream, "$JS.API.STREAM.PURGE."+stream)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DeclarationsStream is the stream of each node's last declaration, `mesh.node.<node>.declare` (issue 107). Release 6
|
||||
// sends none (ADR 0539 clauses 5 and 11): the stream is kept, with each node's consumer, only so a node away during the
|
||||
// switch finds its last declaration and with it the new node-engine; the controller deletes it once every node's have
|
||||
@@ -182,15 +69,3 @@ func RaiseAfterDeclarations(r Raiser) error {
|
||||
}
|
||||
return AssertMeshConsumers(r)
|
||||
}
|
||||
|
||||
// TakeInConsumer is the durable consumer the controller takes done keys in from, on the stream of each of TakeInBuckets
|
||||
// (novox/hq ADR 0544 §1).
|
||||
const TakeInConsumer = "mesh-controller-take-in"
|
||||
|
||||
// TakeInBuckets are the buckets the take-in consumes: the indexer's modules (each module's trunk value) and the builder's
|
||||
// builds (each build's key).
|
||||
var TakeInBuckets = []string{delivery.ModulesBucket, delivery.BuilderBuildsBucket}
|
||||
|
||||
// ConfigFollowConsumer is the durable consumer the controller follows mesh-config's resolved values with, on that
|
||||
// bucket's stream (novox/hq ADR 0545).
|
||||
const ConfigFollowConsumer = "mesh-controller-config-follow"
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
package buscompose
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// The controller's own key-value buckets (novox/hq to-be 45 §1, §6, §7).
|
||||
//
|
||||
// **What the controller must remember across its own restart, it keeps on the bus.** A call's
|
||||
// outcome lived in the memory of the process that served it (novox/hq issue 265), so a controller
|
||||
// replaced while a push ran answered "no such call" for the one thing its caller had been told to
|
||||
// ask about. The bus already outlives the controller and is the shape ADR 0201 gives a module's
|
||||
// current state: one value per key, written by one owner, read by anybody granted it. These are the
|
||||
// controller's, written by it alone — the writers table of to-be 45 §1 — and asserted on every start
|
||||
// like the streams, so a bus raised from nothing has them before the first call is served.
|
||||
|
||||
// CallsBucket keeps every call of the mesh's own verbs and what came of it; HandActsBucket every act
|
||||
// a person did by hand, with why; ConditionsBucket every condition open now (to-be 45 §2), one key
|
||||
// each, and ConditionHistoryBucket every transition of one — raised, changed, silenced, cleared —
|
||||
// for ninety days.
|
||||
//
|
||||
// **The history is a bucket of its own** because its keys expire and an open condition's must not:
|
||||
// a bucket has one age for every key, and a condition open longer than the history is kept would
|
||||
// otherwise vanish from the store while still true.
|
||||
var (
|
||||
CallsBucket = BucketName(ControllerSeat, "calls")
|
||||
HandActsBucket = BucketName(ControllerSeat, "hand-acts")
|
||||
ConditionsBucket = BucketName(ControllerSeat, "conditions")
|
||||
ConditionHistoryBucket = BucketName(ControllerSeat, "condition-history")
|
||||
// LeaseBucket holds the controller's lease (to-be 45 §6): one key, `holder`, which the instance
|
||||
// allowed to act writes by compare-and-set and renews; its revision when taken is the epoch.
|
||||
LeaseBucket = BucketName(ControllerSeat, "lease")
|
||||
// AskedBucket keeps what the controller asked the operator about its conditions (novox/hq ADR 0259):
|
||||
// each ask by its id, its options and the actions they stand for, how it ended and whether the
|
||||
// controller acted on its warrant — so a restart neither asks twice nor acts twice.
|
||||
AskedBucket = BucketName(ControllerSeat, "asked")
|
||||
// DeliveryMergesBucket holds the merges mesh-delivery announced it made, each with the ask whose Approve it merged
|
||||
// on (novox/hq ADR 0420 §7, issue 1025): per merge a key `<owner>/<repo>/<number>/<head sha>` holding the
|
||||
// announcement, kept DeliveryMergesKeptFor. What lets the build of a moved module register it from its new
|
||||
// repository: the controller reads the ask's warrant from the router and accepts only the operator's Approve of
|
||||
// those heads. Written by the controller alone, from the announcement, when it holds the lease.
|
||||
DeliveryMergesBucket = BucketName(ControllerSeat, "delivery-merges")
|
||||
)
|
||||
|
||||
// DeliveryMergesKeptFor is how long a merge mesh-delivery announced is kept: a week, as the events are.
|
||||
const DeliveryMergesKeptFor = 7 * 24 * time.Hour
|
||||
|
||||
// The controller's record mirrors (novox/hq ADR 0289, design 54, design 57 package F3): a bucket per kind of
|
||||
// record, one key per record, written from the store by the lease holder only when the record changed, with
|
||||
// one event on the controller's seat per write. Read by whatever names them in `reads` as
|
||||
// `mesh-controller.<mirror>` (ADR 0201 §1), written by nobody else. **No secret in either** (ADR 0201): an
|
||||
// assignment's value names its module and node; a module's declared config is what its manifest, which is
|
||||
// public, declares.
|
||||
//
|
||||
// AssignmentsBucket holds which module is assigned to which node, keyed `<node>.<module>`; and
|
||||
// DeclaredConfigBucket each registered module's config declarations as its manifest holds them, keyed by
|
||||
// the module: what mesh-config resolves a config value from (ADR 0348 clauses 2 and 4); and PredefinedBucket the
|
||||
// list of predefined keys the mesh published (the newest trunk build's of the catalogue, ADR 0348 §7), one key
|
||||
// `list`: what mesh-config publishes as mesh-config_predefined (design 57 K3, issue 662).
|
||||
var (
|
||||
AssignmentsBucket = BucketName(ControllerSeat, MirrorAssignments)
|
||||
DeclaredConfigBucket = BucketName(ControllerSeat, MirrorDeclaredConfig)
|
||||
PredefinedBucket = BucketName(ControllerSeat, MirrorPredefined)
|
||||
// DeclaredPackagesBucket holds, keyed by the node, each package the modules assigned to it declare, present or absent,
|
||||
// with the declaring modules: what the pacman module's verbs read to say a package is declared (ADR 0507 §5).
|
||||
DeclaredPackagesBucket = BucketName(ControllerSeat, MirrorDeclaredPackages)
|
||||
)
|
||||
|
||||
// The mirrors' local names, as a reader names them in `reads` after `mesh-controller.`.
|
||||
const (
|
||||
MirrorAssignments = "assignments"
|
||||
MirrorDeclaredConfig = "declared-config"
|
||||
MirrorPredefined = "predefined"
|
||||
// MirrorDeclaredPackages is the local name of the packages each node's modules declare (ADR 0507 §5, issue 1840/S3).
|
||||
MirrorDeclaredPackages = "declared-packages"
|
||||
)
|
||||
|
||||
// The chain's buckets (novox/hq ADR 0408, issue 1089): builds, rollouts and assignments are one chain kept on the bus
|
||||
// alone, one bucket each, one key per record, written by the lease holder by compare-and-set. The assignments bucket is
|
||||
// AssignmentsBucket above, which keeps its name and its readers (ADR 0408 §3). Nothing is removed from them (§4), they keep
|
||||
// ChainHistory earlier revisions of every key (§5), and a value is never cut (§6).
|
||||
var (
|
||||
BuildsBucket = BucketName(ControllerSeat, ChainBuilds)
|
||||
RolloutsBucket = BucketName(ControllerSeat, ChainRollouts)
|
||||
)
|
||||
|
||||
// **The assignments bucket keeps the marker age (MirrorMarkerTTL) it was made with as a mirror.** A stream that allows
|
||||
// messages an age cannot be made to disallow them (the server refuses the update: "message TTL status can not be
|
||||
// disabled", nats-server stream.go), so asserting the bucket without the age would fail the controller's start on a
|
||||
// mesh that has the bucket. The chain never purges or removes a key (ADR 0408 §4), so no marker is ever written and the
|
||||
// age ends nothing; it is kept only so the stream's configuration stays one the server accepts.
|
||||
//
|
||||
// The chain buckets' local names, as a reader names them in `reads` after `mesh-controller.`.
|
||||
const (
|
||||
ChainBuilds = "builds"
|
||||
ChainRollouts = "rollouts"
|
||||
)
|
||||
|
||||
// The chain buckets' bounds. ChainHistory is the bus's largest history (ADR 0408 §5). A value is at most ChainValueBytes,
|
||||
// just under the server's largest message of a mebibyte, which also carries the subject and the headers (ADR 0408 §6):
|
||||
// the controller writes none larger and the move refuses to. The buckets' size is the arithmetic of the survey of
|
||||
// issue 981: 8585 builds of 1 to 40 KB, 1049 rollouts of about 2 KB a revision and 64 revisions, so 512 MiB for the two
|
||||
// that grow, and the assignments, 285 of under a kilobyte, 64 MiB.
|
||||
const (
|
||||
ChainHistory = 64
|
||||
ChainValueBytes = 1<<20 - 8<<10
|
||||
ChainBuildsBytes = 512 << 20
|
||||
ChainRolloutsBytes = 512 << 20
|
||||
ChainAssignmentBytes = 64 << 20
|
||||
)
|
||||
|
||||
// GrantsBucket holds, keyed `<module>.<fingerprint hex>`, each fingerprint of a module whose bus credential the controller
|
||||
// has issued (novox/hq ADR 0528 clause 2, ADR 0429 §6 rule 6, issue 1394): written by the controller alone once the bus's
|
||||
// user list composed with the module's manifest at that fingerprint was sent to the bus's machine; read by the assigner,
|
||||
// whose sixth rule moves a node's want to a fingerprint only once its grant exists. The name and layout are mesh-sdk's
|
||||
// (delivery.ControllerGrantsBucket, ControllerGrantKey).
|
||||
var GrantsBucket = BucketName(ControllerSeat, ControllerGrants)
|
||||
|
||||
// ControllerGrants is the grants bucket's local name, as a reader names it in `reads` after `mesh-controller.`.
|
||||
const ControllerGrants = "grants"
|
||||
|
||||
// ChainBuckets are the three buckets of the chain: builds, rollouts and assignments.
|
||||
func ChainBuckets() []string { return []string{BuildsBucket, RolloutsBucket, AssignmentsBucket} }
|
||||
|
||||
// MirrorsWrittenByAnother are the controller's mirrors whose writer is not the controller, each with its reason: the rule
|
||||
// that a mirror is written by the controller alone (TestTheMirrorsListAndTheWritersTableAgree) has these exceptions and no
|
||||
// other.
|
||||
var MirrorsWrittenByAnother = map[string]string{}
|
||||
|
||||
// The mirrors' caps (design 54 §5): one value per key, no age, 64 KiB a value, 64 MiB a bucket.
|
||||
const (
|
||||
MirrorValueBytes = 64 << 10
|
||||
MirrorBytes = 64 << 20
|
||||
// MirrorMarkerTTL is the longest a purge's marker is kept (design 54 §2): the bucket allows a
|
||||
// per-message age for it, and the writer gives each marker a day.
|
||||
MirrorMarkerTTL = 24 * time.Hour
|
||||
)
|
||||
|
||||
// PlanStateBuckets are the buckets of the plans' state that release 6 deletes (novox/hq ADR 0539 clause 13): the merge
|
||||
// window's clock, the silent sends' waits and the building plans' hold. The controller deletes each at its first start
|
||||
// and says it once (composed_start.go); none is asserted, granted or written any more.
|
||||
var PlanStateBuckets = []string{BucketName(ControllerSeat, "merge-window"), BucketName(ControllerSeat, "rollout-silent"),
|
||||
BucketName(ControllerSeat, "plans-building")}
|
||||
|
||||
// AskedKeptFor is how long an ask is kept after it was made: a month, as the router keeps its own.
|
||||
const AskedKeptFor = 30 * 24 * time.Hour
|
||||
|
||||
// LeaseTTL is how long the lease's key lives unrenewed (to-be 45 §6): fifteen seconds, renewed
|
||||
// every five. The bucket's age, so the bus forgets a holder that stopped renewing.
|
||||
const LeaseTTL = 15 * time.Second
|
||||
|
||||
// The bounds to-be 45 §6 sets for calls: the last thousand, or fourteen days, whichever is fewer.
|
||||
// A call is two keys — its record, and its answer apart so a listing does not read every answer —
|
||||
// so the stream holds twice as many messages as it keeps calls.
|
||||
const (
|
||||
KeptCallsDurably = 1000
|
||||
CallsKeptFor = 14 * 24 * time.Hour
|
||||
// CallAnswerBytes is the most of one answer kept: a whole declaration is far smaller, and an
|
||||
// answer larger is cut and says so.
|
||||
CallAnswerBytes = 64 << 10
|
||||
// HandActsKeptFor is as long as a condition's history (to-be 45 §2): an act by hand is read
|
||||
// back beside what it addressed.
|
||||
HandActsKeptFor = 90 * 24 * time.Hour
|
||||
// ConditionHistoryKeptFor is how long a condition's transitions are kept (to-be 45 §2).
|
||||
ConditionHistoryKeptFor = 90 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// IsControllerBucket says a bucket is the controller's own, not a module's state nothing declares.
|
||||
func IsControllerBucket(bucket string) bool {
|
||||
return bucket == CallsBucket || bucket == HandActsBucket || bucket == ConditionsBucket ||
|
||||
bucket == ConditionHistoryBucket || bucket == LeaseBucket || bucket == AskedBucket ||
|
||||
bucket == AssignmentsBucket || bucket == DeclaredConfigBucket || bucket == PredefinedBucket ||
|
||||
bucket == DeclaredPackagesBucket ||
|
||||
bucket == DeliveryMergesBucket || bucket == BuildsBucket || bucket == RolloutsBucket ||
|
||||
bucket == delivery.HaveBucket || bucket == StatesAnnouncedBucket ||
|
||||
bucket == GrantsBucket || bucket == delivery.ControllerComposedBucket
|
||||
}
|
||||
|
||||
// ControllerBuckets are the controller's own buckets, in the order they are asserted.
|
||||
func ControllerBuckets() []string {
|
||||
return []string{LeaseBucket, CallsBucket, HandActsBucket, ConditionsBucket, ConditionHistoryBucket, AskedBucket,
|
||||
AssignmentsBucket, DeclaredConfigBucket, PredefinedBucket, DeclaredPackagesBucket,
|
||||
DeliveryMergesBucket, BuildsBucket, RolloutsBucket, delivery.HaveBucket, StatesAnnouncedBucket, GrantsBucket,
|
||||
delivery.ControllerComposedBucket}
|
||||
}
|
||||
|
||||
// ControllerBucketsAsserter is what raising the controller's buckets needs of a connection.
|
||||
type ControllerBucketsAsserter interface {
|
||||
EnsureControllerBuckets() error
|
||||
}
|
||||
|
||||
// chainBucket is the options of a bucket of the chain: sixty-four revisions of each key, no age, a value up to
|
||||
// ChainValueBytes, on disk.
|
||||
func chainBucket(bucket, why string, bytes int64, markers time.Duration) jetstream.KeyValueConfig {
|
||||
return jetstream.KeyValueConfig{
|
||||
Bucket: bucket,
|
||||
Description: why,
|
||||
History: ChainHistory,
|
||||
MaxValueSize: ChainValueBytes,
|
||||
MaxBytes: bytes,
|
||||
Storage: jetstream.FileStorage,
|
||||
LimitMarkerTTL: markers,
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureLeaseBucket creates the lease's bucket if absent and brings its options to match (to-be 45
|
||||
// §6). **Before the lease is taken, by any candidate**: it is the lease's own precondition, and asserting
|
||||
// a bucket that exists changes nothing. File storage, so its revisions — the epochs — outlive a restart of
|
||||
// the bus; one raised again from nothing is moved past the highest epoch issued when the lease is taken.
|
||||
func EnsureLeaseBucket(ctx context.Context, js jetstream.JetStream) error {
|
||||
if _, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{
|
||||
Bucket: LeaseBucket,
|
||||
Description: "the controller's lease (novox/hq to-be 45 §6): the key `holder`, written by compare-and-set " +
|
||||
"by the one controller instance that may act and renewed every five seconds; its revision when taken " +
|
||||
"is the epoch every declaration and plan write carries",
|
||||
History: 1,
|
||||
TTL: LeaseTTL,
|
||||
MaxValueSize: 4 << 10,
|
||||
MaxBytes: 1 << 20,
|
||||
Storage: jetstream.FileStorage,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("asserting bucket %s: %w", LeaseBucket, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HaveBucketConfig is the options of the have bucket (novox/hq ADR 0429 §1): delivery.HaveHistory revisions of each
|
||||
// key, no age, 64 MiB on disk like the assignments. A module's have is JSON of about 500 bytes, but a node's own key
|
||||
// carries its whole facts (its profile among them), several kilobytes on a desktop: a value is as large as the chain's
|
||||
// buckets take, since a value is never cut (novox/hq ADR 0408 §6, issue 1162).
|
||||
func HaveBucketConfig() jetstream.KeyValueConfig {
|
||||
return jetstream.KeyValueConfig{
|
||||
Bucket: delivery.HaveBucket,
|
||||
Description: "what each machine has of each module, one key `<node>.<module>` (and `<node>.node` for the machine's " +
|
||||
"own facts) (novox/hq ADR 0429, design 47): written by each machine's node-engine under its own node's keys " +
|
||||
"alone, never by the controller; read by the delivery view and the watchman",
|
||||
History: delivery.HaveHistory,
|
||||
MaxValueSize: ChainValueBytes,
|
||||
MaxBytes: 64 << 20,
|
||||
Storage: jetstream.FileStorage,
|
||||
}
|
||||
}
|
||||
@@ -10,64 +10,94 @@ import (
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
)
|
||||
|
||||
// **The health holders' grants** (novox/hq ADR 0564 clauses 2, 5 and 8, Limits 2, 4 and 8; issue 1952 S5).
|
||||
// **The health holders' grants** (novox/hq ADR 0564 clauses 2, 5 and 8, Limits 2, 4 and 8; issue 1952 S5; ADR 0590).
|
||||
//
|
||||
// - The condition keeper reads the controller's open conditions until the lease write, for what clauses 8 and 9 carry
|
||||
// from them, and mesh-healers the controller's hand-act log, for S15 (Limits 2): both buckets the controller
|
||||
// announces readable by nobody, so each is read by its named reader alone. The keeper, mesh-healers and the
|
||||
// watchman read the controller's lease (Limits 8). Nobody reads mesh-healers' `would` (clause 8): what the healers
|
||||
// would have done reaches no condition. Every observer's `findings`, the healers' `attempts`, the wants and the
|
||||
// haves are read through `reads` as any announced state is; and no module writes another's state, so an observer
|
||||
// can never write the keeper's buckets (clause 2).
|
||||
// - The watchman makes its own consumer on DEAD_LETTER_NOTICES, by its own name, pulled, and reads whether the
|
||||
// controller's consumer `controller` still stands; it may neither delete nor change that consumer (clause 5). It
|
||||
// lists and shows DEAD_LETTERS, which is all it does before the lease write.
|
||||
// - Nobody reads mesh-healers' `would` (clause 8): what the healers would have done reaches no condition. Every
|
||||
// observer's `findings`, the healers' `attempts`, the wants and the haves are read through `reads` as any announced
|
||||
// state is; and no module writes another's state, so an observer can never write the keeper's buckets (clause 2).
|
||||
// - The watchman makes its own consumer on DEAD_LETTER_NOTICES, by its own name, pulled, and reads whether the ended
|
||||
// controller's consumer `controller` still stands; it may neither delete nor change that consumer (clause 5).
|
||||
// - The bus module's tools run on an account of their own, declared by their bundle's `bus` with `removes`
|
||||
// (catalogue.BusAccounts), which may remove only the consumer `controller` on DEAD_LETTER_NOTICES and keys of the
|
||||
// node-engine's acts bucket (Limits 4), never the backup's account `broker`, which only reads (ADR 0235).
|
||||
// (catalogue.BusAccounts), which may remove only keys of the node-engine's acts bucket (Limits 4) and the ended
|
||||
// controller's leftovers (ADR 0590 clause 6), never the backup's account `broker`, which only reads (ADR 0235).
|
||||
|
||||
// The states the composer names its readers for, beside the lease (NamedReaders).
|
||||
var (
|
||||
// ControllerConditions is the controller's open conditions, read by the keeper alone (ADR 0564 clauses 8, 9).
|
||||
ControllerConditions = ControllerSeat + ".conditions"
|
||||
// ControllerHandActs is the controller's hand-act log, read by mesh-healers alone of the four health holders (ADR
|
||||
// 0564 Limits 2).
|
||||
ControllerHandActs = ControllerSeat + ".hand-acts"
|
||||
// HealersWould is what mesh-healers would have done while the lease names a controller: read by nobody (clause 8).
|
||||
HealersWould = health.HealersModule + "." + health.WouldState
|
||||
)
|
||||
// HealersWould is what mesh-healers would have done while a controller was placed: read by nobody (ADR 0564 clause 8).
|
||||
var HealersWould = health.HealersModule + "." + health.WouldState
|
||||
|
||||
// BusRemovalGrants are the whole authority of an account whose bus declares removals (ADR 0564 Limits 4, issue 1952
|
||||
// notes 6 and 7), each removal its grant: the controller's consumer on DEAD_LETTER_NOTICES read and deleted (the lease
|
||||
// write's step, clause 5); the node-engine's acts bucket read (STREAM.INFO with a subject filter lists what a removal
|
||||
// would take, so the tools read before they remove) and its keys removed, which nothing else may empty when it fills; the ASSIGNMENTS stream read and deleted whole (the lease
|
||||
// write, research 072 row 33). A
|
||||
// removal outside the set is refused, as the indexer's judge refuses it in a manifest (novox/hq ADR 0578 §1).
|
||||
func BusRemovalGrants(removes []string) ([]string, error) {
|
||||
// ControllerLeftoverConsumers are the ended controller's durable consumers left on the bus, one leftover of the module
|
||||
// mesh-delivery placed nowhere (issue 1126, milestone section 3), and mesh-catalog's of the controller's built-before,
|
||||
// which its manifest no longer consumes and no part composes; each `<stream>.<consumer>`.
|
||||
var ControllerLeftoverConsumers = []string{
|
||||
"EVENTS." + ControllerName, "EVENTS." + MergesConsumerName, "EVENTS.novox_mesh-delivery", "EVENTS.novox_mesh-catalog",
|
||||
"CONTROL." + ControllerName, DeadLetterNoticesStream + "." + ControllerName,
|
||||
}
|
||||
|
||||
// ControllerLeftoverConsumersOn are ControllerLeftoverConsumers and, for each node given, the consumer the controller
|
||||
// made for the node's engine (`EVENTS.<node>_mesh-host`, of restic's backed-up): the engine hears that event without a
|
||||
// durable consumer, and the grants part composes none for it, so nothing reads it (issue 1126).
|
||||
func ControllerLeftoverConsumersOn(nodes []string) []string {
|
||||
out := slices.Clone(ControllerLeftoverConsumers)
|
||||
for _, n := range nodes {
|
||||
if safeSubject.MatchString(n) {
|
||||
out = append(out, "EVENTS."+n+"_"+EngineModule)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ControllerLeftoverBuckets are the ended controller's buckets, each by its local name (`KV_mesh-controller_<name>`).
|
||||
var ControllerLeftoverBuckets = []string{"asked", "assignments", "builds", "calls", "checks-running", "composed",
|
||||
"condition-history", "conditions", "declared-config", "declared-packages", "delivery-merges", "grants", "hand-acts",
|
||||
"lease", "predefined", "rollouts", "states-announced"}
|
||||
|
||||
// ControllerLeftoverStreams are the ended controller's streams, by their names: CONTROL, ASSIGNMENTS, each of its buckets'
|
||||
// and, for each node given, the declaration files' object bucket the controller wrote (ADR 0486 §2).
|
||||
func ControllerLeftoverStreams(nodes []string) []string {
|
||||
out := []string{"CONTROL", AssignmentsStream}
|
||||
for _, b := range ControllerLeftoverBuckets {
|
||||
out = append(out, "KV_"+BucketName(catalogue.EndedControllerName, b))
|
||||
}
|
||||
for _, n := range nodes {
|
||||
if safeSubject.MatchString(n) {
|
||||
out = append(out, "OBJ_"+DeclarationFilesBucket+n)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// BusRemovalGrants are the whole authority of an account whose bus declares removals (ADR 0564 Limits 4, ADR 0590 clause
|
||||
// 6), each removal its grant: the node-engine's acts bucket read (STREAM.INFO with a subject filter lists what a removal
|
||||
// would take, so the tools read before they remove) and its keys removed, which nothing else may empty when it fills; and
|
||||
// the ended controller's leftovers, each consumer and each stream read and deleted by its name, for the nodes given (the
|
||||
// declaration files' object buckets). A removal outside the set is refused, as the indexer's judge refuses it in a
|
||||
// manifest (novox/hq ADR 0578 §1).
|
||||
func BusRemovalGrants(removes, nodes []string) ([]string, error) {
|
||||
var out []string
|
||||
for _, r := range removes {
|
||||
switch r {
|
||||
case catalogue.RemovesControllerDeadLetterConsumer:
|
||||
out = append(out, "$JS.API.CONSUMER.INFO."+DeadLetterNoticesStream+"."+ControllerName,
|
||||
"$JS.API.CONSUMER.DELETE."+DeadLetterNoticesStream+"."+ControllerName)
|
||||
case catalogue.RemovesEngineActs:
|
||||
out = append(out, "$JS.API.STREAM.INFO.KV_"+EngineActsBucket, "$KV."+EngineActsBucket+".>")
|
||||
case catalogue.RemovesAssignmentsStream:
|
||||
out = append(out, "$JS.API.STREAM.INFO."+AssignmentsStream, "$JS.API.STREAM.DELETE."+AssignmentsStream)
|
||||
case catalogue.RemovesControllerLeftovers:
|
||||
for _, c := range ControllerLeftoverConsumersOn(nodes) {
|
||||
out = append(out, "$JS.API.CONSUMER.INFO."+c, "$JS.API.CONSUMER.DELETE."+c)
|
||||
}
|
||||
for _, s := range ControllerLeftoverStreams(nodes) {
|
||||
out = append(out, "$JS.API.STREAM.INFO."+s, "$JS.API.STREAM.DELETE."+s)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("a bus account removes %q, which is not one of %s (novox/hq ADR 0564 Limits 4)", r,
|
||||
strings.Join([]string{catalogue.RemovesControllerDeadLetterConsumer, catalogue.RemovesEngineActs,
|
||||
catalogue.RemovesAssignmentsStream}, ", "))
|
||||
return nil, fmt.Errorf("a bus account removes %q, which is not one of %s (novox/hq ADR 0564 Limits 4, ADR 0590)", r,
|
||||
strings.Join([]string{catalogue.RemovesEngineActs, catalogue.RemovesControllerLeftovers}, ", "))
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// removesAssignmentsStream is an account declared to remove the stream ASSIGNMENTS: the writers table's one exemption from
|
||||
// stream definitions being the controller's, for deleting that one stream at the lease write (research 072 row 33).
|
||||
func removesAssignmentsStream(p Principal, pattern string) bool {
|
||||
return p.Kind == KindProcess && slices.Contains(p.Removes, catalogue.RemovesAssignmentsStream) &&
|
||||
pattern == "$JS.API.STREAM.DELETE."+AssignmentsStream
|
||||
// removesLeftover is an account declared to remove the ended controller's leftovers deleting one of its streams: the
|
||||
// writers table's one exemption from stream definitions being the grants part's (ADR 0590 clause 6).
|
||||
func removesLeftover(p Principal, pattern string) bool {
|
||||
name, ok := strings.CutPrefix(pattern, "$JS.API.STREAM.DELETE.")
|
||||
return ok && p.Kind == KindProcess && slices.Contains(p.Removes, catalogue.RemovesControllerLeftovers) &&
|
||||
slices.Contains(ControllerLeftoverStreams(p.FilesFor), name)
|
||||
}
|
||||
|
||||
// removesEngineActs is an account declared to remove keys of the node-engine's acts bucket: the writers table's one
|
||||
@@ -114,7 +144,7 @@ func WatchmanDeadLetterGrants(sources []string) []string {
|
||||
// WatchmanHears are the core subjects the watchman subscribes, with no durable consumer (ADR 0564 clauses 4, 5 and
|
||||
// Limits 3, 11): the power seat's words (S1, ADR 0211), the providers' failing and recovered words (S8), each tool
|
||||
// runner's beat (S11), the self-check's checked (S10), a consumer deleted and the bus module's slow consumers and closed
|
||||
// connections (S9), the provisioner clients' reports (S21), and the dead letters kept. Never the bus's API audit
|
||||
// connections (S9), the provisioner clients' reports (S21), the dead letters kept, and the lease write's events. Never the bus's API audit
|
||||
// (`$JS.EVENT.ADVISORY.API`): it carries every API call's request and answer in the account, the vault's hashes among
|
||||
// them (the lead, 2026-10-29).
|
||||
func WatchmanHears() []string {
|
||||
@@ -178,6 +208,5 @@ func deadLetterSourcesOf(r Records) []string {
|
||||
// keeps them until its lease is handed away and the watchman after (ADR 0564 clause 5). Delivering again stays the
|
||||
// controller's in L4.
|
||||
func keepsDeadLetters(p Principal, pattern string) bool {
|
||||
return isController(p, pattern) ||
|
||||
(p.Kind == KindModule && p.Module == WatchmanModule && pattern == deadLetterPrefix+">")
|
||||
return p.Kind == KindModule && p.Module == WatchmanModule && pattern == deadLetterPrefix+">"
|
||||
}
|
||||
|
||||
@@ -53,9 +53,6 @@ func MembershipFor(node string, d Declared, where Placements) Membership {
|
||||
m.Serves = append(m.Serves, Served{Subject: own + ".tool.{tool}", Queue: "serve." + d.Module})
|
||||
}
|
||||
for _, s := range d.Holds {
|
||||
if servedOnlyByTheController(s) {
|
||||
continue // answered by the serving controller alone, never through a membership
|
||||
}
|
||||
for _, verb := range s.Serves {
|
||||
m.Seats = append(m.Seats, SeatServed{Seat: s.Name, Verb: verb, Subject: seatToolSubject(s, verb, node)})
|
||||
}
|
||||
|
||||
+150
-465
@@ -15,13 +15,13 @@ package buscompose
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.novox.be/novox/mesh-sdk/go/announce"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
@@ -31,10 +31,9 @@ import (
|
||||
type Kind string
|
||||
|
||||
const (
|
||||
KindModule Kind = "module"
|
||||
KindNode Kind = "node"
|
||||
KindController Kind = "controller"
|
||||
KindEnrolment Kind = "enrolment"
|
||||
KindModule Kind = "module"
|
||||
KindNode Kind = "node"
|
||||
KindEnrolment Kind = "enrolment"
|
||||
// KindPerson is somebody reaching the mesh's tools from a workstation (design 25 §7). Its
|
||||
// authority is a list of tools and nothing else — not control, not declarations, not builds,
|
||||
// and no ability to answer anything, because a person asks.
|
||||
@@ -107,8 +106,9 @@ type Principal struct {
|
||||
// Username and inbox are <node>.<module>.<process id>.
|
||||
Process string
|
||||
|
||||
// FilesFor are the nodes whose declaration-file buckets this principal writes, for the controller alone (novox/hq ADR 0486 §2):
|
||||
// every node of the mesh, one bucket each.
|
||||
// FilesFor are the nodes whose declaration-file buckets (novox/hq ADR 0486 §2) this principal may remove, for the bus
|
||||
// module's tools removing the ended controller's leftovers alone (ADR 0590 clause 6): every node of the mesh, one
|
||||
// bucket each.
|
||||
FilesFor []string
|
||||
|
||||
Emits []string
|
||||
@@ -144,30 +144,15 @@ type Principal struct {
|
||||
// permission and nothing beside it.
|
||||
Invokes []string
|
||||
|
||||
// AsksIssued are the tools the controller asks in its own name for a credential a provider's software
|
||||
// issued, each `<module>.<tool>` (novox/hq ADR 0305): on the subject that names its caller, so the tool
|
||||
// knows the controller asks. The vault's holder is granted the same tools in its own name (issue 723,
|
||||
// vaultCalls); the controller's grant goes with its path in R1 (issue 682). Composed from the records, so the installer's first user list — a controller
|
||||
// with no records — carries none, and moves with issue 464 like the rest of the controller's caller-named
|
||||
// grants.
|
||||
// AsksIssued are, for the vault's holder, the tools it asks in its own name for a credential a provider's software
|
||||
// issued, each `<module>.<tool>` (novox/hq ADR 0305, issue 723, vaultCalls): on the subject that names its caller,
|
||||
// so the tool knows the vault asks.
|
||||
AsksIssued []string
|
||||
|
||||
// AsksTheVault are the vault's verbs the controller asks in its own name (VerbsTheControllerAsksTheVault,
|
||||
// issue 675), those the vault's holder serves: on the subject that names its caller, so the vault knows the
|
||||
// controller asks. Composed from the records like AsksIssued, so the installer's first user list carries none.
|
||||
AsksTheVault []string
|
||||
|
||||
// AsksTheConfig are the mesh-config verbs the controller asks in its own name (VerbsTheControllerAsksTheConfig,
|
||||
// issue 925), those mesh-config's holder serves: on the subject that names its caller, so mesh-config takes a
|
||||
// value as the controller carries it (ADR 0348 §8). Composed from the records like AsksTheVault, so the
|
||||
// installer's first user list carries none.
|
||||
AsksTheConfig []string
|
||||
|
||||
// AsksTheDesk says this principal is the vault's holder, which asks the desks' secret prompt in its own name
|
||||
// (novox/hq ADR 0349 clause 9, issue 723): `node-launcher.secret` on any machine, on the subject that names
|
||||
// its caller, and nothing else of that seat. With AsksIssued on the same principal, the vault's whole reach
|
||||
// outside its own seat: it asks each issuing provider's tool in its own name (clause 10). The controller
|
||||
// keeps its own grant to both until R1 (issue 682), for the modules still filled the old way (clause 17).
|
||||
// outside its own seat: it asks each issuing provider's tool in its own name (clause 10).
|
||||
AsksTheDesk bool
|
||||
|
||||
// State is the local names of the state this principal's module keeps, and Reads the state of
|
||||
@@ -205,11 +190,6 @@ type Principal struct {
|
||||
// (deadLetterSourcesOf, novox/hq ADR 0564 clause 5): never a bucket's.
|
||||
DeadLetterSources []string
|
||||
|
||||
// WitnessesController is a machine principal whose node-engine witnesses the controller's upgrades:
|
||||
// the machine runs the controller (novox/hq ADR 0236, lease/witness.go). It may read the lease's one
|
||||
// key, and nothing else of the bucket, so it can judge a new controller build and put the previous
|
||||
// one back.
|
||||
WitnessesController bool
|
||||
// Checks are the tools a machine principal's node-engine asks as a declared health check, each
|
||||
// `<module>.<tool>` (novox/hq ADR 0240, to-be 48 §3): asked of the instance on its own machine and
|
||||
// nowhere else.
|
||||
@@ -250,6 +230,26 @@ type Principal struct {
|
||||
// stream and name. Created only, never updated or deleted.
|
||||
CreatesConsumers []ModuleConsumer
|
||||
|
||||
// Login is a node principal's login when it is not `node.<n>`: its second login, `node.<n>--b`, composed beside or
|
||||
// in place of the first as the vault's node-logins says (novox/hq ADR 0583 §1, issue 774). Each login is granted
|
||||
// the node's permissions and the node's inbox `_INBOX.node.<n>`; what it calls, it calls in its own name, so the
|
||||
// vault tells the login that confirms a rotation from the one it replaces (§3).
|
||||
Login string
|
||||
// RotatesItsLogin is a machine principal whose node-engine reads its own node's key of the vault's node-logins,
|
||||
// and no other key, and confirms a switch with node-account-confirm in its own name (novox/hq ADR 0583 §1 insight,
|
||||
// §3). Set while the vault's holder serves rotate-node.
|
||||
RotatesItsLogin bool
|
||||
|
||||
// Redeems is an enrolment principal that may call the seat mesh-enrolment's redeem in its own name, and nothing else
|
||||
// of it (novox/hq ADR 0587 §4): set while the seat's holder serves redeem. LeaseHandedAway drops the controller's
|
||||
// enrolment subject from it, which is served no more from the lease write (§6).
|
||||
Redeems bool
|
||||
LeaseHandedAway bool
|
||||
|
||||
// VaultEnrols says the vault's holder serves an enrolment verb (VerbsTheEnrolmentAsksTheVault): every principal but
|
||||
// the module mesh-enrolment and its processes is then denied them, however wide its grant (ADR 0587 §1).
|
||||
VaultEnrols bool
|
||||
|
||||
// PasswordHash is the bcrypt hash the mesh minted. The plaintext is sealed to the principal
|
||||
// and never appears here: this file is written to a node's disk and read by a server, and a
|
||||
// secret that can be read from a configuration file is a secret with a wider blast radius
|
||||
@@ -278,17 +278,18 @@ var VerbsTheSelfCheckAsks = []SeatVerb{{Seat: "node-intrusion-prevention", Verb:
|
||||
// takes before it asks its first tier (novox/hq ADR 0404).
|
||||
var VerbsTheBusStepAsks = []SeatVerb{{Seat: "node-backup", Verb: "now"}}
|
||||
|
||||
// VerbsTheControllerAsksForASecret are the seat verbs `give` calls (novox/hq ADR 0259 §10): the operator's
|
||||
// desk opens a prompt that does not show what is typed, and answers it sealed to the controller's call.
|
||||
var VerbsTheControllerAsksForASecret = []SeatVerb{{Seat: "node-launcher", Verb: "secret"}}
|
||||
// VerbsTheVaultAsksForASecret are the seat verbs the vault's holder calls for a secret typed at a desk (novox/hq ADR
|
||||
// 0259 §10, ADR 0349 clause 9): the operator's desk opens a prompt that does not show what is typed, and answers it
|
||||
// sealed to the vault's call.
|
||||
var VerbsTheVaultAsksForASecret = []SeatVerb{{Seat: "node-launcher", Verb: "secret"}}
|
||||
|
||||
// ControllerOnly are the subjects the controller alone may publish, however wide another's grant (the review
|
||||
// of 2026-10-09, M4): the desk's hidden prompt, on its seat's subjects and on any holder's own module
|
||||
// subjects. A grant of every tool — the runtime's, which carries every agent's calls, or a person's `*` — would
|
||||
// otherwise reach it, and the prompt says the controller asks: only the bus makes that true.
|
||||
func ControllerOnly() []string {
|
||||
// DeskOnly are the subjects the vault's holder alone may publish in its own name, however wide another's grant (the
|
||||
// review of 2026-10-09, M4): the desk's hidden prompt, on its seat's subjects and on any holder's own module subjects. A
|
||||
// grant of every tool — the runtime's, which carries every agent's calls, or a person's `*` — would otherwise reach it,
|
||||
// and the prompt says the vault asks: only the bus makes that true.
|
||||
func DeskOnly() []string {
|
||||
var out []string
|
||||
for _, v := range VerbsTheControllerAsksForASecret {
|
||||
for _, v := range VerbsTheVaultAsksForASecret {
|
||||
for _, base := range []string{"mesh.seat." + v.Seat + ".tool." + v.Verb, "mesh.mod.*.tool." + v.Seat + "." + v.Verb} {
|
||||
out = append(out, base, base+".*")
|
||||
}
|
||||
@@ -300,6 +301,22 @@ func ControllerOnly() []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// VerbsTheEnrolmentAsksTheVault are the vault's verbs the seat mesh-enrolment's holder alone calls (novox/hq ADR 0587
|
||||
// §1, §3; ADR 0561 §5): a token and its hash made and dropped, a joining node's account minted and retired.
|
||||
var VerbsTheEnrolmentAsksTheVault = []SeatVerb{{Seat: VaultSeat, Verb: delivery.EnrolmentTokenVerb},
|
||||
{Seat: VaultSeat, Verb: delivery.NodeAccountVerb}}
|
||||
|
||||
// EnrolmentOnly are the subjects the holder of mesh-enrolment alone may publish, however wide another's grant: the
|
||||
// vault's two enrolment verbs, by the tool's and the caller-named shape (Principal.VaultEnrols denies them).
|
||||
func EnrolmentOnly() []string {
|
||||
var out []string
|
||||
for _, v := range VerbsTheEnrolmentAsksTheVault {
|
||||
out = append(out, "mesh.seat."+v.Seat+".tool."+v.Verb, "mesh.seat."+v.Seat+".tool."+v.Verb+".>",
|
||||
"mesh.seat."+v.Seat+"."+CallKind+"."+v.Verb+".>")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MayPublish says whether permissions let a principal publish one subject: an allow covers it and no deny does.
|
||||
func MayPublish(perms Permissions, subject string) bool {
|
||||
for _, d := range perms.PublishDeny {
|
||||
@@ -325,42 +342,6 @@ func MaySubscribe(perms Permissions, subject string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// VerbsTheControllerAsksTheDeliveryOwner are the mesh-delivery seat's verbs the controller calls (novox/hq
|
||||
// ADR 0239): its self-check reads `stalled`, and healer H2 takes the one transition the table allows
|
||||
// through `close`. A mesh seat's verb is flat: no machine in the subject.
|
||||
//
|
||||
// And, since novox/hq ADR 0259, `release` and `stop`: the controller asks the operator for them about a
|
||||
// delivery held past its bound, and calls them on the operator's warrant, with its why.
|
||||
//
|
||||
// And, since novox/hq ADR 0282, `times`: the self-check reads the delivery times to say a delivery over its budget.
|
||||
//
|
||||
// And, since novox/hq ADR 0306, `groups`: a merge heard from a head waits for the window while its delivery group
|
||||
// has a member still to merge.
|
||||
var VerbsTheControllerAsksTheDeliveryOwner = []SeatVerb{{Seat: "mesh-delivery", Verb: "stalled"},
|
||||
{Seat: "mesh-delivery", Verb: "close"}, {Seat: "mesh-delivery", Verb: "release"}, {Seat: "mesh-delivery", Verb: "stop"},
|
||||
{Seat: "mesh-delivery", Verb: "times"}, {Seat: "mesh-delivery", Verb: "groups"}}
|
||||
|
||||
// VerbsTheControllerAsksTheVault are the mesh-vault seat's verbs the controller calls in its own name (novox/hq
|
||||
// ADR 0349 clauses 8 to 10, design 57's V2, issue 675): it carries a value given at its terminal, typed at a desk
|
||||
// or issued by a provider's software into the vault, sealed to a key the vault made for the call, and asks a
|
||||
// rotation. The vault takes these from the controller alone, which it knows from the subject that names its
|
||||
// caller; granted as Principal.AsksTheVault says.
|
||||
//
|
||||
// And `move-asks` (design 57's V3, issue 676): the controller copies its record of asks at a desk, which holds no
|
||||
// value, into the vault's.
|
||||
//
|
||||
// And `get` (novox/hq ADR 0349 §16, issue 755): the controller asks for a core module's own credential sealed to its
|
||||
// machine's key, which it carries in that machine's declaration and cannot open (mesh-vault's core.go).
|
||||
var VerbsTheControllerAsksTheVault = []SeatVerb{{Seat: "mesh-vault", Verb: "accept"},
|
||||
{Seat: "mesh-vault", Verb: "secret-ask"}, {Seat: "mesh-vault", Verb: "issued"}, {Seat: "mesh-vault", Verb: "rotate"},
|
||||
{Seat: "mesh-vault", Verb: "move-asks"}, {Seat: "mesh-vault", Verb: "get"}}
|
||||
|
||||
// VerbsTheControllerAsksTheConfig are the mesh-config seat's verbs the controller calls in its own name (issue 925):
|
||||
// `config`, the one verb mesh-config serves, which move-config uses to set each value and the settings verb to set,
|
||||
// clear and propose through mesh-config. mesh-config takes a value as the controller carries it only from the
|
||||
// subject that names the controller as caller (ADR 0348 §8); granted as Principal.AsksTheConfig says.
|
||||
var VerbsTheControllerAsksTheConfig = []SeatVerb{{Seat: "mesh-config", Verb: "config"}}
|
||||
|
||||
// VaultMoveIn is the vault's verb a machine's node-engine asks in its own name to hand in a value sealed to its
|
||||
// machine (design 57's V3, issue 676); granted as Principal.MovesIntoTheVault says.
|
||||
var VaultMoveIn = SeatVerb{Seat: "mesh-vault", Verb: "move-in"}
|
||||
@@ -377,122 +358,31 @@ var VaultOwn = SeatVerb{Seat: "mesh-vault", Verb: "own"}
|
||||
// ADR 0550 §2); granted as Principal.AsksTheVaultToSign says.
|
||||
var VaultSign = SeatVerb{Seat: "mesh-vault", Verb: delivery.SignVerb}
|
||||
|
||||
// VerbsTheControllerActsOnAWarrant are the other seat verbs the controller calls when the operator's warrant
|
||||
// chooses them (novox/hq ADR 0259): a machine's service restarted, and a walk started or stopped through the
|
||||
// controller's own `plans`, a machine's held builds sent by its own `push` and the builds held after a failed release
|
||||
// sent by its own `upgrade release-backlog` (novox/hq issue 1732). Named one by one; a node seat's on any machine, a
|
||||
// mesh seat's flat.
|
||||
var VerbsTheControllerActsOnAWarrant = []SeatVerb{{Seat: "node-service-manager", Verb: "restart"},
|
||||
{Seat: ControllerSeat, Verb: "plans"}, {Seat: ControllerSeat, Verb: "push"}, {Seat: ControllerSeat, Verb: "upgrade"}}
|
||||
// VaultRotateNode is the vault's verb that begins a node's account rotation; while its holder serves it, every node
|
||||
// principal reads its own node's key of node-logins (novox/hq ADR 0583 §1, §2). VaultNodeAccountConfirm is the verb a
|
||||
// node-engine confirms a switch with, in its new login's name (§3).
|
||||
var (
|
||||
VaultRotateNode = SeatVerb{Seat: "mesh-vault", Verb: delivery.RotateNodeVerb}
|
||||
VaultNodeAccountConfirm = SeatVerb{Seat: "mesh-vault", Verb: delivery.NodeAccountConfirmVerb}
|
||||
)
|
||||
|
||||
// VerbsTheControllerAsksTheOperatorChannel are the operator channel's verbs the controller calls (novox/hq
|
||||
// ADR 0380): `tell`, one plain message with no condition behind it — a module's own secret given, named,
|
||||
// where and when and by whom, asking nothing. A mesh seat's verb is flat: no machine in the subject.
|
||||
var VerbsTheControllerAsksTheOperatorChannel = []SeatVerb{{Seat: "operator-channel", Verb: "tell"}}
|
||||
// EnrolmentRedeem is the seat mesh-enrolment's verb a joining node-engine calls as enrol.<node> (novox/hq ADR 0587 §4,
|
||||
// §6).
|
||||
var EnrolmentRedeem = SeatVerb{Seat: catalogue.EnrolmentSeat, Verb: catalogue.EnrolmentVerbRedeem}
|
||||
|
||||
// DegradableGrant is a publish grant whose absence from the installer's carried first-user list is not a
|
||||
// bug in the template: a fallback covers the refusal.
|
||||
type DegradableGrant struct {
|
||||
Subject string
|
||||
Reason string
|
||||
// EnrolmentRedeemCall is the one subject an enrolment user may publish once it enrols through the seat: redeem, in its
|
||||
// own name.
|
||||
func EnrolmentRedeemCall(p Principal) string {
|
||||
return "mesh.seat." + EnrolmentRedeem.Seat + "." + CallKind + "." + EnrolmentRedeem.Verb + "." + CallerToken(p.Username())
|
||||
}
|
||||
|
||||
// DegradableGrants are the only publish grants TestTheInstallersFirstUserListIsWhatTheControllerWouldCompose
|
||||
// may find missing from the installer's carried list without failing (novox/hq issue 835, the forge check
|
||||
// of mesh-controller#345): a change that only edits mesh-host's examples can never satisfy a check that
|
||||
// reads the mesh-host commit the machines actually run, so the carried list lags behind the controller's
|
||||
// derived one until mesh-host's own module next builds. operator-channel.tell is the one grant the
|
||||
// controller already has a fallback for: refused (no holder, or the bus refuses it), the controller raises
|
||||
// the secret-given condition exactly as before (ADR 0380 §9), so an older carried list still comes up and
|
||||
// works. Every other grant stays required — missing one of those is a controller that comes up and is
|
||||
// refused on the first thing it tries, with no fallback that covers it.
|
||||
var DegradableGrants = []DegradableGrant{
|
||||
{
|
||||
Subject: announce.Subject(ControllerSeat),
|
||||
Reason: "refused: the controller's own announcement waits for its first push, and until it is kept every reader of " +
|
||||
"its states is granted from its `reads` as before (novox/hq ADR 0495, issue 1790)",
|
||||
},
|
||||
{
|
||||
Subject: "$KV." + StatesAnnouncedBucket + ".>",
|
||||
Reason: "refused: no announcement is kept until the controller's first push, and until then every reader is " +
|
||||
"granted from its `reads` as before (novox/hq ADR 0495, issue 1790)",
|
||||
},
|
||||
{
|
||||
Subject: "mesh.seat." + ControllerSeat + ".tool.push",
|
||||
Reason: "refused: the ask of a held move (Release) is answered, and the refusal told, while the operator's own " +
|
||||
"push at the terminal still sends the machine (issue 1732; ADR 0259)",
|
||||
},
|
||||
{
|
||||
Subject: "mesh.seat." + ControllerSeat + ".tool.upgrade",
|
||||
Reason: "refused: the ask of the builds held after a failed release is answered, and the refusal told, while the " +
|
||||
"operator's own `upgrade release-backlog` at the terminal still releases them (issue 1732; ADR 0259)",
|
||||
},
|
||||
{
|
||||
Subject: "mesh.seat." + VerbsTheControllerAsksTheOperatorChannel[0].Seat + ".tool." + VerbsTheControllerAsksTheOperatorChannel[0].Verb,
|
||||
Reason: "refused: the controller raises the secret-given condition as before (ADR 0380 §9)",
|
||||
},
|
||||
{
|
||||
Subject: "$JS.ACK.EVENTS." + MergesConsumerName + ".>",
|
||||
Reason: "carried only at genesis: the controller composes its own permissions and pushes them to the bus at " +
|
||||
"its first push, which is when it acknowledges a merge (ADR 0407 decision 3, issue 973)",
|
||||
},
|
||||
{
|
||||
Subject: "$KV." + DeclaredPackagesBucket + ".>",
|
||||
Reason: "carried only at genesis: the controller composes its own permissions and pushes them to the bus at its " +
|
||||
"first push, and keeps the packages each node declares only after that, holding the lease (ADR 0507 §5, issue 1840)",
|
||||
},
|
||||
{
|
||||
Subject: "mesh.seat." + ControllerSeat + ".event.artifacts-gone",
|
||||
Reason: "refused: the take-in is refused all the same and says why; only the builder's rebuild waits for the " +
|
||||
"controller's first push, and the builder also asks held when a module's value or its own start comes (novox/hq " +
|
||||
"issue 1937)",
|
||||
},
|
||||
{
|
||||
Subject: "mesh.seat." + ControllerSeat + ".event.sent",
|
||||
Reason: "refused: the controller says a tier sent to a machine only for outsiders, and a send that could not " +
|
||||
"be said is printed and goes on (ADR 0406 §5, issue 974)",
|
||||
},
|
||||
{
|
||||
Subject: "$KV." + DeliveryMergesBucket + ".>",
|
||||
Reason: "refused: the controller then keeps no merge mesh-delivery announced, so the build of a moved module is " +
|
||||
"refused as it is without the operator's Approve — failing closed (ADR 0420 §7, issue 1025)",
|
||||
},
|
||||
{
|
||||
Subject: "$KV." + BuildsBucket + ".>",
|
||||
Reason: "carried only at genesis: the controller composes its own permissions and pushes them to the bus at its " +
|
||||
"first push, and writes the chain only after that, holding the lease (ADR 0408 §2 and §8, issue 1089)",
|
||||
},
|
||||
{
|
||||
Subject: "mesh.seat." + ControllerSeat + ".event.bucket-deleted",
|
||||
Reason: "refused: nothing is deleted at genesis; the controller composes its own permissions into the bus module's " +
|
||||
"composition before a later start's deletions (novox/hq ADR 0539 clause 13)",
|
||||
},
|
||||
{
|
||||
Subject: "$KV." + delivery.ControllerComposedBucket + ".>",
|
||||
Reason: "carried only at genesis: the controller composes its own permissions into the bus module's composition, " +
|
||||
"and writes a composition's digest only after the bus applied it (novox/hq ADR 0539 clauses 2 and 7)",
|
||||
},
|
||||
{
|
||||
Subject: "$JS.ACK.KV_" + delivery.ModulesBucket + "." + TakeInConsumer + ".>",
|
||||
Reason: "refused: an acknowledgement refused leaves the key delivered again after its wait, and the take-in's first " +
|
||||
"batch with something new composes the bus's node, which grants it (novox/hq ADR 0544 §1)",
|
||||
},
|
||||
{
|
||||
Subject: "$JS.ACK.KV_" + delivery.BuilderBuildsBucket + "." + TakeInConsumer + ".>",
|
||||
Reason: "refused: an acknowledgement refused leaves the key delivered again after its wait, and the take-in's first " +
|
||||
"batch with something new composes the bus's node, which grants it (novox/hq ADR 0544 §1)",
|
||||
},
|
||||
{
|
||||
Subject: "$KV." + GrantsBucket + ".>",
|
||||
Reason: "carried only at genesis: the controller composes its own permissions and pushes them to the bus at its " +
|
||||
"first push, and writes a grant only after a send of the bus's user list, which comes after that (ADR 0528 " +
|
||||
"clause 2, issue 1394)",
|
||||
},
|
||||
{
|
||||
Subject: "$KV." + RolloutsBucket + ".>",
|
||||
Reason: "carried only at genesis: the controller composes its own permissions and pushes them to the bus at its " +
|
||||
"first push, and writes the chain only after that, holding the lease (ADR 0408 §2 and §8, issue 1089)",
|
||||
},
|
||||
// NodeLoginsReads is what a node's engine is granted of the vault's node-logins: its own node's key, read and watched,
|
||||
// and no other (novox/hq ADR 0583 §1 insight).
|
||||
func NodeLoginsReads(node string) []string {
|
||||
if !safeSubject.MatchString(node) {
|
||||
return nil
|
||||
}
|
||||
return readGrants(delivery.NodeLoginsBucket, node)
|
||||
}
|
||||
|
||||
// perMachineEvents are a node-scoped seat's events about the holder itself, whose last token is the
|
||||
@@ -525,9 +415,10 @@ func (p Principal) Username() string {
|
||||
// never the module's own name alone, which is a different account with a different secret.
|
||||
return p.Node + "." + p.Module + "." + p.Process
|
||||
case KindNode:
|
||||
if p.Login != "" {
|
||||
return p.Login
|
||||
}
|
||||
return "node." + p.Node
|
||||
case KindController:
|
||||
return "controller"
|
||||
case KindProvisioner:
|
||||
// Named for the machine, as a node is: one client per machine, whichever module runs it, and never
|
||||
// `<node>.<module>`, which is the module's own account or the tool runner's.
|
||||
@@ -584,12 +475,19 @@ func AskAccessActSubject(node string) string { return "mesh.node." + node + ".as
|
||||
// inbox is a principal's own reply space. No user is ever granted a bare `_INBOX.>` (design 25
|
||||
// §4): with one account, inbox privacy is the permission list or it is nothing, so each user's
|
||||
// inbox is derived from its own identity and its permissions name that prefix and no other.
|
||||
func (p Principal) inbox() string { return "_INBOX." + p.Username() + ".>" }
|
||||
//
|
||||
// A node's two logins share the node's inbox (novox/hq ADR 0583 §1): its answers reach it whichever login it dials as.
|
||||
func (p Principal) inbox() string {
|
||||
if p.Kind == KindNode {
|
||||
return "_INBOX.node." + p.Node + ".>"
|
||||
}
|
||||
return "_INBOX." + p.Username() + ".>"
|
||||
}
|
||||
|
||||
// Permissions is what a principal may publish and subscribe, and whether it may answer.
|
||||
type Permissions struct {
|
||||
Publish []string
|
||||
// PublishDeny are subjects refused although an allow covers them: the controller's alone (ControllerOnly),
|
||||
// PublishDeny are subjects refused although an allow covers them: the vault's holder's alone (DeskOnly),
|
||||
// denied to everybody whose grant is wide enough to reach them. The server's deny outranks its allow.
|
||||
PublishDeny []string
|
||||
Subscribe []string
|
||||
@@ -664,7 +562,7 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
}
|
||||
|
||||
if p.Kind == KindProcess && len(p.Removes) > 0 {
|
||||
pub, err := BusRemovalGrants(p.Removes)
|
||||
pub, err := BusRemovalGrants(p.Removes, p.FilesFor)
|
||||
if err != nil {
|
||||
return Permissions{}, err
|
||||
}
|
||||
@@ -678,157 +576,6 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
|
||||
var pub, sub []string
|
||||
switch p.Kind {
|
||||
case KindController:
|
||||
// The controller owns the mesh's own traffic and the streams. It is the only writer of
|
||||
// stream definitions (design 25 §3), so it alone reaches the JetStream API — and it alone
|
||||
// issues memberships (novox/hq ADR 0160), which it publishes into the assignments stream
|
||||
// after each push; refused by the server on 2026-10-01 until this line named them.
|
||||
//
|
||||
// **Not what the machines say** (novox/hq to-be 45 §1): `mesh.control.>` is subscribed, never
|
||||
// published — a machine's report has one writer, its node-engine, and the controller granted
|
||||
// that subject would be a second (the writers table refuses it). It was granted from the first
|
||||
// composition and nothing ever published under it.
|
||||
pub = []string{"mesh.node.>", "mesh.assignment.>", "$JS.API.>"}
|
||||
// **And where its consumers deliver.** A push consumer delivers on `_DELIVER.<its name>`,
|
||||
// and a client bound to it subscribes exactly that; the server refused it for every
|
||||
// principal the first time one bound a consumer (2026-09-28). Each kind below is granted
|
||||
// its own consumers' delivery subjects and no other's.
|
||||
sub = []string{"mesh.control.>", "$JS.API.>", "_DELIVER." + ControllerName, "_DELIVER." + ControllerName + ".>"}
|
||||
|
||||
// Work the mesh's own flows submit to a role, and the outcomes they wait on (ADR 0121). A
|
||||
// build is the one today: the controller asks, and reads the answer from the seat's event
|
||||
// like the catalogue does — which is why no holder needs to publish into anybody's inbox.
|
||||
// A node-scoped seat's work subject carries no node (novox/hq ADR 0190): the ask goes to
|
||||
// the role, and whichever machine holding it is idle takes it.
|
||||
// **And what the mesh says it did** (novox/hq ADR 0134). The control plane states its own
|
||||
// facts under the seat it holds, because a role's events belong to the role and keep their
|
||||
// address while the holder is replaced. Named one by one rather than as a whole namespace:
|
||||
// least authority, and a fact nothing states is authority nobody uses.
|
||||
for _, event := range ControllerStates {
|
||||
pub = append(pub, seatEventSubject(ControllerSeat, event))
|
||||
}
|
||||
|
||||
// Every module's tools: **the control plane is the way in** (novox/hq ADR 0095). A person
|
||||
// or an agent asks through it and every question passes one process where an audit
|
||||
// belongs — so it, alone among principals, may call any tool by name. The first `ask` on
|
||||
// the new bus was refused the publish (2026-09-28).
|
||||
pub = append(pub, "mesh.mod.*.tool.>")
|
||||
|
||||
// **And the mesh's own verbs, as the seat it holds** (novox/hq ADR 0132, ADR 0154):
|
||||
// `status`, `push`, `assign` are the mesh-controller seat's tools, served by its holder. The
|
||||
// whole verb namespace of its own seat rather than a list: the list is the seat's protocol,
|
||||
// which this package mirrors rather than reads, and a verb the seat does not declare is a
|
||||
// subject nothing publishes.
|
||||
sub = append(sub, "mesh.seat."+ControllerSeat+".tool.>")
|
||||
// And says so (novox/hq ADR 0197): it answers discovery for the seat it serves.
|
||||
sub = append(sub, announcing(ControllerSeat)...)
|
||||
// And the verbs the self-check reads with, of any machine's holder (novox/hq to-be 45 §4).
|
||||
for _, v := range VerbsTheSelfCheckAsks {
|
||||
pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb+".*")
|
||||
}
|
||||
// And the bus's planned step: a snapshot taken now, before the bus is replaced (ADR 0236).
|
||||
for _, v := range VerbsTheBusStepAsks {
|
||||
pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb+".*")
|
||||
}
|
||||
// And the operator's desk, for a secret given there (ADR 0259 §10).
|
||||
for _, v := range VerbsTheControllerAsksForASecret {
|
||||
pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb+".*")
|
||||
}
|
||||
// And the operator channel, a mesh seat, asked flat: one message when a secret is given (ADR 0380).
|
||||
for _, v := range VerbsTheControllerAsksTheOperatorChannel {
|
||||
pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb)
|
||||
}
|
||||
// And the delivery's owner, a mesh seat, asked on its flat subjects (ADR 0239).
|
||||
for _, v := range VerbsTheControllerAsksTheDeliveryOwner {
|
||||
pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb)
|
||||
}
|
||||
// And the verbs a warrant chooses (novox/hq ADR 0259): a node seat's on any machine, its own flat.
|
||||
for _, v := range VerbsTheControllerActsOnAWarrant {
|
||||
if v.Seat == ControllerSeat {
|
||||
pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb)
|
||||
continue
|
||||
}
|
||||
pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb+".*")
|
||||
}
|
||||
// And asking the operator (novox/hq ADR 0259): an ask and its cancel under its own name, its warrants
|
||||
// heard under its own name, the record of its asks read under its own name — as any user of the seat,
|
||||
// derived the same way, from the seat its holder declares.
|
||||
tp, ts := seatTrafficGrants(SeatTrafficOf(ControllerSeat, nil, p.Uses, nil))
|
||||
pub = append(pub, tp...)
|
||||
sub = append(sub, ts...)
|
||||
// And asks who answers (novox/hq to-be 45 §4, D3): the self-check finds every seat's holder by
|
||||
// the same discovery the console reads. The question only; the answers come to its own inbox.
|
||||
pub = append(pub, "$SRV.INFO")
|
||||
|
||||
// The events it reacts to, and its ack subject on the stream they arrive from
|
||||
// (streams.go). **Each named, not a pattern**: `mesh.mod.*.event.>` would make the
|
||||
// controller a subscriber to every event in the mesh, and its permission list would stop
|
||||
// saying what it is for. The one wildcard is the emitter of a provider's standing (ADR
|
||||
// 0224) — still two named events, from whichever module provides. The ack grant below is scoped per stream because the controller's
|
||||
// consumer name is the same on both and `$JS.ACK.CONTROL.controller.>` does not cover a
|
||||
// delivery from EVENTS — a consumer that cannot ack has every message redelivered for
|
||||
// ever, refused by the list it already has.
|
||||
sub = append(sub, ControllerFollows...)
|
||||
pub = append(pub, "$JS.ACK.EVENTS."+ControllerName+".>", "$JS.ACK.EVENTS."+MergesConsumerName+".>")
|
||||
// And the take-in's two durable consumers on the indexer's and the builder's buckets (novox/hq ADR 0544 §1):
|
||||
// acknowledged after each key is taken in, and no other consumer of those streams.
|
||||
for _, bucket := range TakeInBuckets {
|
||||
pub = append(pub, "$JS.ACK.KV_"+bucket+"."+TakeInConsumer+".>")
|
||||
}
|
||||
|
||||
// **Where an enrolment's answer goes**, and `allow_responses` does not cover it. That
|
||||
// permits one reply to the reply subject of a message the user received — and a message a
|
||||
// JetStream consumer delivers has had that field claimed for the consumer's own ack address
|
||||
// (design 25 §2), so the address the controller actually answers is the one the request
|
||||
// carried in its payload, which is not a reply subject as the server understands it.
|
||||
//
|
||||
// Verified against a real server before this line existed: the answer was refused with
|
||||
// "Permissions Violation for Publish to _INBOX.enrol.anchor…", and every enrolment on the
|
||||
// mesh would have timed out while the controller logged success.
|
||||
//
|
||||
// **The enrolment inbox space, not a blanket `_INBOX.>`.** Design 25 §4 refuses that, and
|
||||
// this is not it: nothing but an enrolling node ever subscribes under this prefix, each
|
||||
// scoped to its own token's, so the controller publishing here is the mesh answering
|
||||
// enrolments and can reach nothing else.
|
||||
pub = append(pub, "_INBOX."+enrolmentPrefix+".>")
|
||||
|
||||
// **And its own buckets** (novox/hq to-be 45 §1): the calls it served and the acts done by
|
||||
// hand, which it alone writes. A put is a publish to the bucket's subject, which `$JS.API.>`
|
||||
// does not cover; each bucket named, not `$KV.>`, which would let it write any module's state.
|
||||
// And says what it keeps, as every module does (novox/hq ADR 0495 clause 2, issue 1790).
|
||||
pub = append(pub, announce.Subject(ControllerSeat))
|
||||
for _, bucket := range ControllerBuckets() {
|
||||
if bucket == delivery.HaveBucket {
|
||||
continue // each node's node-engine writes its own keys of it, the controller none (ADR 0429 §1)
|
||||
}
|
||||
pub = append(pub, "$KV."+bucket+".>")
|
||||
}
|
||||
// **And the object bucket a declaration's large files travel in** (novox/hq ADR 0486 §2, issue 1697): the
|
||||
// controller alone writes it, as a publish to the bucket's own subjects, which `$JS.API.>` does not cover.
|
||||
for _, node := range p.FilesFor {
|
||||
writes, err := DeclarationFilesWrites(node)
|
||||
if err != nil {
|
||||
return Permissions{}, err
|
||||
}
|
||||
pub = append(pub, writes)
|
||||
// And each node's compositions, which it alone writes (novox/hq ADR 0539 clause 2).
|
||||
composed, err := ComposedWrites(node)
|
||||
if err != nil {
|
||||
return Permissions{}, err
|
||||
}
|
||||
pub = append(pub, composed)
|
||||
}
|
||||
// **And what the bus says about itself, read-only** (novox/hq to-be 45 §3, S9): a durable
|
||||
// consumer that gave up on a message, or one that was deleted. The server already publishes
|
||||
// both in the mesh's own account; the controller says each as a condition in the mesh's words.
|
||||
// Named, not `$JS.EVENT.>`: the other advisories are every API call the mesh makes.
|
||||
sub = append(sub, BusAdvisories...)
|
||||
// **And what a consumer gave up on, kept and delivered again** (novox/hq issue 330): the notice
|
||||
// acknowledged once the message is copied, the copy kept, and an event delivered again to the one
|
||||
// consumer that gave it up. An ask to a seat is not delivered again, so no seat's queue is granted.
|
||||
pub = append(pub, "$JS.ACK."+DeadLetterNoticesStream+"."+ControllerName+".>", deadLetterPrefix+">",
|
||||
againPrefix+">")
|
||||
|
||||
case KindProvisioner:
|
||||
var err error
|
||||
if pub, sub, err = provisionerGrants(p); err != nil {
|
||||
@@ -852,15 +599,6 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
// And may ask what answers (novox/hq ADR 0197): a question every service answers about
|
||||
// itself, its replies to the asker's own inbox.
|
||||
pub = append(pub, discovering()...)
|
||||
// And, for an administrator (`*`, the operator), release 6's hand path (ADR 0539 clauses 2 and 10): a want written
|
||||
// at the terminal, and a composition made by hand with mesh-hand-compose.
|
||||
if administers(p.Invokes) {
|
||||
hand, err := HandPathWrites(p.FilesFor)
|
||||
if err != nil {
|
||||
return Permissions{}, err
|
||||
}
|
||||
pub = append(pub, hand...)
|
||||
}
|
||||
|
||||
case KindEnrolment:
|
||||
// A leaked token is useless for anything but enrolling: it cannot read a declaration, hear
|
||||
@@ -880,7 +618,13 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
"enrolment: a token is issued for a node record, and that record's name is " +
|
||||
"the token's id")
|
||||
}
|
||||
pub = []string{"mesh.control.enrol"}
|
||||
// **A node enrols through the seat mesh-enrolment alone** (novox/hq ADR 0587 §4, §6, ADR 0590): its user calls
|
||||
// redeem in its own name, while the seat's holder serves it.
|
||||
if !p.Redeems {
|
||||
return Permissions{}, fmt.Errorf("the enrolment user of %s may enrol nowhere: no holder of %s serves %s",
|
||||
p.Node, EnrolmentRedeem.Seat, EnrolmentRedeem.Verb)
|
||||
}
|
||||
pub = []string{EnrolmentRedeemCall(p)}
|
||||
sub = []string{p.inbox()}
|
||||
|
||||
case KindNode:
|
||||
@@ -986,13 +730,18 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
// on it rather than at the next unrelated change (ADR 0547 §2).
|
||||
sub = append(sub, VaultSecretChanged)
|
||||
}
|
||||
// And, while the vault serves `sign`, its own node's certificate in its own name, the vault's public signing
|
||||
// halves, read and watched, and the controller's lease key, read and watched, which says when the controller's key
|
||||
// is no longer trusted (ADR 0550 §2). Read only.
|
||||
// And, while the vault serves `sign`, its own node's certificate in its own name, and the vault's public signing
|
||||
// halves, read and watched (ADR 0550 §2). Read only.
|
||||
if p.AsksTheVaultToSign {
|
||||
pub = append(pub, VaultSignCall(p))
|
||||
pub = append(pub, readGrants(delivery.SignersBucket, "")...)
|
||||
pub = append(pub, readGrants(LeaseBucket, leaseKey)...)
|
||||
}
|
||||
// And, while the vault rotates node accounts, its own node's key of node-logins, read and watched, and no other
|
||||
// key (novox/hq ADR 0583 §1 insight): the login it dials as; and the confirmation of a switch in its own name (§3).
|
||||
if p.RotatesItsLogin {
|
||||
pub = append(pub, NodeLoginsReads(p.Node)...)
|
||||
pub = append(pub, "mesh.seat."+VaultNodeAccountConfirm.Seat+"."+CallKind+"."+VaultNodeAccountConfirm.Verb+"."+
|
||||
CallerToken(p.Username()))
|
||||
}
|
||||
// And its node seat, node-engine, and its asks to the operator in its own name (novox/hq ADR 0563 §1, §3).
|
||||
ep, es := engineGrants(p)
|
||||
@@ -1061,10 +810,10 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
}
|
||||
}
|
||||
sub = append(sub, announcing(announced...)...)
|
||||
// Its own membership (ADR 0160): the one subject a runtime derives for itself, heard live. Nothing else's.
|
||||
sub = append(sub, MembershipSubject(p.Node, p.Module))
|
||||
// And from the grants part's state memberships, its own key (novox/hq ADR 0571 clause 7): its one read of it,
|
||||
// since the assignments stream's direct get went with the stream (research 072 row 33).
|
||||
// Its own membership (ADR 0160), from the grants part's state memberships, its own key (novox/hq ADR 0571 clause
|
||||
// 7): its one read of it, since the assignments stream's direct get went with the stream (research 072 row 33),
|
||||
// and no longer heard on its subject, where the grants part stops publishing at the lease write (ADR 0571 clause
|
||||
// 10, issue 774).
|
||||
pub = append(pub, readGrants(GrantsMembershipsBucket, MembershipKey(p.Node, p.Module))...)
|
||||
|
||||
// 1b. The tools it calls, if its manifest says it calls any (novox/hq ADR 0152). The same
|
||||
@@ -1119,12 +868,8 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
"$JS.API.CONSUMER.INFO."+consumerStream(p)+"."+consumerDurable(p),
|
||||
"$JS.API.CONSUMER.MSG.NEXT."+consumerStream(p)+"."+consumerDurable(p))
|
||||
|
||||
// 3. Seats it holds: full participation — but the controller's own seat, whose verbs only the serving
|
||||
// controller answers, on its own connection (servedOnlyByTheController).
|
||||
// 3. Seats it holds: full participation.
|
||||
for _, s := range p.Holds {
|
||||
if servedOnlyByTheController(s) {
|
||||
continue
|
||||
}
|
||||
if s.isNewTraffic() {
|
||||
// Composed by SeatTrafficOf below, worker and all; only its tools are served here.
|
||||
for _, t := range s.Serves {
|
||||
@@ -1218,18 +963,15 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
// And its own streams' events (novox/hq ADR 0363 §2).
|
||||
pub = append(pub, ownStreamGrants(d.Module, d.Streams)...)
|
||||
for _, s := range d.Holds {
|
||||
if servedOnlyByTheController(s) {
|
||||
continue
|
||||
}
|
||||
for _, t := range s.Serves {
|
||||
sub = append(sub, seatToolSubject(s, t, p.Node))
|
||||
}
|
||||
}
|
||||
}
|
||||
// Every assigned module's membership on this node (ADR 0160): one per module, heard live and read from the
|
||||
// grants part's state memberships (ADR 0571 clause 7). This node's and no other's — the one token
|
||||
// that varies is the module, so the pattern is the machine's own assignments.
|
||||
sub = append(sub, "mesh.assignment."+p.Node+".*")
|
||||
// Every assigned module's membership on this node (ADR 0160): one per module, read and watched from the
|
||||
// grants part's state memberships (ADR 0571 clause 7), and no longer heard on `mesh.assignment`, where the grants
|
||||
// part stops publishing at the lease write (ADR 0571 clause 10, issue 774). This node's and no other's — the
|
||||
// one token that varies is the module, so the pattern is the machine's own assignments.
|
||||
pub = append(pub, readGrants(GrantsMembershipsBucket, p.Node+".*")...)
|
||||
// And that it is there (novox/hq to-be 45 §3, S11): its own heartbeat, under its machine's
|
||||
// name and no other's, on core NATS like the host's.
|
||||
@@ -1338,7 +1080,7 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
sub = append(sub, p.inbox())
|
||||
}
|
||||
|
||||
if p.Kind == KindModule || p.Kind == KindNode || p.Kind == KindController {
|
||||
if p.Kind == KindModule || p.Kind == KindNode {
|
||||
// Its own reply space, and nothing wider.
|
||||
sub = append(sub, p.inbox())
|
||||
|
||||
@@ -1351,52 +1093,14 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
}
|
||||
|
||||
// **And every tool grant again, naming its caller** (novox/hq issue 365): a call in this principal's own
|
||||
// name, and an answer to a call naming anybody. The `tool` grants above stay for one release beside these,
|
||||
// so callers and runtimes move without a gap; their retirement is hq issue 464.
|
||||
//
|
||||
// **Not the controller's, this release.** Its grants are also the installer's first user list
|
||||
// (TestTheInstallersFirstUserListIsWhatTheControllerWouldCompose), judged against the node-engine the mesh
|
||||
// runs, so a change to them waits on a node-engine delivery. It calls and serves on the subjects that name
|
||||
// no caller meanwhile — a caller-named call to its seat reaches nobody and is asked again on those at once —
|
||||
// and moves with issue 464.
|
||||
// name, and an answer to a call naming anybody. The `tool` grants above stay beside these, so callers and runtimes
|
||||
// move without a gap; their retirement is hq issue 464.
|
||||
// The vault's calls in its own name (issue 723), kept apart so the desk's grant is not denied below.
|
||||
var vaultsOwn []string
|
||||
if p.Kind != KindController {
|
||||
pub, sub = callerNamed(p.Username(), pub, sub)
|
||||
if p.Kind == KindModule {
|
||||
vaultsOwn = vaultCalls(p)
|
||||
pub = append(pub, vaultsOwn...)
|
||||
}
|
||||
} else {
|
||||
// **But the tools that answer a software-issued credential** (novox/hq ADR 0305 §2): those answer the
|
||||
// controller alone, and know it only from the subject's last token, so it asks them in its own name —
|
||||
// those tools, on any machine, and nothing else under `call`.
|
||||
for _, key := range p.AsksIssued {
|
||||
module, tool, ok := strings.Cut(key, ".")
|
||||
if !ok || module == "" || tool == "" || strings.ContainsAny(key, "*> ") {
|
||||
continue
|
||||
}
|
||||
pub = append(pub, "mesh.mod."+module+"."+CallKind+"."+tool+".*."+CallerToken(p.Username()))
|
||||
}
|
||||
// **And the vault's intake verbs** (issue 675): the vault takes a value from the controller alone, known
|
||||
// from the subject's last token — the verbs its holder serves, flat, and nothing else of its seat.
|
||||
for _, v := range VerbsTheControllerAsksTheVault {
|
||||
if slices.Contains(p.AsksTheVault, v.Verb) {
|
||||
pub = append(pub, "mesh.seat."+v.Seat+"."+CallKind+"."+v.Verb+"."+CallerToken(p.Username()))
|
||||
}
|
||||
}
|
||||
// **And mesh-config's verb** (issue 925): it takes a trusted value from the controller alone, known from the
|
||||
// subject's last token.
|
||||
for _, v := range VerbsTheControllerAsksTheConfig {
|
||||
if slices.Contains(p.AsksTheConfig, v.Verb) {
|
||||
pub = append(pub, "mesh.seat."+v.Seat+"."+CallKind+"."+v.Verb+"."+CallerToken(p.Username()))
|
||||
}
|
||||
}
|
||||
// And its consumer of mesh-config's resolved values, acknowledged once a change is composed (novox/hq ADR 0545):
|
||||
// where mesh-config serves, as its verb, so genesis grants nothing of mesh-config.
|
||||
if len(p.AsksTheConfig) > 0 {
|
||||
pub = append(pub, "$JS.ACK.KV_"+ConfigResolvedBucket+"."+ConfigFollowConsumer+".>")
|
||||
}
|
||||
pub, sub = callerNamed(p.Username(), pub, sub)
|
||||
if p.Kind == KindModule {
|
||||
vaultsOwn = vaultCalls(p)
|
||||
pub = append(pub, vaultsOwn...)
|
||||
}
|
||||
|
||||
sort.Strings(pub)
|
||||
@@ -1406,18 +1110,24 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
if err := CheckWriters(p, pub); err != nil {
|
||||
return Permissions{}, err
|
||||
}
|
||||
// What the controller alone may publish is denied to everybody else whose grant reaches it.
|
||||
// What the vault's holder alone may publish is denied to everybody else whose grant reaches it.
|
||||
var deny []string
|
||||
if p.Kind != KindController {
|
||||
for _, only := range ControllerOnly() {
|
||||
for _, a := range pub {
|
||||
if slices.Contains(vaultsOwn, a) {
|
||||
continue
|
||||
}
|
||||
if SubjectsOverlap(a, only) {
|
||||
deny = append(deny, only)
|
||||
break
|
||||
}
|
||||
for _, only := range DeskOnly() {
|
||||
for _, a := range pub {
|
||||
if slices.Contains(vaultsOwn, a) {
|
||||
continue
|
||||
}
|
||||
if SubjectsOverlap(a, only) {
|
||||
deny = append(deny, only)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// And the vault's enrolment verbs to every principal but mesh-enrolment's (novox/hq ADR 0587 §1).
|
||||
if p.VaultEnrols && !((p.Kind == KindModule || p.Kind == KindProcess) && p.Module == delivery.EnrolmentModule) {
|
||||
for _, only := range EnrolmentOnly() {
|
||||
if slices.ContainsFunc(pub, func(a string) bool { return SubjectsOverlap(a, only) }) {
|
||||
deny = append(deny, only)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1426,13 +1136,13 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
PublishDeny: deny,
|
||||
Subscribe: sub,
|
||||
// A module answers what it was asked — a tool call reaches it on its own namespace, so the
|
||||
// authority is bounded by having been asked — and so does the controller. A node is asked one
|
||||
// authority is bounded by having been asked. A node is asked one
|
||||
// thing, a hand-over on its own subject (novox/hq issue 356), and answers that: the node is its
|
||||
// machine's engine, root there already, and it is delivered only its own subjects. What it answers is
|
||||
// never trusted for being an answer — the controller reads the engine's words and records nothing. A
|
||||
// never trusted for being an answer. A
|
||||
// provisioner client is asked by its own machine's tool runner alone, on its own machine's subject (novox/hq
|
||||
// ADR 0370 §3), and answers that. A person is never asked anything, and is granted nothing here.
|
||||
AllowResponses: p.Kind == KindModule || p.Kind == KindController || p.Kind == KindNodeTools || p.Kind == KindNode ||
|
||||
AllowResponses: p.Kind == KindModule || p.Kind == KindNodeTools || p.Kind == KindNode ||
|
||||
p.Kind == KindProvisioner,
|
||||
}, nil
|
||||
}
|
||||
@@ -1448,7 +1158,7 @@ func vaultCalls(p Principal) []string {
|
||||
}
|
||||
var out []string
|
||||
if p.AsksTheDesk {
|
||||
for _, v := range VerbsTheControllerAsksForASecret {
|
||||
for _, v := range VerbsTheVaultAsksForASecret {
|
||||
out = append(out, "mesh.seat."+v.Seat+"."+CallKind+"."+v.Verb+".*."+token)
|
||||
}
|
||||
}
|
||||
@@ -1481,13 +1191,6 @@ func seatSubject(s Seat, kind, verb string) string {
|
||||
// seat carries the node it is asked of, because a flat subject would reach every machine's holder
|
||||
// and the queue group would silently pick a winner (novox/hq ADR 0132, design 33 §4). A holder
|
||||
// subscribes its own node's; a user publishes any node's (`*`) and names the machine in the subject.
|
||||
// servedOnlyByTheController says a seat's verbs are answered by the serving controller alone, on its own
|
||||
// connection (the KindController grant), never by a module claiming the seat or a runtime carrying it: the
|
||||
// controller's own seat. Its verbs decide what the mesh is — and `root-free` decides whether the router believes
|
||||
// a verified sender (novox/hq ADR 0259 §8) — so a machine's runtime, whose credential an agent on that machine
|
||||
// may hold, answering one would be an agent answering it (the confirmation review of 2026-10-09).
|
||||
func servedOnlyByTheController(s Seat) bool { return s.Name == ControllerSeat }
|
||||
|
||||
func seatToolSubject(s Seat, verb, node string) string {
|
||||
base := seatSubject(s, "tool", verb)
|
||||
if s.Scope == "node" && node != "" {
|
||||
@@ -1561,8 +1264,6 @@ func consumerStream(p Principal) string {
|
||||
return "EVENTS"
|
||||
case KindNode:
|
||||
return "NODES"
|
||||
case KindController:
|
||||
return "CONTROL"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1573,8 +1274,6 @@ func consumerDurable(p Principal) string {
|
||||
return p.Node + "_" + p.Module
|
||||
case KindNode:
|
||||
return p.Node
|
||||
case KindController:
|
||||
return "controller"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1602,7 +1301,7 @@ func Compose(s Server, principals []Principal) (string, error) {
|
||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Username() < sorted[j].Username() })
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("# Composed by the mesh controller. Do not edit: the next composition overwrites it.\n")
|
||||
b.WriteString("# Composed by the mesh. Do not edit: the next composition overwrites it.\n")
|
||||
b.WriteString("# Accounts and permissions are derived from what each module declares and nothing\n")
|
||||
b.WriteString("# else (novox/hq ADR 0043, design 29 §2).\n\n")
|
||||
|
||||
@@ -1656,7 +1355,7 @@ func ComposeAccounts(principals []Principal) (string, error) {
|
||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Username() < sorted[j].Username() })
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("# The mesh's users, composed by the controller. Do not edit: the next\n")
|
||||
b.WriteString("# The mesh's users, composed by the grants part. Do not edit: the next\n")
|
||||
b.WriteString("# composition overwrites it. Permissions are derived from what each module\n")
|
||||
b.WriteString("# declares and nothing else (novox/hq ADR 0043, design 29 §2).\n\n")
|
||||
|
||||
@@ -1871,15 +1570,9 @@ func discovering() []string {
|
||||
}
|
||||
|
||||
// WitnessSubjects are the subjects a machine's node-engine publishes to witness the core builds it
|
||||
// places (novox/hq ADR 0236, lease/witness.go): its own node tools' PING, and the lease's key where it
|
||||
// runs the controller. Reads only: a write to the bucket is `$KV.<bucket>.>`, the controller's alone
|
||||
// (the writers table). The answers come to the machine's own inbox.
|
||||
// places (novox/hq ADR 0236): its own node tools' PING. The answers come to the machine's own inbox.
|
||||
func WitnessSubjects(p Principal) []string {
|
||||
out := []string{pingSubject(p.Node)}
|
||||
if p.WitnessesController {
|
||||
out = append(out, leaseReadSubject(LeaseBucket))
|
||||
}
|
||||
return append(out, CheckSubjects(p)...)
|
||||
return append([]string{pingSubject(p.Node)}, CheckSubjects(p)...)
|
||||
}
|
||||
|
||||
// CheckSubjects are the tools a machine's node-engine asks as declared health checks (novox/hq ADR 0240,
|
||||
@@ -1903,18 +1596,10 @@ func CheckSubjects(p Principal) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// pingSubject and leaseReadSubject are the controller's lease witness's subjects (mesh-controller internal/lease), copied
|
||||
// with the composer so the grants it composes name them without the lease's code.
|
||||
// pingSubject is a node-engine's witness's ping of its own node's tools (novox/hq ADR 0236).
|
||||
func pingSubject(node string) string { return "$SRV.PING." + nodeToolsService + "." + node }
|
||||
|
||||
func leaseReadSubject(bucket string) string {
|
||||
return "$JS.API.DIRECT.GET.KV_" + bucket + ".$KV." + bucket + "." + leaseKey
|
||||
}
|
||||
|
||||
const (
|
||||
nodeToolsService = "node-tools"
|
||||
leaseKey = "holder"
|
||||
)
|
||||
const nodeToolsService = "node-tools"
|
||||
|
||||
// VaultOwnCall is the subject a machine principal's node-engine asks the vault's `own` on, in its own name.
|
||||
func VaultOwnCall(p Principal) string {
|
||||
|
||||
@@ -41,14 +41,15 @@ var ConfigResolvedBucket = BucketName("mesh-config", "resolved")
|
||||
// provisioner adds its bucket here, and a bucket not named is one the client cannot read.
|
||||
var ProvisionerReads = []string{ConfigResolvedBucket}
|
||||
|
||||
// ProvisionerReadsWhole are the buckets a provisioner client reads every key of: the controller's assignments mirror,
|
||||
// where a provider's consumers are bound to it in their own machines' plans, which the provider machine's client
|
||||
// reads to fill ${consumers:<provision>} with each one's credential (novox/hq ADR 0373, issue 679). It holds placement
|
||||
// and plans, never a secret's value; the client writes none of it. **The seat's plans are read whole for the same
|
||||
// ProvisionerReadsWhole are the buckets a provisioner client reads every key of: the seat mesh-placement's placement,
|
||||
// where a provider's consumers are placed on their own machines, which the provider machine's client reads to fill
|
||||
// ${consumers:<provision>} with each one's credential (novox/hq ADR 0373, issue 679; ADR 0567, issue 1126: the
|
||||
// controller's assignments mirror it read before is gone). It holds placement, never a secret's value; the client
|
||||
// writes none of it. **The seat's plans are read whole for the same
|
||||
// reason** (2026-10-30 recovery, issue 774): the client V3 watches every machine's plans to bind a provider's
|
||||
// consumers, and granted only its own machine's keys it read none. ADR 0570 clause 3 has providers act on the seat's
|
||||
// provision keys instead; once the client does, plans go back to its own machine's keys.
|
||||
var ProvisionerReadsWhole = []string{AssignmentsBucket, provisions.PlansBucket}
|
||||
var ProvisionerReadsWhole = []string{delivery.PlacementBucket, provisions.PlansBucket}
|
||||
|
||||
// BoundState is a providing module's state of entries, one per consumer (mesh-sdk go/provider, issue 678): a
|
||||
// consumer's machine's client reads its own keys of it, `<node>.<module>.<provision>[.<local>]`, to fill
|
||||
|
||||
@@ -16,7 +16,7 @@ type Raiser interface {
|
||||
Ensurer
|
||||
}
|
||||
|
||||
// Raise asserts the mesh's streams, the controller's own consumers, and one consumer per node.
|
||||
// Raise asserts the mesh's streams, its own consumers, and one consumer per node.
|
||||
//
|
||||
// **The order is not a preference.** A consumer on a stream that does not exist is refused, and the
|
||||
// refusal names the stream rather than the order — so somebody reading it goes looking for a deleted
|
||||
|
||||
@@ -63,9 +63,6 @@ func SeatTrafficOf(module string, holds, uses, watches []Seat) SeatTraffic {
|
||||
// no way to, so the self-check's `checked` was never said. Listed here, the membership names them for the
|
||||
// runtime and the runtime is granted them. A machine's event on a node seat carries the machine, which
|
||||
// only the holder's own account is composed with, so it is left to that rule.
|
||||
if servedOnlyByTheController(s) {
|
||||
continue
|
||||
}
|
||||
for _, e := range s.Emits {
|
||||
if s.Scope == "node" && perMachineEvents[e] {
|
||||
continue
|
||||
|
||||
@@ -309,9 +309,9 @@ func RaiseBuckets(a BucketAsserter, buckets []Bucket) (undeclared []string, err
|
||||
return nil, fmt.Errorf("listing the bus's state: %w", err)
|
||||
}
|
||||
for _, n := range names {
|
||||
// A seat's cancelled set is the mesh's own (novox/hq ADR 0219), not a module's state; so are
|
||||
// the controller's own buckets (novox/hq to-be 45 §1).
|
||||
if !declared[n] && !IsCancelledSet(n) && !IsControllerBucket(n) {
|
||||
// A seat's cancelled set is the mesh's own (novox/hq ADR 0219), not a module's state. The ended controller's
|
||||
// buckets are undeclared like any other until they are removed (ADR 0590 clause 6).
|
||||
if !declared[n] && !IsCancelledSet(n) {
|
||||
undeclared = append(undeclared, n)
|
||||
}
|
||||
}
|
||||
|
||||
+13
-266
@@ -144,26 +144,14 @@ func OriginalOfAgain(subject string) (string, bool) {
|
||||
|
||||
// MeshStreams is the foundation set, in the order a person reads it.
|
||||
//
|
||||
// **CONTROL names its subjects rather than taking `mesh.control.>`**, because heartbeats live
|
||||
// under that prefix and must not be persisted: a lost heartbeat is the next heartbeat, and a
|
||||
// stream of them is a stream of the least valuable messages the mesh sends, competing for the
|
||||
// same retention as the ones that matter.
|
||||
//
|
||||
// **EVENTS filters on the `event` token**, which is the reason that token exists. A module's
|
||||
// namespace carries both its events and its tool calls; a filter of `mesh.mod.*.>` would persist
|
||||
// every tool invocation in the mesh, and a tool call must never be persisted (design 25 §3 keeps
|
||||
// tools on core NATS, where a lost call is a timeout the caller already handles).
|
||||
func MeshStreams() []Stream {
|
||||
return []Stream{
|
||||
{
|
||||
Name: "CONTROL",
|
||||
// A build's outcome is no longer here: it is the build-machine seat's own event, so one
|
||||
// publish reaches whoever asked, the controller and the catalogue (novox/hq ADR 0121).
|
||||
Subjects: []string{"mesh.control.*.report", "mesh.control.enrol"},
|
||||
Retention: RetentionWorkQueue,
|
||||
Why: "the store-window guarantee (ADR 0083): the controller naks with a delay while its " +
|
||||
"store is away and the message is redelivered; nothing is dropped",
|
||||
},
|
||||
// CONTROL, the ended controller's work queue of what nodes reported, is no longer the mesh's (novox/hq ADR 0590
|
||||
// clause 6): not declared here, which would make it again; the bus module's tools remove it.
|
||||
{
|
||||
Name: "NODES",
|
||||
Subjects: []string{"mesh.node.*.declare"},
|
||||
@@ -235,18 +223,6 @@ const ProvAppliedStream = "PROV_APPLIED"
|
||||
// all (ProvisionerAppliedSubject).
|
||||
const ProvAppliedSubjects = "prov.applied.>"
|
||||
|
||||
// eventsTheControllerFollows are the events the controller's events consumer takes: all it follows but the forge's
|
||||
// merges, which the merges consumer takes (MergesConsumerName).
|
||||
func eventsTheControllerFollows() []string {
|
||||
var out []string
|
||||
for _, subject := range ControllerFollows {
|
||||
if subject != ControllerFollows[3] {
|
||||
out = append(out, subject)
|
||||
}
|
||||
}
|
||||
return append(out, ControllerHears...)
|
||||
}
|
||||
|
||||
// DeliverSubjectFor is where a push consumer's messages land.
|
||||
//
|
||||
// **Per consumer, which means per stream as well as per name** (novox/hq 04-ISSUES/146). A push
|
||||
@@ -321,147 +297,17 @@ func Overlaps() []string {
|
||||
return clashes
|
||||
}
|
||||
|
||||
// The mesh's own consumers.
|
||||
//
|
||||
// A seat's streams and a module's consumers are derived from declarations (derived.go). These two
|
||||
// are not: **the controller is not a module and files no manifest**, so its authority and its
|
||||
// subscriptions cannot come from a declaration that does not exist. They are named here, where the
|
||||
// mesh's own streams are named, and narrowly — a controller subscribing `mesh.mod.*.event.>` would
|
||||
// hear every event in the mesh, which it has no business doing and which would make its permission
|
||||
// list stop explaining anything.
|
||||
|
||||
// ControllerName is the controller's durable consumer on each stream it reads, and the name its
|
||||
// ack subject is derived from (nats.go: `$JS.ACK.<stream>.controller.>`).
|
||||
const ControllerName = "controller"
|
||||
|
||||
// MergesConsumerName is the controller's durable consumer of the forge's merged event on EVENTS, apart from the
|
||||
// events consumer (novox/hq ADR 0407 decision 3, issue 973). A merge is never dropped, so this consumer has no
|
||||
// max-deliver, which would send a merge held five times to DEAD_LETTERS to wait for a person, and no healer
|
||||
// resets it, which would drop the merges it holds. A consumer of its own because both of those are properties of
|
||||
// a consumer, not of a subject.
|
||||
const MergesConsumerName = "controller-merges"
|
||||
|
||||
// ControllerSeat is the role the control plane holds, and ControllerStates are the facts it states
|
||||
// under it (novox/hq ADR 0134).
|
||||
//
|
||||
// **Written here as well as in `link`, and a test keeps them agreeing.** `link` imports `broker`, so
|
||||
// `broker` cannot import `link`; a grant naming a subject the controller never publishes is authority
|
||||
// nobody uses, and a controller publishing one the grant omits is refused at the moment it has
|
||||
// something to say.
|
||||
const ControllerSeat = "mesh-controller"
|
||||
|
||||
var ControllerStates = []string{"applied", "refused", "built-before",
|
||||
// What is wrong, said as it changes (novox/hq to-be 45 §2): a condition raised, changed in
|
||||
// severity, resolver or silence, and cleared. The operator-channel's holder and any other surface
|
||||
// consume them; the controller tells nobody itself.
|
||||
"condition-raised", "condition-changed", "condition-cleared",
|
||||
// And the self-check's heartbeat, at the end of every run (to-be 45 §4, S10): watched from a
|
||||
// machine that is not the control node, so the controller going quiet is itself said.
|
||||
"doctor-heartbeat",
|
||||
// And a value given by hand, replaced after its module's first good start (novox/hq ADR 0228).
|
||||
"secret-replaced",
|
||||
// And every act a healer takes on a condition (novox/hq to-be 45 §7, Phase 3): a repair the mesh
|
||||
// made by itself is said like one a person made, never quietly.
|
||||
"healer-acted",
|
||||
// And a build put back after its gate failed on its first machine (novox/hq ADR 0236, to-be 45 §8).
|
||||
"rolled-back",
|
||||
// And a pull request's merge check, judged (novox/hq to-be 45 §9): what the forge's holder sets as
|
||||
// the pull request's status, and anybody else may read.
|
||||
"checked",
|
||||
// (A walk kept in a new state and a tier sent to a machine are gone with the plans, novox/hq ADR 0539.)
|
||||
// And a key of a record mirror written or removed (novox/hq ADR 0289, design 54 §3): the assignments and
|
||||
// each registered module's declared config (design 57 F3).
|
||||
"assignment-changed", "declared-config-changed", "predefined-changed",
|
||||
// And a builder's key not taken in because the artifact store does not hold what it names (novox/hq issue 1937).
|
||||
"artifacts-gone",
|
||||
// And a bucket of the plans' state deleted at the controller's first start, and the declarations' stream once no node
|
||||
// reads one (novox/hq ADR 0539 clauses 11 and 13).
|
||||
"bucket-deleted"}
|
||||
|
||||
// BusAdvisories are what the bus server says about the mesh's own account that the controller
|
||||
// reads (novox/hq to-be 45 §3, S9): a durable consumer that handed a message over as often as it
|
||||
// may and gave up on it, and one that was deleted. Read-only: an advisory is the server's to
|
||||
// publish, and the controller's subscription changes nothing on the bus.
|
||||
var BusAdvisories = []string{
|
||||
"$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>",
|
||||
"$JS.EVENT.ADVISORY.CONSUMER.DELETED.>",
|
||||
}
|
||||
|
||||
// ControllerFollows are the events the controller reacts to: the catalogue saying a module's
|
||||
// current version moved, and a catalogue that has just started saying it may have missed builds.
|
||||
//
|
||||
// **Derived the same way a module's subscription is**, from the emitter and the bare local event
|
||||
// name, rather than written out. They were written out while the catalogue still spelled its events
|
||||
// as the old bus's routing keys, and the moment those were converted (novox/hq 04-ISSUES/127) a
|
||||
// hard-coded pair became a controller listening to a subject nothing publishes — the same fault, from
|
||||
// the other side. Deriving them means the conversion could not leave these behind.
|
||||
var ControllerFollows = []string{
|
||||
moduleEventSubject("mesh-catalog", "upgraded"),
|
||||
moduleEventSubject("mesh-catalog", "catching-up"),
|
||||
// A build's outcome, which is the build-machine role's own event now (ADR 0121) rather than a
|
||||
// message on the control branch. Same three audiences, one publish: whoever asked, this, and the
|
||||
// catalogue.
|
||||
seatEventSubject("node-build-agent", "built"),
|
||||
// The forge's merges: what moved a source, so the mesh builds what that source produces
|
||||
// without anybody telling it (novox/hq 04-ISSUES/131). Appended, because the index is a name.
|
||||
moduleEventSubject("gitea", "pull.merged"),
|
||||
// The retired build role's outcome too, while the handover runs (novox/hq ADR 0190): the one
|
||||
// build machine keeps answering on its seat until build-agent replaces it, and the outcome that
|
||||
// registers build-agent itself comes from there. Appended, for the same reason as above; goes
|
||||
// with the retired seat row.
|
||||
seatEventSubject("mesh-build-machine", "built"),
|
||||
// **Every provider's standing** (novox/hq ADR 0224): a consumer it has failed for minutes, and
|
||||
// that consumer recovered. The one pattern on this list, and a narrow one — two named events,
|
||||
// from whichever module provides — because the rule is about every provider, and a list of
|
||||
// providers here would be a list somebody forgets to extend. On 2026-10-05 the identity provider
|
||||
// failed every consumer for a day and only its journal said so (issue 179). Appended, because
|
||||
// the index is a name.
|
||||
moduleEventSubject("*", ProvisionerFailing),
|
||||
moduleEventSubject("*", ProvisionerRecovered),
|
||||
// **What becomes of a consumer the mesh stopped asking for** (novox/hq ADR 0230): retired, waiting
|
||||
// for a person, re-enabled, deleted — a provider's third word, from whichever module provides.
|
||||
// Appended, because the index is a name.
|
||||
moduleEventSubject("*", ProvisionerRetirement),
|
||||
// **A pull request's head, announced** (novox/hq to-be 45 §9): what the controller asks the build
|
||||
// seat to check before it merges — every machine of the facts snapshot composed with the change.
|
||||
// Appended, because the index is a name.
|
||||
moduleEventSubject("gitea", "pull.updated"),
|
||||
// **The operator's answers to what the controller asked** (novox/hq ADR 0259): the router's warrant, or
|
||||
// the end of an ask without one, said to the controller alone under its own name. On the stream, so a
|
||||
// controller that was away hears what was decided meanwhile. Appended, because the index is a name.
|
||||
DecidedSubject,
|
||||
}
|
||||
|
||||
// ControllerHears are events the controller's events consumer takes beside the ones it follows, and that need no
|
||||
// subscribe grant of their own. **A push consumer delivers onto its deliver subject** (`_DELIVER.controller.>`, already
|
||||
// granted), so the subject a consumer filters on is never one the controller subscribes: listing this one in
|
||||
// ControllerFollows would add a subscribe grant the installer's carried user list, which lives in mesh-host, would
|
||||
// have to carry too (TestTheInstallersFirstUserListIsWhatTheControllerWouldCompose), for nothing the consumer uses.
|
||||
//
|
||||
// A merge mesh-delivery made, announced (novox/hq ADR 0420 §7, issue 1025): with the ask whose Approve it merged on and
|
||||
// the heads that Approve covered, kept a week to read the warrant a moved module's registration needs.
|
||||
var ControllerHears = []string{DeliveryMergedSubject}
|
||||
|
||||
// DeliveryMergedEvent is the event a module says when it merged a pull request.
|
||||
const DeliveryMergedEvent = "merged"
|
||||
|
||||
// DeliveryMergedSubject is where the delivery's owner says it merged a pull request: one wildcard subject, the
|
||||
// module that claims the delivery seat being whichever the catalogue says (novox/hq ADR 0437 §3). Who said it is
|
||||
// read from the subject, and whether it claims the seat is the controller's to check.
|
||||
var DeliveryMergedSubject = moduleEventSubject("*", DeliveryMergedEvent)
|
||||
|
||||
// DeliveryMergedSubjectOf is where one module says it merged a pull request.
|
||||
func DeliveryMergedSubjectOf(module string) string {
|
||||
return moduleEventSubject(module, DeliveryMergedEvent)
|
||||
}
|
||||
// The ended controller's consumers' names (novox/hq ADR 0590 clause 6): kept only so its leftovers are named for their
|
||||
// removal (ControllerLeftoverConsumers) and the watchman can read whether `controller` on DEAD_LETTER_NOTICES still
|
||||
// stands.
|
||||
const (
|
||||
ControllerName = "controller"
|
||||
MergesConsumerName = "controller-merges"
|
||||
)
|
||||
|
||||
// AsksSeat is the seat an ask is made on and its warrant heard from (novox/hq ADR 0259): the router's.
|
||||
const AsksSeat = "operator-channel"
|
||||
|
||||
// DecidedSubject is where the router says the controller's warrants: the seat's event named by the
|
||||
// controller as its caller.
|
||||
var DecidedSubject = seatEventSubject(AsksSeat, "decided."+ControllerSeat)
|
||||
|
||||
// The provider standing events, by their local names. Written here as well as in the catalogue
|
||||
// (catalogue.ProvisionerEvents), which this package cannot import; a test keeps them agreeing.
|
||||
const (
|
||||
@@ -484,108 +330,9 @@ func seatEventSubject(seat, verb string) string {
|
||||
// EventsStream holds every module's and every role's events, a build's log among them.
|
||||
const EventsStream = "EVENTS"
|
||||
|
||||
// MeshConsumers is what the controller consumes, in the order a person reads it.
|
||||
//
|
||||
// **Unlimited redelivery on CONTROL, deliberately.** The store window's bound is the controller's,
|
||||
// not the server's (window.go): a message is held with a nak-and-delay until the controller either
|
||||
// takes it or gives up and says so. A max-deliver here would give up on a push that was being
|
||||
// held through a store restart — the exact message the stream exists to protect — some minutes
|
||||
// before the controller had finished deciding about it.
|
||||
func MeshConsumers() []Consumer {
|
||||
return ControllerConsumers()
|
||||
}
|
||||
|
||||
// ControllerConsumers are the consumers the controller's own event loop takes from: MeshConsumers without the build
|
||||
// seat's check workers, which the builders take from and the loop never does (issue 1063). A consumer added to the
|
||||
// controller is counted here without a change elsewhere.
|
||||
func ControllerConsumers() []Consumer {
|
||||
return []Consumer{
|
||||
{
|
||||
Name: ControllerName,
|
||||
Stream: "CONTROL",
|
||||
Push: true,
|
||||
AckWaitSeconds: 30,
|
||||
Why: "the controller is the single consumer of what nodes say; explicit ack and no " +
|
||||
"max-deliver, because the store window's bound is the controller's own",
|
||||
},
|
||||
// The forge's merges (novox/hq ADR 0407 decision 3, issue 973): never dropped, so no max-deliver and no
|
||||
// reset; a merge the controller cannot record yet is held with a growing delay until it can. **Asserted before
|
||||
// the events consumer**, which stops filtering merges: the order of this list is the order of assertion.
|
||||
{
|
||||
Name: MergesConsumerName,
|
||||
Stream: "EVENTS",
|
||||
// Within what the controller may subscribe already (`_DELIVER.controller.>`).
|
||||
DeliverSubject: "_DELIVER." + ControllerName + ".merges",
|
||||
Filters: []string{ControllerFollows[3]},
|
||||
Push: true,
|
||||
AckWaitSeconds: 30,
|
||||
MaxAckPending: 1,
|
||||
FromNow: true,
|
||||
TakesOverFrom: ControllerName,
|
||||
Why: "the forge's merges, one at a time; no max-deliver and never reset, because a merge is never " +
|
||||
"dropped and no person releases one",
|
||||
},
|
||||
{
|
||||
Name: ControllerName,
|
||||
Stream: "EVENTS",
|
||||
Filters: append(eventsTheControllerFollows(), AgainFilter(ControllerName)),
|
||||
Push: true,
|
||||
AckWaitSeconds: 30,
|
||||
MaxDeliver: 5,
|
||||
// One at a time (novox/hq issue 175): acting on a merge builds for minutes, and an
|
||||
// announcement handed over behind it must wait on the server, not time out on the
|
||||
// client and come back to be acted on again.
|
||||
MaxAckPending: 1,
|
||||
FromNow: true,
|
||||
// **The one consumer the mesh resets by itself** (healer H4, issue 248): it fell a week
|
||||
// behind once and held every merge after it. What a reset drops is caught up elsewhere — a
|
||||
// build's outcome from the build records a plan settles from (issue 214), a provider's failing
|
||||
// word by the provider saying it again every quarter of an hour (ADR 0224). **Not the merges**:
|
||||
// they are the merges consumer's below, which a reset leaves alone (ADR 0407 decision 3).
|
||||
Resettable: "what it drops is caught up: build outcomes from the build records (issue 214), a " +
|
||||
"provider's failing word said again (ADR 0224); the forge's merges are not its, they have a " +
|
||||
"consumer of their own that no reset touches",
|
||||
Why: "the events the mesh's own controller reacts to, one at a time; after " +
|
||||
"max-deliver it gives the event up, and the controller keeps it in DEAD_LETTERS until " +
|
||||
"a person delivers it again or drops it",
|
||||
},
|
||||
// What the server said a consumer gave up on (novox/hq issue 330): copied into DEAD_LETTERS and
|
||||
// acknowledged. No max-deliver: a notice the controller could not copy is offered again, and said.
|
||||
{
|
||||
Name: ControllerName,
|
||||
Stream: DeadLetterNoticesStream,
|
||||
Push: true,
|
||||
AckWaitSeconds: 30,
|
||||
Why: "the controller copies each message a consumer gave up on into DEAD_LETTERS; no max-deliver, " +
|
||||
"because a notice it gave up on would lose the message it is about",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NoticesConsumer is the controller's consumer on DEAD_LETTER_NOTICES (novox/hq issue 330).
|
||||
func NoticesConsumer() Consumer {
|
||||
for _, c := range MeshConsumers() {
|
||||
if c.Stream == DeadLetterNoticesStream {
|
||||
return c
|
||||
}
|
||||
}
|
||||
panic("the mesh's consumers carry none on " + DeadLetterNoticesStream)
|
||||
}
|
||||
|
||||
// AssertServingConsumers are the controller's own consumers its serving cannot go without: all but the
|
||||
// one on DEAD_LETTER_NOTICES, which the keeper of dead letters asserts and retries by itself, so a fault
|
||||
// there never stops the controller serving (novox/hq issue 330).
|
||||
func AssertServingConsumers(e Ensurer) error {
|
||||
for _, c := range MeshConsumers() {
|
||||
if c.Stream == DeadLetterNoticesStream {
|
||||
continue
|
||||
}
|
||||
if err := e.EnsureConsumer(c); err != nil {
|
||||
return fmt.Errorf("asserting consumer %s on %s: %w", c.Name, c.Stream, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// MeshConsumers are the mesh's own durable consumers, beside those derived from declarations: none since the controller
|
||||
// ended (novox/hq ADR 0590), whose consumers they were.
|
||||
func MeshConsumers() []Consumer { return nil }
|
||||
|
||||
// Ensurer is the part of a JetStream connection consumer assertion needs, narrow for the reason
|
||||
// Asserter is.
|
||||
@@ -593,7 +340,7 @@ type Ensurer interface {
|
||||
EnsureConsumer(c Consumer) error
|
||||
}
|
||||
|
||||
// AssertMeshConsumers brings the controller's own consumers into being, and says which one failed.
|
||||
// AssertMeshConsumers brings the mesh's own consumers into being, and says which one failed.
|
||||
//
|
||||
// After the streams, necessarily: a consumer on a stream that does not exist is refused, and the
|
||||
// refusal names the stream rather than the order.
|
||||
|
||||
Vendored
+13
-18
@@ -1,4 +1,4 @@
|
||||
# Composed by the mesh controller. Do not edit: the next composition overwrites it.
|
||||
# Composed by the mesh. Do not edit: the next composition overwrites it.
|
||||
# Accounts and permissions are derived from what each module declares and nothing
|
||||
# else (novox/hq ADR 0043, design 29 §2).
|
||||
|
||||
@@ -15,7 +15,7 @@ jetstream {
|
||||
store_dir: "/data"
|
||||
}
|
||||
|
||||
# The mesh's users, composed by the controller. Do not edit: the next
|
||||
# The mesh's users, composed by the grants part. Do not edit: the next
|
||||
# composition overwrites it. Permissions are derived from what each module
|
||||
# declares and nothing else (novox/hq ADR 0043, design 29 §2).
|
||||
|
||||
@@ -28,23 +28,18 @@ accounts {
|
||||
{ service: { account: "$SYS", subject: "$SYS.REQ.ACCOUNT.MESH.CONNZ" }, to: "mesh.bus.connz" }
|
||||
]
|
||||
users = [
|
||||
{ user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: {
|
||||
publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.DEAD_LETTER_NOTICES.controller.>", "$JS.ACK.EVENTS.controller-merges.>", "$JS.ACK.EVENTS.controller.>", "$JS.ACK.KV_mesh-delivery-builder_builds.mesh-controller-take-in.>", "$JS.ACK.KV_mesh-delivery-indexer_modules.mesh-controller-take-in.>", "$JS.API.>", "$KV.mesh-controller_asked.>", "$KV.mesh-controller_assignments.>", "$KV.mesh-controller_builds.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_composed.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_declared-config.>", "$KV.mesh-controller_declared-packages.>", "$KV.mesh-controller_delivery-merges.>", "$KV.mesh-controller_grants.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$KV.mesh-controller_predefined.>", "$KV.mesh-controller_rollouts.>", "$KV.mesh-controller_states-announced.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.again.>", "mesh.assignment.>", "mesh.events.dead.>", "mesh.mod.*.tool.>", "mesh.mod.mesh-controller.event.states", "mesh.node.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.artifacts-gone", "mesh.seat.mesh-controller.event.assignment-changed", "mesh.seat.mesh-controller.event.bucket-deleted", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.checked", "mesh.seat.mesh-controller.event.condition-changed", "mesh.seat.mesh-controller.event.condition-cleared", "mesh.seat.mesh-controller.event.condition-raised", "mesh.seat.mesh-controller.event.declared-config-changed", "mesh.seat.mesh-controller.event.doctor-heartbeat", "mesh.seat.mesh-controller.event.healer-acted", "mesh.seat.mesh-controller.event.predefined-changed", "mesh.seat.mesh-controller.event.refused", "mesh.seat.mesh-controller.event.rolled-back", "mesh.seat.mesh-controller.event.secret-replaced", "mesh.seat.mesh-controller.tool.plans", "mesh.seat.mesh-controller.tool.push", "mesh.seat.mesh-controller.tool.upgrade", "mesh.seat.mesh-delivery.tool.close", "mesh.seat.mesh-delivery.tool.groups", "mesh.seat.mesh-delivery.tool.release", "mesh.seat.mesh-delivery.tool.stalled", "mesh.seat.mesh-delivery.tool.stop", "mesh.seat.mesh-delivery.tool.times", "mesh.seat.node-backup.tool.backed-up.*", "mesh.seat.node-backup.tool.now.*", "mesh.seat.node-intrusion-prevention.tool.banned.*", "mesh.seat.node-launcher.tool.secret.*", "mesh.seat.node-service-manager.tool.restart.*", "mesh.seat.operator-channel.tool.tell"] }
|
||||
subscribe: { allow: ["$JS.API.>", "$JS.EVENT.ADVISORY.CONSUMER.DELETED.>", "$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>", "$SRV.INFO", "$SRV.INFO.mesh-controller", "$SRV.INFO.mesh-controller.>", "$SRV.PING", "$SRV.PING.mesh-controller", "$SRV.PING.mesh-controller.>", "$SRV.STATS", "$SRV.STATS.mesh-controller", "$SRV.STATS.mesh-controller.>", "_DELIVER.controller", "_DELIVER.controller.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.*.event.provisioner.failing", "mesh.mod.*.event.provisioner.recovered", "mesh.mod.*.event.provisioner.retirement", "mesh.mod.gitea.event.pull.merged", "mesh.mod.gitea.event.pull.updated", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.built", "mesh.seat.mesh-controller.tool.>", "mesh.seat.node-build-agent.event.built", "mesh.seat.operator-channel.event.decided.mesh-controller"] }
|
||||
allow_responses: { max: 1, ttl: "1m" }
|
||||
} }
|
||||
{ user: "enrol.one", password: "$2a$11$eeeeeeeeeeeeeeeeeeeeee", permissions: {
|
||||
publish: { allow: ["mesh.control.enrol"] }
|
||||
publish: { allow: ["mesh.seat.mesh-enrolment.call.redeem.enrol~one"] }
|
||||
subscribe: { allow: ["_INBOX.enrol.one.>"] }
|
||||
} }
|
||||
{ user: "node.one", password: "$2a$11$nnnnnnnnnnnnnnnnnnnnnn", permissions: {
|
||||
publish: { allow: ["$JS.ACK.NODES.one.>", "$JS.API.CONSUMER.CREATE.KV_composed-one.>", "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_wants.*.$KV.mesh-delivery-assigner_wants.want.one.>", "$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.one.>", "$JS.API.CONSUMER.CREATE.OBJ_declaration-files-one.*.$O.declaration-files-one.C.*", "$JS.API.CONSUMER.DELETE.KV_composed-one.>", "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_wants.>", "$JS.API.CONSUMER.DELETE.KV_node-engine_have.>", "$JS.API.CONSUMER.DELETE.OBJ_declaration-files-one.*", "$JS.API.CONSUMER.INFO.NODES.one", "$JS.API.DIRECT.GET.KV_composed-one.>", "$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_wants.$KV.mesh-delivery-assigner_wants.want.one.>", "$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.one.>", "$JS.API.DIRECT.GET.OBJ_declaration-files-one.$O.declaration-files-one.M.*", "$JS.API.STREAM.INFO.KV_composed-one", "$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_wants", "$JS.API.STREAM.INFO.KV_node-engine_have", "$JS.FC.KV_composed-one.>", "$JS.FC.KV_mesh-delivery-assigner_wants.>", "$JS.FC.KV_node-engine_have.>", "$JS.FC.OBJ_declaration-files-one.>", "$KV.node-engine_have.one.>", "$SRV.PING.node-tools.one", "mesh.control.one.>", "mesh.seat.node-backup.call.backed-up.one.node~one", "mesh.seat.node-backup.call.now.one.node~one", "mesh.seat.node-backup.tool.backed-up.one", "mesh.seat.node-backup.tool.now.one"] }
|
||||
subscribe: { allow: ["_DELIVER.one", "_DELIVER.one.>", "_INBOX.node.one.>", "mesh.mod.restic.event.backed-up", "mesh.node.one.ask.access-act", "mesh.node.one.ask.hand-over", "mesh.node.one.ask.judge-directories", "mesh.node.one.ask.move-secret", "mesh.node.one.ask.report", "mesh.node.one.ask.setuid-search", "mesh.node.one.declare"] }
|
||||
publish: { allow: ["$JS.ACK.NODES.one.>", "$JS.API.CONSUMER.CREATE.KV_composed-one.>", "$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.one.>", "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_candidates.>", "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_placement.>", "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_wants.*.$KV.mesh-delivery-assigner_wants.want.one.>", "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_builds.>", "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_manifests.>", "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_modules.>", "$JS.API.CONSUMER.CREATE.KV_mesh-host_acts.*.$KV.mesh-host_acts.one.>", "$JS.API.CONSUMER.CREATE.KV_mesh-host_data.*.$KV.mesh-host_data.one.>", "$JS.API.CONSUMER.CREATE.KV_mesh-host_health.*.$KV.mesh-host_health.one", "$JS.API.CONSUMER.CREATE.KV_mesh-host_packages.*.$KV.mesh-host_packages.one", "$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_provisions.*.$KV.mesh-provisioner_provisions.one.>", "$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.*.node", "$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.one.>", "$JS.API.CONSUMER.CREATE.OBJ_declaration-files-one.*.$O.declaration-files-one.C.*", "$JS.API.CONSUMER.DELETE.KV_composed-one.>", "$JS.API.CONSUMER.DELETE.KV_mesh-config_resolved.>", "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_candidates.>", "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_placement.>", "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_wants.>", "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_builds.>", "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_manifests.>", "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_modules.>", "$JS.API.CONSUMER.DELETE.KV_mesh-host_acts.>", "$JS.API.CONSUMER.DELETE.KV_mesh-host_data.>", "$JS.API.CONSUMER.DELETE.KV_mesh-host_health.>", "$JS.API.CONSUMER.DELETE.KV_mesh-host_packages.>", "$JS.API.CONSUMER.DELETE.KV_mesh-provisioner_provisions.>", "$JS.API.CONSUMER.DELETE.KV_node-engine_have.>", "$JS.API.CONSUMER.DELETE.OBJ_declaration-files-one.*", "$JS.API.CONSUMER.INFO.NODES.one", "$JS.API.DIRECT.GET.KV_composed-one.>", "$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.one.>", "$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_candidates.>", "$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_placement.>", "$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_wants.$KV.mesh-delivery-assigner_wants.want.one.>", "$JS.API.DIRECT.GET.KV_mesh-delivery-builder_builds.>", "$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_manifests.>", "$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_modules.>", "$JS.API.DIRECT.GET.KV_mesh-host_acts.$KV.mesh-host_acts.one.>", "$JS.API.DIRECT.GET.KV_mesh-host_data.$KV.mesh-host_data.one.>", "$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.one", "$JS.API.DIRECT.GET.KV_mesh-host_packages.$KV.mesh-host_packages.one", "$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.one.>", "$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.*.node", "$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.one.>", "$JS.API.DIRECT.GET.OBJ_declaration-files-one.$O.declaration-files-one.M.*", "$JS.API.STREAM.INFO.KV_composed-one", "$JS.API.STREAM.INFO.KV_mesh-config_resolved", "$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_candidates", "$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_placement", "$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_wants", "$JS.API.STREAM.INFO.KV_mesh-delivery-builder_builds", "$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_manifests", "$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_modules", "$JS.API.STREAM.INFO.KV_mesh-host_acts", "$JS.API.STREAM.INFO.KV_mesh-host_data", "$JS.API.STREAM.INFO.KV_mesh-host_health", "$JS.API.STREAM.INFO.KV_mesh-host_packages", "$JS.API.STREAM.INFO.KV_mesh-provisioner_provisions", "$JS.API.STREAM.INFO.KV_node-engine_have", "$JS.FC.KV_composed-one.>", "$JS.FC.KV_mesh-config_resolved.>", "$JS.FC.KV_mesh-delivery-assigner_candidates.>", "$JS.FC.KV_mesh-delivery-assigner_placement.>", "$JS.FC.KV_mesh-delivery-assigner_wants.>", "$JS.FC.KV_mesh-delivery-builder_builds.>", "$JS.FC.KV_mesh-delivery-indexer_manifests.>", "$JS.FC.KV_mesh-delivery-indexer_modules.>", "$JS.FC.KV_mesh-host_acts.>", "$JS.FC.KV_mesh-host_data.>", "$JS.FC.KV_mesh-host_health.>", "$JS.FC.KV_mesh-host_packages.>", "$JS.FC.KV_mesh-provisioner_provisions.>", "$JS.FC.KV_node-engine_have.>", "$JS.FC.OBJ_declaration-files-one.>", "$KV.mesh-host_acts.one.>", "$KV.mesh-host_data.one.>", "$KV.mesh-host_health.one", "$KV.mesh-host_packages.one", "$KV.node-engine_have.one.>", "$SRV.INFO", "$SRV.INFO.>", "$SRV.PING", "$SRV.PING.>", "$SRV.PING.node-tools.one", "mesh.control.one.>", "mesh.seat.node-backup.call.backed-up.one.node~one", "mesh.seat.node-backup.call.now.one.node~one", "mesh.seat.node-backup.tool.backed-up.one", "mesh.seat.node-backup.tool.now.one", "mesh.seat.node-engine.event.*.one"] }
|
||||
subscribe: { allow: ["$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_candidates", "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_placement", "$SRV.INFO", "$SRV.INFO.node-engine", "$SRV.INFO.node-engine.>", "$SRV.PING", "$SRV.PING.node-engine", "$SRV.PING.node-engine.>", "$SRV.STATS", "$SRV.STATS.node-engine", "$SRV.STATS.node-engine.>", "_DELIVER.one", "_DELIVER.one.>", "_INBOX.node.one.>", "mesh.mod.restic.event.backed-up", "mesh.node.one.ask.access-act", "mesh.node.one.ask.hand-over", "mesh.node.one.ask.judge-directories", "mesh.node.one.ask.move-secret", "mesh.node.one.ask.report", "mesh.node.one.ask.setuid-search", "mesh.node.one.declare", "mesh.seat.node-engine.call.*.one.*", "mesh.seat.node-engine.tool.*.one"] }
|
||||
allow_responses: { max: 1, ttl: "1m" }
|
||||
} }
|
||||
{ user: "one.mesh-delivery-watchman", password: "$2a$11$wwwwwwwwwwwwwwwwwwwwww", permissions: {
|
||||
publish: { allow: ["$JS.ACK.EVENTS.one_mesh-delivery-watchman.>", "$JS.API.CONSUMER.INFO.EVENTS.one_mesh-delivery-watchman", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.one_mesh-delivery-watchman", "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.one.mesh-delivery-watchman", "mesh.bus.connz"] }
|
||||
subscribe: { allow: ["$SRV.INFO", "$SRV.INFO.mesh-delivery-watchman", "$SRV.INFO.mesh-delivery-watchman.>", "$SRV.PING", "$SRV.PING.mesh-delivery-watchman", "$SRV.PING.mesh-delivery-watchman.>", "$SRV.STATS", "$SRV.STATS.mesh-delivery-watchman", "$SRV.STATS.mesh-delivery-watchman.>", "_INBOX.one.mesh-delivery-watchman.>", "mesh.assignment.one.mesh-delivery-watchman", "mesh.bus.connect", "mesh.bus.disconnect", "mesh.mod.mesh-delivery-watchman.call.>", "mesh.mod.mesh-delivery-watchman.tool.>"] }
|
||||
publish: { allow: ["$JS.ACK.DEAD_LETTER_NOTICES.mesh-delivery-watchman.>", "$JS.ACK.EVENTS.one_mesh-delivery-watchman.>", "$JS.API.CONSUMER.CREATE.DEAD_LETTER_NOTICES.mesh-delivery-watchman", "$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.one.mesh-delivery-watchman", "$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.>", "$JS.API.CONSUMER.INFO.DEAD_LETTER_NOTICES.controller", "$JS.API.CONSUMER.INFO.DEAD_LETTER_NOTICES.mesh-delivery-watchman", "$JS.API.CONSUMER.INFO.EVENTS.one_mesh-delivery-watchman", "$JS.API.CONSUMER.MSG.NEXT.DEAD_LETTER_NOTICES.mesh-delivery-watchman", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.one_mesh-delivery-watchman", "$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.one.mesh-delivery-watchman", "$JS.API.DIRECT.GET.PROV_APPLIED.>", "$JS.API.STREAM.INFO.DEAD_LETTERS", "$JS.API.STREAM.INFO.DEAD_LETTER_NOTICES", "$JS.API.STREAM.INFO.KV_mesh-grants_memberships", "$JS.API.STREAM.INFO.PROV_APPLIED", "$JS.API.STREAM.MSG.GET.DEAD_LETTERS", "$JS.API.STREAM.MSG.GET.PROV_APPLIED", "$JS.FC.KV_mesh-grants_memberships.>", "mesh.bus.connz", "mesh.events.dead.>"] }
|
||||
subscribe: { allow: ["$JS.EVENT.ADVISORY.CONSUMER.DELETED.>", "$SRV.INFO", "$SRV.INFO.mesh-delivery-watchman", "$SRV.INFO.mesh-delivery-watchman.>", "$SRV.PING", "$SRV.PING.mesh-delivery-watchman", "$SRV.PING.mesh-delivery-watchman.>", "$SRV.STATS", "$SRV.STATS.mesh-delivery-watchman", "$SRV.STATS.mesh-delivery-watchman.>", "_INBOX.one.mesh-delivery-watchman.>", "mesh.bus.connect", "mesh.bus.disconnect", "mesh.control.*.tools-alive", "mesh.events.dead.>", "mesh.mod.*.event.provisioner.failing", "mesh.mod.*.event.provisioner.recovered", "mesh.mod.mesh-delivery-watchman.call.>", "mesh.mod.mesh-delivery-watchman.tool.>", "mesh.mod.nats.event.bus.connection-closed", "mesh.mod.nats.event.bus.slow-consumer", "mesh.mod.power.event.*", "mesh.seat.mesh-self-check.event.checked", "prov.applied.>"] }
|
||||
allow_responses: { max: 1, ttl: "1m" }
|
||||
} }
|
||||
{ user: "one.nats", password: "$2a$11$bbbbbbbbbbbbbbbbbbbbbb", permissions: {
|
||||
@@ -52,18 +47,18 @@ accounts {
|
||||
subscribe: { allow: ["_INBOX.one.nats.>"] }
|
||||
} }
|
||||
{ user: "one.telegram", password: "$2a$11$tttttttttttttttttttttt", permissions: {
|
||||
publish: { allow: ["$JS.ACK.EVENTS.one_telegram.>", "$JS.ACK.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker.>", "$JS.API.CONSUMER.INFO.EVENTS.one_telegram", "$JS.API.CONSUMER.INFO.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.one_telegram", "$JS.API.CONSUMER.MSG.NEXT.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker", "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.one.telegram", "mesh.seat.telegram-sender.event.delivered", "mesh.seat.telegram-sender.event.failed"] }
|
||||
subscribe: { allow: ["$SRV.INFO", "$SRV.INFO.telegram", "$SRV.INFO.telegram.>", "$SRV.PING", "$SRV.PING.telegram", "$SRV.PING.telegram.>", "$SRV.STATS", "$SRV.STATS.telegram", "$SRV.STATS.telegram.>", "_INBOX.one.telegram.>", "mesh.assignment.one.telegram", "mesh.mod.telegram.call.>", "mesh.mod.telegram.tool.>", "mesh.seat.telegram-sender.accept.send"] }
|
||||
publish: { allow: ["$JS.ACK.EVENTS.one_telegram.>", "$JS.ACK.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker.>", "$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.one.telegram", "$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.>", "$JS.API.CONSUMER.INFO.EVENTS.one_telegram", "$JS.API.CONSUMER.INFO.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.one_telegram", "$JS.API.CONSUMER.MSG.NEXT.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker", "$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.one.telegram", "$JS.API.STREAM.INFO.KV_mesh-grants_memberships", "$JS.FC.KV_mesh-grants_memberships.>", "mesh.seat.telegram-sender.event.delivered", "mesh.seat.telegram-sender.event.failed"] }
|
||||
subscribe: { allow: ["$SRV.INFO", "$SRV.INFO.telegram", "$SRV.INFO.telegram.>", "$SRV.PING", "$SRV.PING.telegram", "$SRV.PING.telegram.>", "$SRV.STATS", "$SRV.STATS.telegram", "$SRV.STATS.telegram.>", "_INBOX.one.telegram.>", "mesh.mod.telegram.call.>", "mesh.mod.telegram.tool.>", "mesh.seat.telegram-sender.accept.send"] }
|
||||
allow_responses: { max: 1, ttl: "1m" }
|
||||
} }
|
||||
{ user: "two.audit", password: "$2a$11$aaaaaaaaaaaaaaaaaaaaaa", permissions: {
|
||||
publish: { allow: ["$JS.ACK.EVENTS.two_audit.>", "$JS.API.CONSUMER.INFO.EVENTS.two_audit", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.two_audit", "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.two.audit"] }
|
||||
subscribe: { allow: ["$SRV.INFO", "$SRV.INFO.audit", "$SRV.INFO.audit.>", "$SRV.PING", "$SRV.PING.audit", "$SRV.PING.audit.>", "$SRV.STATS", "$SRV.STATS.audit", "$SRV.STATS.audit.>", "_INBOX.two.audit.>", "mesh.assignment.two.audit", "mesh.mod.audit.call.>", "mesh.mod.audit.tool.>", "mesh.mod.shop.event.order.placed"] }
|
||||
publish: { allow: ["$JS.ACK.EVENTS.two_audit.>", "$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.two.audit", "$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.>", "$JS.API.CONSUMER.INFO.EVENTS.two_audit", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.two_audit", "$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.two.audit", "$JS.API.STREAM.INFO.KV_mesh-grants_memberships", "$JS.FC.KV_mesh-grants_memberships.>"] }
|
||||
subscribe: { allow: ["$SRV.INFO", "$SRV.INFO.audit", "$SRV.INFO.audit.>", "$SRV.PING", "$SRV.PING.audit", "$SRV.PING.audit.>", "$SRV.STATS", "$SRV.STATS.audit", "$SRV.STATS.audit.>", "_INBOX.two.audit.>", "mesh.mod.audit.call.>", "mesh.mod.audit.tool.>", "mesh.mod.shop.event.order.placed"] }
|
||||
allow_responses: { max: 1, ttl: "1m" }
|
||||
} }
|
||||
{ user: "two.shop", password: "$2a$11$ssssssssssssssssssssss", permissions: {
|
||||
publish: { allow: ["$JS.ACK.EVENTS.two_shop.>", "$JS.API.CONSUMER.INFO.EVENTS.two_shop", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.two_shop", "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.two.shop", "mesh.mod.shop.event.order.placed", "mesh.seat.telegram-sender.accept.send"] }
|
||||
subscribe: { allow: ["$SRV.INFO", "$SRV.INFO.shop", "$SRV.INFO.shop.>", "$SRV.PING", "$SRV.PING.shop", "$SRV.PING.shop.>", "$SRV.STATS", "$SRV.STATS.shop", "$SRV.STATS.shop.>", "_INBOX.two.shop.>", "mesh.assignment.two.shop", "mesh.mod.shop.call.>", "mesh.mod.shop.tool.>"] }
|
||||
publish: { allow: ["$JS.ACK.EVENTS.two_shop.>", "$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.two.shop", "$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.>", "$JS.API.CONSUMER.INFO.EVENTS.two_shop", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.two_shop", "$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.two.shop", "$JS.API.STREAM.INFO.KV_mesh-grants_memberships", "$JS.FC.KV_mesh-grants_memberships.>", "mesh.mod.shop.event.order.placed", "mesh.seat.telegram-sender.accept.send"] }
|
||||
subscribe: { allow: ["$SRV.INFO", "$SRV.INFO.shop", "$SRV.INFO.shop.>", "$SRV.PING", "$SRV.PING.shop", "$SRV.PING.shop.>", "$SRV.STATS", "$SRV.STATS.shop", "$SRV.STATS.shop.>", "_INBOX.two.shop.>", "mesh.mod.shop.call.>", "mesh.mod.shop.tool.>"] }
|
||||
allow_responses: { max: 1, ttl: "1m" }
|
||||
} }
|
||||
]
|
||||
|
||||
-283
File diff suppressed because one or more lines are too long
+289
File diff suppressed because one or more lines are too long
@@ -7,7 +7,6 @@ import (
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/announce"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/handacts"
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
)
|
||||
|
||||
@@ -88,23 +87,17 @@ const ArtifactStoreTools = "artifact-store-tools"
|
||||
const ConfigModule = "mesh-config"
|
||||
|
||||
// NamedReaders are the states only the modules named may read, by `<owner>.<name>`: the grants part's wanted accounts
|
||||
// are read by the vault alone (novox/hq ADR 0561 §2).
|
||||
// The controller's lease is read by the parts that turn on at its write (ADR 0561 §3, §6), and by the assigner, which
|
||||
// reads the catalogue's placement alone from then (ADR 0549 §5, issue 774).
|
||||
//
|
||||
// The health holders (novox/hq ADR 0564 Limits 2 and 8, clauses 8 and 9): the condition keeper, mesh-healers and the
|
||||
// watchman read the lease; the keeper alone the controller's conditions; mesh-healers and mesh-hand-acts its hand-act log
|
||||
// (ADR 0565 clauses 10, 11: every reader of acts reads both logs); and nobody the healers' `would` (health_grants.go).
|
||||
var NamedReaders = map[string][]string{VaultBusAccounts: {GrantsModule}, GrantsAccounts: {VaultSeat},
|
||||
ControllerLease: {VaultSeat, GrantsModule, delivery.AssignerModule, health.KeeperModule, health.HealersModule, WatchmanModule,
|
||||
// ADR 0571 clause 2: the artifact store's seat sweeps only once the lease names no controller.
|
||||
ArtifactStoreTools,
|
||||
// ADR 0561 §3: mesh-config takes over the config only once the lease is handed away (its takeover.go leaseAway).
|
||||
ConfigModule},
|
||||
ControllerConditions: {health.KeeperModule}, ControllerHandActs: {health.HealersModule, handacts.Module}, HealersWould: {},
|
||||
// are read by the vault alone (novox/hq ADR 0561 §2); nobody reads the healers' `would` (ADR 0564 clause 8,
|
||||
// health_grants.go).
|
||||
var NamedReaders = map[string][]string{VaultBusAccounts: {GrantsModule}, GrantsAccounts: {VaultSeat}, HealersWould: {},
|
||||
// ADR 0577 clause 1: the node-engine's data items are read by disk-load alone, each node's its own node's keys
|
||||
// (NodeKeyedReads); the engine announces nothing of its own, so its readers are named here.
|
||||
delivery.EngineDataRead: {health.DiskLoadModule}}
|
||||
delivery.EngineDataRead: {health.DiskLoadModule},
|
||||
// ADR 0583 §1 insight: every node's login is read by the grants part alone, each engine its own node's key through its
|
||||
// machine principal (NodeLoginsReads), never through `reads`.
|
||||
delivery.NodeLoginsRead: {GrantsModule},
|
||||
// ADR 0587 §2: the open enrolments are read by their holder and, as a named reader, by the grants part.
|
||||
delivery.EnrolmentsRead: {GrantsModule}}
|
||||
|
||||
// ReaderMay says whether module may read the state read (`<owner>.<name>`) by the named readers: always, for a state
|
||||
// that names none.
|
||||
|
||||
+56
-30
@@ -2,10 +2,13 @@ package buscompose
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.novox.be/novox/mesh-sdk/go/announce"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/announce"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// Every user the composed file should contain, derived from what the mesh knows.
|
||||
@@ -129,7 +132,8 @@ type Records struct {
|
||||
Assigned map[string][]Declared
|
||||
// Enrolling is every node with a live token — one enrolment user each, because the inbox an
|
||||
// answer goes to is scoped to the token and a shared one is one machine reading another's
|
||||
// sealed credentials (design 25 §6).
|
||||
// sealed credentials (design 25 §6). The grants part fills it from the seat mesh-enrolment's keys at live or
|
||||
// redeeming (novox/hq ADR 0587 §4), the controller from its store.
|
||||
Enrolling []string
|
||||
// People is each person's name against the tools they may invoke, `*` for an administrator.
|
||||
People map[string][]string
|
||||
@@ -145,40 +149,38 @@ type Records struct {
|
||||
// grants part as a placed module's are; it adds no principal, since each node's engine speaks as the node's account.
|
||||
// Nil where its manifest was not read.
|
||||
Engine *Declared
|
||||
// NodeLogins is each node's key of the vault's node-logins (novox/hq ADR 0583 §1): the login in use and the one
|
||||
// rotating, each composed as a user of the node's permissions. A node absent logs in as `node.<n>` alone.
|
||||
NodeLogins map[string]delivery.NodeLogin
|
||||
// Announced is each module's last announcement of the states it keeps (novox/hq ADR 0495, issue 1790), as the
|
||||
// controller keeps it; a module absent has announced nothing yet. Nil where none was read.
|
||||
Announced map[string]announce.States
|
||||
}
|
||||
|
||||
// Users is every user the composed file should contain, in the order it will be written.
|
||||
//
|
||||
// The controller is always first and always present: a mesh whose own controller is not in the file
|
||||
// is a mesh that cannot be told anything, and there is no state of the records in which that is
|
||||
// correct.
|
||||
func Users(r Records) ([]Principal, error) {
|
||||
out := []Principal{{Kind: KindController, Uses: asksSeatOf(r), AsksIssued: issuedOf(r), AsksTheVault: vaultVerbsOf(r),
|
||||
AsksTheConfig: servedOf(r, VerbsTheControllerAsksTheConfig), FilesFor: sortedCopy(r.Nodes)}}
|
||||
var out []Principal
|
||||
|
||||
served := servedReadsOf(r)
|
||||
logs := DeclaredLogs(r)
|
||||
grants := -1
|
||||
for _, node := range sortedCopy(r.Nodes) {
|
||||
witness := false
|
||||
var checks []string
|
||||
bus := false
|
||||
for _, d := range r.Assigned[node] {
|
||||
if d.Module == controllerModule {
|
||||
witness = true
|
||||
}
|
||||
if d.SnapshotsTheBus {
|
||||
bus = true
|
||||
}
|
||||
checks = append(checks, d.Checks...)
|
||||
}
|
||||
out = append(out, Principal{Kind: KindNode, Node: node, WitnessesController: witness, Checks: checks,
|
||||
MovesIntoTheVault: servesMoveIn(r), StatesItsKeyToTheVault: servesVerb(r, VaultNodeKey),
|
||||
AsksItsOwnSecrets: servesVerb(r, VaultOwn), AsksTheVaultToSign: servesVerb(r, VaultSign), Uses: asksSeatOf(r),
|
||||
FollowsGrantsUserList: bus, ServedReads: servedOn(served, node)})
|
||||
// **One user per login the node has** (novox/hq ADR 0583 §1): the one in use, and while it rotates the other too,
|
||||
// each with the node's permissions.
|
||||
for _, login := range nodeLoginsOf(r, node) {
|
||||
out = append(out, Principal{Kind: KindNode, Node: node, Login: login, Checks: checks,
|
||||
MovesIntoTheVault: servesMoveIn(r), StatesItsKeyToTheVault: servesVerb(r, VaultNodeKey),
|
||||
AsksItsOwnSecrets: servesVerb(r, VaultOwn), AsksTheVaultToSign: servesVerb(r, VaultSign), Uses: asksSeatOf(r),
|
||||
FollowsGrantsUserList: bus, ServedReads: servedOn(served, node), RotatesItsLogin: servesVerb(r, VaultRotateNode)})
|
||||
}
|
||||
// **And the machine's provisioner client, on an account of its own** (novox/hq ADR 0349 clause 11): one
|
||||
// per machine that runs it, delivered to the module running it.
|
||||
for _, d := range r.Assigned[node] {
|
||||
@@ -218,6 +220,10 @@ func Users(r Records) ([]Principal, error) {
|
||||
Emits: pr.Emits, Queues: pr.Queues, State: pr.State, Uses: d.Uses, Asks: pr.Asks,
|
||||
Invokes: pr.Invokes, Removes: pr.Removes,
|
||||
})
|
||||
// The ended controller's leftovers include each node's declaration files' bucket (ADR 0590 clause 6).
|
||||
if slices.Contains(pr.Removes, catalogue.RemovesControllerLeftovers) {
|
||||
out[len(out)-1].FilesFor = sortedCopy(r.Nodes)
|
||||
}
|
||||
}
|
||||
if runtimeHere && d.Module == RuntimeModule {
|
||||
continue
|
||||
@@ -268,16 +274,22 @@ func Users(r Records) ([]Principal, error) {
|
||||
out = append(out, Principal{Kind: KindNodeTools, Node: node, Module: RuntimeModule, Carries: carried})
|
||||
}
|
||||
}
|
||||
for _, node := range sortedCopy(r.Enrolling) {
|
||||
out = append(out, Principal{Kind: KindEnrolment, Node: node})
|
||||
// A node enrols through the seat mesh-enrolment alone (ADR 0587 §6, ADR 0590): while no holder serves redeem, an
|
||||
// enrolling node has nowhere to enrol, and gets no user.
|
||||
if servesVerb(r, EnrolmentRedeem) {
|
||||
for _, node := range sortedCopy(r.Enrolling) {
|
||||
out = append(out, Principal{Kind: KindEnrolment, Node: node, Redeems: true})
|
||||
}
|
||||
}
|
||||
for _, person := range sortedNames(r.People) {
|
||||
p := Principal{Kind: KindPerson, Module: person, Invokes: r.People[person]}
|
||||
if administers(p.Invokes) {
|
||||
// The operator's hand path writes each node's compositions (ADR 0539 clause 10).
|
||||
p.FilesFor = sortedCopy(r.Nodes)
|
||||
out = append(out, Principal{Kind: KindPerson, Module: person, Invokes: r.People[person]})
|
||||
}
|
||||
|
||||
// The vault's enrolment verbs are mesh-enrolment's alone, however wide another's grant (novox/hq ADR 0587 §1).
|
||||
if slices.ContainsFunc(VerbsTheEnrolmentAsksTheVault, func(v SeatVerb) bool { return servesVerb(r, v) }) {
|
||||
for i := range out {
|
||||
out[i].VaultEnrols = true
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
|
||||
// The grants part creates the durable consumers the accounts need (novox/hq ADR 0561 §6), by stream and name.
|
||||
@@ -339,9 +351,6 @@ func sortedNames(in map[string][]string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// controllerModule is the controller's module: the machine assigned it witnesses its upgrades.
|
||||
const controllerModule = "mesh-controller"
|
||||
|
||||
// asksSeatOf is the seat an ask is made on, as its holder declares it (novox/hq ADR 0259): the controller
|
||||
// asks the operator through it like any other user, and is granted what its declaration names for a caller.
|
||||
// None while nothing holds it.
|
||||
@@ -379,6 +388,27 @@ func asksSeatOf(r Records) []Seat {
|
||||
return nil
|
||||
}
|
||||
|
||||
// nodeLoginsOf are the logins composed for node, as Principal.Login names them ("" for `node.<n>`): the one its
|
||||
// node-logins key says is in use and the one rotating, in that order; `node.<n>` alone when the key is absent or does
|
||||
// not hold (a key naming another node's login is no login of this node's).
|
||||
func nodeLoginsOf(r Records, node string) []string {
|
||||
l, ok := r.NodeLogins[node]
|
||||
if !ok || l.Node != node || (l.Login != delivery.FirstLogin(node) && l.Login != delivery.SecondLogin(node)) {
|
||||
return []string{""}
|
||||
}
|
||||
login := func(name string) string {
|
||||
if name == delivery.FirstLogin(node) {
|
||||
return ""
|
||||
}
|
||||
return name
|
||||
}
|
||||
out := []string{login(l.Login)}
|
||||
if l.Rotates() && l.Rotating == delivery.OtherLogin(node, l.Login) {
|
||||
out = append(out, login(l.Rotating))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// VaultSeat is the vault's seat: its holder alone asks the desks and the issuing providers in its own name.
|
||||
const VaultSeat = "mesh-vault"
|
||||
|
||||
@@ -391,10 +421,6 @@ func holdsSeat(d Declared, seat string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// vaultVerbsOf are the vault's intake verbs its holder serves, as the records say (issue 675): what the controller
|
||||
// is granted to ask it in its own name. None while no holder serves them.
|
||||
func vaultVerbsOf(r Records) []string { return servedOf(r, VerbsTheControllerAsksTheVault) }
|
||||
|
||||
// servedOf are the verbs of asked that a seat's holder serves, as the records say, by name.
|
||||
func servedOf(r Records, asked []SeatVerb) []string {
|
||||
seen := map[string]bool{}
|
||||
|
||||
+54
-123
@@ -25,7 +25,7 @@ type WriterRow struct {
|
||||
KeptIn string
|
||||
Others string
|
||||
// Subjects are the bus subjects a write of this state is a publish to; none for state kept off the
|
||||
// bus (the controller's store, a module's own) or not built yet.
|
||||
// bus (a module's own store) or not built yet.
|
||||
Subjects []string
|
||||
// Writes says a principal granted a publish pattern overlapping Subjects is this state's writer —
|
||||
// given the pattern, because a machine writes its own report and no other's.
|
||||
@@ -34,16 +34,13 @@ type WriterRow struct {
|
||||
Shared string
|
||||
}
|
||||
|
||||
// isController, and the other writers' tests, are who a row's writer is as a principal.
|
||||
func isController(p Principal, _ string) bool { return p.Kind == KindController }
|
||||
|
||||
// createsDeclared is the controller, or the grants part creating one stream a manifest declares, by its own name: the
|
||||
// create alone, never an update or a delete (novox/hq ADR 0547 §5; the operator, 2026-10-26 08:54).
|
||||
// createsDeclared, and the other writers' tests, are who a row's writer is as a principal.
|
||||
//
|
||||
// createsDeclared is the grants part creating one stream a manifest declares, by its own name: the create alone, never an
|
||||
// update or a delete (novox/hq ADR 0547 §5; the operator, 2026-10-26 08:54); or the bus module's tools deleting one of
|
||||
// the ended controller's leftover streams (ADR 0590 clause 6).
|
||||
func createsDeclared(p Principal, pattern string) bool {
|
||||
if isController(p, pattern) {
|
||||
return true
|
||||
}
|
||||
if removesAssignmentsStream(p, pattern) {
|
||||
if removesLeftover(p, pattern) {
|
||||
return true
|
||||
}
|
||||
name, ok := strings.CutPrefix(pattern, "$JS.API.STREAM.CREATE.")
|
||||
@@ -96,17 +93,27 @@ func ownModule(p Principal, pattern string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// ownAnnouncer is a module, a process of it or the node tools carrying it saying what that module keeps, or the
|
||||
// controller saying what it keeps itself (novox/hq ADR 0495 clause 2): each under its own name alone.
|
||||
// moduleNamed is the writer that is module: its own principal, a process of it, or the node tools carrying it.
|
||||
func moduleNamed(module string) func(Principal, string) bool {
|
||||
return func(p Principal, _ string) bool {
|
||||
switch p.Kind {
|
||||
case KindModule, KindProcess:
|
||||
return p.Module == module
|
||||
case KindNodeTools:
|
||||
return slices.ContainsFunc(p.Carries, func(d Declared) bool { return d.Module == module })
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ownAnnouncer is a module, a process of it or the node tools carrying it saying what that module keeps (novox/hq ADR
|
||||
// 0495 clause 2): each under its own name alone.
|
||||
func ownAnnouncer(p Principal, pattern string) bool {
|
||||
tokens := strings.Split(pattern, ".")
|
||||
if len(tokens) < 3 {
|
||||
return false
|
||||
}
|
||||
switch p.Kind {
|
||||
case KindController:
|
||||
return tokens[2] == ControllerSeat
|
||||
case KindProcess:
|
||||
if p.Kind == KindProcess {
|
||||
return p.Module == tokens[2]
|
||||
}
|
||||
return ownModule(p, pattern)
|
||||
@@ -131,121 +138,61 @@ func kvOf(bucket string) []string { return []string{"$KV." + bucket + ".>"} }
|
||||
|
||||
// WritersTable is to-be 45 §1, in its order.
|
||||
var WritersTable = []WriterRow{
|
||||
{State: "a machine's declaration", Writer: "controller (lease holder)", KeptIn: "the bus, last per subject",
|
||||
Others: "read", Subjects: []string{"mesh.node.*.declare"}, Writes: isController},
|
||||
// The operator's hand-over of a directory used as found, asked of the machine's engine at the controller's
|
||||
// terminal (novox/hq issue 356). One publisher; and because a responder can still reach the subject through a
|
||||
// reply, the ask is signed with the mesh's key and the engine verifies it — the row bounds who is granted the
|
||||
// publish, the signature who is believed.
|
||||
{State: "a hand-over asked of a machine", Writer: "controller, at its terminal", KeptIn: "the machine, beside its state",
|
||||
Others: "the engine verifies the mesh's signature and records it, or refuses",
|
||||
Subjects: []string{"mesh.node.*.ask.hand-over"}, Writes: isController},
|
||||
// The operator asking a machine's engine for a fresh search for setuid programs, at the controller's
|
||||
// terminal (novox/hq issue 361): signed as a hand-over is, under a signing context of its own.
|
||||
{State: "a fresh setuid search asked of a machine", Writer: "controller, at its terminal",
|
||||
KeptIn: "the machine, which throws its last search away", Others: "the engine verifies the mesh's signature and starts it, or refuses",
|
||||
Subjects: []string{"mesh.node.*.ask.setuid-search"}, Writes: isController},
|
||||
// A read-only judgement of a machine's declared directories asked of its engine (novox/hq issue 358): signed as a
|
||||
// hand-over is, under a signing context of its own. It changes nothing on the machine.
|
||||
{State: "a judgement of a machine's directories asked of it", Writer: "controller",
|
||||
KeptIn: "nowhere: the engine answers it and keeps nothing", Others: "the engine verifies the mesh's signature and answers, or refuses",
|
||||
Subjects: []string{"mesh.node.*.ask.judge-directories"}, Writes: isController},
|
||||
{State: "a machine's applied state and its report", Writer: "the node-engine's apply queue",
|
||||
KeptIn: "the machine; the report on the bus", Others: "the reconcile and a delivery enqueue, never apply",
|
||||
// And its health statement between reports (novox/hq ADR 0240): the same writer stating the same
|
||||
// machine, inside the grant it already had (`mesh.control.<its own>.>`). And a line mesh-cli was given
|
||||
// on the machine (novox/hq ADR 0272): the node is what the controller judges the terminal by, so that
|
||||
// subject is this machine's engine's alone.
|
||||
// on the machine (novox/hq ADR 0272): that subject is this machine's engine's alone.
|
||||
Subjects: []string{"mesh.control.*.report", "mesh.control.*.health", "mesh.control.*.cli"}, Writes: ownMachine},
|
||||
{State: "the controller lease", Writer: "the controller instance holding it", KeptIn: "key-value " + LeaseBucket,
|
||||
Others: "a candidate waits", Subjects: kvOf(LeaseBucket), Writes: isController},
|
||||
// The merges mesh-delivery announced, for a moved module's registration (ADR 0420 §7), written by the lease holder.
|
||||
{State: "the merges mesh-delivery announced", Writer: "controller (lease holder)",
|
||||
KeptIn: "key-value " + DeliveryMergesBucket, Others: "read at a moved module's registration",
|
||||
Subjects: kvOf(DeliveryMergesBucket), Writes: isController},
|
||||
// And the compositions (novox/hq ADR 0539 clauses 2 and 4): each node's in its own bucket, which only that node's
|
||||
// node-engine reads, and each digest with the judgement of its move, which the assigner reads.
|
||||
{State: "the compositions and their digests", Writer: "controller (lease holder), compare-and-set",
|
||||
KeptIn: "key-value composed-<node> for each node, and " + delivery.ControllerComposedBucket,
|
||||
Others: "a node-engine reads its own node's; the assigner reads the digests",
|
||||
Subjects: kvOf(delivery.ControllerComposedBucket), Writes: isControllerOrTheHandPath},
|
||||
{State: "conditions", Writer: "controller", KeptIn: "key-value " + ConditionsBucket + " (and its history, " +
|
||||
ConditionHistoryBucket + "); how often each was observed, when last and its newest evidence in the " +
|
||||
"controller's store, so a look that changes nothing writes no bucket", Others: "raise or clear only through observations the controller reads",
|
||||
Subjects: append(kvOf(ConditionsBucket), kvOf(ConditionHistoryBucket)...), Writes: isController},
|
||||
{State: "calls and their outcomes", Writer: "controller", KeptIn: "key-value " + CallsBucket,
|
||||
Others: "read by id", Subjects: kvOf(CallsBucket), Writes: isController},
|
||||
{State: "the hand-act log", Writer: "controller, through the verbs that act", KeptIn: "key-value " + HandActsBucket,
|
||||
Others: "—", Subjects: kvOf(HandActsBucket), Writes: isController},
|
||||
// What the healers did (novox/hq to-be 45 §7, Phase 3): the controller's alone, in its store — the
|
||||
// budgets and the mesh-wide brake are counted from it, so a controller restarting cannot reset them.
|
||||
{State: "the healers' acts and their brake", Writer: "controller (lease holder), each act begun before it is made",
|
||||
KeptIn: "the controller's store", Others: "read through healers; each act said as healer-acted and in its condition's tried"},
|
||||
{State: "stream definitions and bus permissions", Writer: "controller", KeptIn: "the bus", Others: "—",
|
||||
{State: "stream definitions and bus permissions", Writer: "the grants part", KeptIn: "the bus", Others: "—",
|
||||
// A stream's definition, and a durable consumer's by the API that names it so. Not every
|
||||
// consumer create: a module watching its own bucket makes and deletes an ordered consumer on the
|
||||
// bucket's stream (ADR 0201), which defines nothing the mesh keeps.
|
||||
Subjects: []string{"$JS.API.STREAM.CREATE.>", "$JS.API.STREAM.UPDATE.>", "$JS.API.STREAM.DELETE.>",
|
||||
"$JS.API.CONSUMER.DURABLE.CREATE.>"},
|
||||
Writes: createsDeclared,
|
||||
Shared: "the grants part creates a stream a manifest declares, by its name, and never updates or deletes one (novox/hq ADR 0547 §5; the operator, 2026-10-26 08:54); the bus module's tools account deletes the stream ASSIGNMENTS at the lease write, and no other (research 072 row 33)"},
|
||||
Shared: "the grants part creates a stream a manifest declares, by its name, and never updates or deletes one (novox/hq ADR 0547 §5; the operator, 2026-10-26 08:54); the bus module's tools account deletes the ended controller's leftover streams, each by its name, and no other (novox/hq ADR 0590 clause 6)"},
|
||||
{State: "builds and their outcomes", Writer: "the build seat's holder", KeptIn: "its own state",
|
||||
Others: "the controller asks",
|
||||
Others: "the builder asks",
|
||||
Subjects: []string{"mesh.seat.node-build-agent.event.built", "mesh.seat.mesh-build-machine.event.built"},
|
||||
Writes: holdsSeatOf,
|
||||
Shared: "every machine holding the build seat answers the asks it took; each outcome names its ask"},
|
||||
// What a module keeps and who may read it (novox/hq ADR 0495 clauses 2 and 3, issue 1790): said by the module, kept by
|
||||
// the controller.
|
||||
// What a module keeps and who may read it (novox/hq ADR 0495 clauses 2 and 3, issue 1790): said by the module.
|
||||
{State: "a module's states announced", Writer: "the module itself, from its code, under its own name",
|
||||
KeptIn: "the bus (EVENTS)", Others: "the controller keeps the last of each and grants readers from it",
|
||||
KeptIn: "the bus (EVENTS)", Others: "the grants part grants readers from it",
|
||||
Subjects: []string{"mesh.mod.*.event.states"}, Writes: ownAnnouncer},
|
||||
{State: "the states announced", Writer: "controller, from each module's announcement",
|
||||
KeptIn: "key-value " + StatesAnnouncedBucket, Others: "the composition of the bus's users reads it",
|
||||
Subjects: kvOf(StatesAnnouncedBucket), Writes: isController},
|
||||
{State: "a merge announced", Writer: "one announcer per forge (the hook, or the poll when the hook is absent — never both)",
|
||||
KeptIn: "the bus", Others: "—", Subjects: []string{"mesh.mod.*.event.pull.merged"}, Writes: ownModule},
|
||||
// A pull request's head, before it merges (novox/hq to-be 45 §9): the same one announcer.
|
||||
{State: "a pull request's head announced", Writer: "the forge's announcer, the one that announces its merges",
|
||||
KeptIn: "the bus", Others: "the controller asks the build seat to check it", Subjects: []string{"mesh.mod.*.event.pull.updated"},
|
||||
KeptIn: "the bus", Others: "the checker checks it", Subjects: []string{"mesh.mod.*.event.pull.updated"},
|
||||
Writes: ownModule},
|
||||
{State: "a pull request's merge check", Writer: "the build seat's holder that ran it, said as the controller's `checked`",
|
||||
{State: "a pull request's merge check", Writer: "the checker that ran it",
|
||||
KeptIn: "the bus", Others: "the forge's holder sets it as the pull request's status"},
|
||||
{State: "a provider's standing", Writer: "the provider", KeptIn: "the provider's events",
|
||||
Others: "the controller keeps the newest word as a condition",
|
||||
Others: "the watchman keeps the newest word as a condition",
|
||||
Subjects: []string{"mesh.mod.*.event.provisioner.failing", "mesh.mod.*.event.provisioner.recovered",
|
||||
"mesh.mod.*.event.provisioner.retirement"},
|
||||
Writes: ownModule},
|
||||
{State: "the operator-channel's open messages", Writer: "the seat's holder", KeptIn: "its own key-value state",
|
||||
Others: "—"},
|
||||
{State: "the facts snapshot", Writer: "controller", KeptIn: "the artifact store, facts/latest",
|
||||
Others: "the build seat reads"},
|
||||
// What a consumer gave up on (novox/hq issue 330): copied by the controller from the server's notice,
|
||||
// and delivered again or dropped only through its verb, with why.
|
||||
{State: "a message a consumer gave up on", Writer: "controller", KeptIn: "the bus, the stream " + DeadLettersStream,
|
||||
Others: "read, delivered again or dropped through the controller's dead-letters verb",
|
||||
Subjects: []string{deadLetterPrefix + ">", againPrefix + ">"}, Writes: keepsDeadLetters,
|
||||
Shared: "the watchman keeps the copies from the controller's lease write, the controller before it; delivering again stays the controller's (novox/hq ADR 0564 clause 5)"},
|
||||
// The record mirrors (novox/hq ADR 0289 §9, design 54 §2): rows of this table, so a grant that lets
|
||||
// anything else write them is refused at composition. Built so far: the assignments and each registered
|
||||
// module's declared config (design 57 F3), and the list of predefined keys kept (design 57 K3).
|
||||
{State: "the assignments mirror", Writer: "controller (lease holder): the verbs that assign, and the sends that record what runs; " +
|
||||
"a mirror of its store only until the assignments are moved to the bus (novox/hq ADR 0408)",
|
||||
KeptIn: "key-value " + AssignmentsBucket + ", the record once moved", Others: "read through `reads` as mesh-controller." + MirrorAssignments,
|
||||
Subjects: kvOf(AssignmentsBucket), Writes: isController},
|
||||
// The chain (novox/hq ADR 0408): builds and rollouts beside the assignments above, which keep their bucket. Not
|
||||
// mirrors of the store but the record, written by the lease holder by compare-and-set and removed by nobody.
|
||||
{State: "the builds of the chain", Writer: "controller (lease holder), compare-and-set on the key's revision",
|
||||
KeptIn: "key-value " + BuildsBucket + ", sixty-four revisions of each key, none removed",
|
||||
Others: "read through `reads` as mesh-controller." + ChainBuilds, Subjects: kvOf(BuildsBucket), Writes: isController},
|
||||
{State: "the rollouts of the chain", Writer: "controller (lease holder), compare-and-set on the key's revision",
|
||||
KeptIn: "key-value " + RolloutsBucket + ", sixty-four revisions of each key, none removed",
|
||||
Others: "read through `reads` as mesh-controller." + ChainRollouts, Subjects: kvOf(RolloutsBucket), Writes: isController},
|
||||
// The grants issued (novox/hq ADR 0528 clause 2, issue 1394): the controller's, read by the assigner for its sixth rule.
|
||||
{State: "the grants issued", Writer: "controller (lease holder), once the bus's user list carrying a module's manifest at a fingerprint was sent",
|
||||
KeptIn: "key-value " + GrantsBucket + ", one key a module's fingerprint, none removed",
|
||||
Others: "read through `reads` as mesh-controller." + ControllerGrants, Subjects: kvOf(GrantsBucket), Writes: isController},
|
||||
// The seat mesh-enrolment's open enrolments (novox/hq ADR 0587 §2, issue 774): its holder alone writes them, each a
|
||||
// compare-and-set at the revision read; the grants part reads them as a named reader.
|
||||
{State: "the open enrolments", Writer: "the seat mesh-enrolment's holder, compare-and-set",
|
||||
KeptIn: "key-value " + delivery.EnrolmentsBucket + ", with per-key expiry",
|
||||
Others: "the grants part reads them (a named reader) and composes each enrol.<node> user from them",
|
||||
Subjects: kvOf(delivery.EnrolmentsBucket), Writes: moduleNamed(delivery.EnrolmentModule)},
|
||||
// Which login each node uses (novox/hq ADR 0583 §1 insight): the vault alone writes it.
|
||||
{State: "a node's logins", Writer: "the vault", KeptIn: "key-value " + delivery.NodeLoginsBucket,
|
||||
Others: "the grants part reads every key, each node's engine its own node's key alone",
|
||||
Subjects: kvOf(delivery.NodeLoginsBucket), Writes: moduleNamed(delivery.VaultModule)},
|
||||
// What a consumer gave up on (novox/hq issue 330): copied by the watchman from the server's notice (ADR 0564
|
||||
// clause 5, ADR 0590 clause 2).
|
||||
{State: "a message a consumer gave up on", Writer: "the watchman", KeptIn: "the bus, the stream " + DeadLettersStream,
|
||||
Others: "read through the watchman's dead-letters; delivered again by nobody until the hand-act log's home is delivered",
|
||||
Subjects: []string{deadLetterPrefix + ">", againPrefix + ">"}, Writes: keepsDeadLetters},
|
||||
// What each machine has of each module (novox/hq ADR 0429 §1, design 47): written by that machine's node-engine under
|
||||
// its own node's keys, never by the controller; the view and the watchman read it.
|
||||
// its own node's keys; the view and the watchman read it.
|
||||
{State: "a machine's have", Writer: "the node-engine of the machine, under its own keys `<node>.>`",
|
||||
KeptIn: "key-value " + delivery.HaveBucket + ", twenty revisions of each key",
|
||||
Others: "read through `reads` as node-engine.have", Subjects: kvOf(delivery.HaveBucket), Writes: ownNodeKeys},
|
||||
@@ -270,33 +217,23 @@ var WritersTable = []WriterRow{
|
||||
return ownNodeKeys(p, pattern) || removesEngineActs(p, pattern)
|
||||
},
|
||||
Shared: "the bus module's tools account removes keys of a full bucket, for removal only (novox/hq ADR 0564 Limits 4)"},
|
||||
// The contents of a declaration's files over 64 KiB (novox/hq ADR 0486 §2): the controller alone writes the bucket, each
|
||||
// object under the digest of its content; a node-engine reads one by its name, with no list and no write.
|
||||
{State: "a declaration's large files", Writer: "controller, each object under the sha256 digest of its content; the " +
|
||||
"operator's account on release 6's hand path alone (ADR 0539 clause 10)",
|
||||
// The object buckets (novox/hq ADR 0486 §2, ADR 0547 clause 7): the declaration files' buckets the ended controller
|
||||
// wrote are written by nobody (ADR 0590); the grants part writes its own.
|
||||
{State: "an object bucket", Writer: "the grants part, its own bucket alone",
|
||||
KeptIn: "the object bucket " + DeclarationFilesBucket + "-<node> of each node, an object removed a day after the " +
|
||||
"node's latest declaration no longer names it",
|
||||
Others: "a node-engine reads an object of its own bucket by its name and may neither list the bucket nor write it",
|
||||
Subjects: []string{"$O.*.>"}, Writes: func(p Principal, pattern string) bool {
|
||||
return isControllerOrTheHandPath(p, pattern) || grantsOwnObjects(p, pattern)
|
||||
return grantsOwnObjects(p, pattern)
|
||||
},
|
||||
Shared: "the grants part writes its own object bucket " + GrantsUserListObjects + ", the user list sealed to the bus node, and no other (novox/hq ADR 0547 clause 7)"},
|
||||
{State: "the declared-config mirror", Writer: "controller (lease holder), from its store, only on a change",
|
||||
KeptIn: "key-value " + DeclaredConfigBucket, Others: "read through `reads` as mesh-controller." + MirrorDeclaredConfig,
|
||||
Subjects: kvOf(DeclaredConfigBucket), Writes: isController},
|
||||
{State: "the predefined-keys mirror", Writer: "controller (lease holder), from its store, only on a change",
|
||||
KeptIn: "key-value " + PredefinedBucket, Others: "read through `reads` as mesh-controller." + MirrorPredefined,
|
||||
Subjects: kvOf(PredefinedBucket), Writes: isController},
|
||||
{State: "the declared-packages mirror", Writer: "controller (lease holder), from the composition of each node, only on a change",
|
||||
KeptIn: "key-value " + DeclaredPackagesBucket, Others: "read through `reads` as mesh-controller." + MirrorDeclaredPackages,
|
||||
Subjects: kvOf(DeclaredPackagesBucket), Writes: isController},
|
||||
Shared: "nobody else: the grants part writes its own object bucket " + GrantsUserListObjects + ", the user list sealed to the bus node, and no other (novox/hq ADR 0547 clause 7)"},
|
||||
// What a machine's provisioner client placed of a module's files (novox/hq ADR 0349 §4, design 57 P6): its own
|
||||
// machine's word alone, which a walk's end and a gate wait for (ADR 0362, ADR 0349 §15). Kept newest per module
|
||||
// in the stream ProvAppliedStream, and by the client in the provisioner server's `applied` bucket.
|
||||
{State: "a module's files placed on a machine", Writer: "that machine's provisioner client",
|
||||
KeptIn: "the bus, the stream " + ProvAppliedStream + ", newest per module; and key-value " +
|
||||
ProvisionerAppliedBucket,
|
||||
Others: "the controller reads it at a walk's end and a gate; a failure is that machine's failed apply",
|
||||
Others: "the watchman reads it; a failure is that machine's failed apply",
|
||||
Subjects: []string{"prov.applied.*.>"}, Writes: ownProvisioner},
|
||||
}
|
||||
|
||||
@@ -343,9 +280,3 @@ func SubjectsOverlap(a, b string) bool {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isControllerOrTheHandPath is the controller, or the operator's account (a person who may invoke every tool) writing
|
||||
// release 6's hand path: the one second writer ADR 0539 clauses 2 and 10 name, for a composition made by hand.
|
||||
func isControllerOrTheHandPath(p Principal, pattern string) bool {
|
||||
return isController(p, pattern) || (p.Kind == KindPerson && administers(p.Invokes))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
// Package calladdress reads the key a module calls a tool or a seat verb by, as a module's invokes names it, with the
|
||||
// machine it is asked of after '@': `seat:<seat>.<verb>[@<node>]` or `<module>.<tool>[@<node>]` (novox/hq ADR 0159,
|
||||
// ADR 0469 §3). Every caller in the SDK parses its key here before it publishes anything, so a malformed key fails at
|
||||
// once and says why, never as a call nobody serves and a timeout (novox/hq issue 774, the lease write's stop asked on
|
||||
// `...call.stop@novox-be...`).
|
||||
package calladdress
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Address is a parsed key: a seat and its verb, or a module and its tool, and the machine named, if any.
|
||||
type Address struct {
|
||||
Seat, Verb string
|
||||
Module, Tool string
|
||||
Node string
|
||||
}
|
||||
|
||||
// Key is the address as a module's invokes names it, without the machine.
|
||||
func (a Address) Key() string {
|
||||
if a.Seat != "" {
|
||||
return "seat:" + a.Seat + "." + a.Verb
|
||||
}
|
||||
return a.Module + "." + a.Tool
|
||||
}
|
||||
|
||||
// Parse reads a key, refusing one with more than one '@', a node that is not one subject token, or a seat, verb, module
|
||||
// or tool that holds a wildcard, white space or (but a tool) a dot.
|
||||
func Parse(key string) (Address, error) {
|
||||
name, node, err := cutNode(key)
|
||||
if err != nil {
|
||||
return Address{}, err
|
||||
}
|
||||
if rest, ok := strings.CutPrefix(name, "seat:"); ok {
|
||||
seat, verb, ok := strings.Cut(rest, ".")
|
||||
if !ok || seat == "" || verb == "" {
|
||||
return Address{}, fmt.Errorf("%q names no seat and verb (seat:<seat>.<verb>)", key)
|
||||
}
|
||||
if err := tokens(key, seat, verb); err != nil {
|
||||
return Address{}, err
|
||||
}
|
||||
return Address{Seat: seat, Verb: verb, Node: node}, nil
|
||||
}
|
||||
module, tool, ok := strings.Cut(name, ".")
|
||||
if !ok || module == "" || tool == "" {
|
||||
return Address{}, fmt.Errorf("%q is not <module>.<tool>, and does not start seat:<seat>.<verb>", key)
|
||||
}
|
||||
if err := tokens(key, module); err != nil {
|
||||
return Address{}, err
|
||||
}
|
||||
if err := checkTool(key, tool); err != nil {
|
||||
return Address{}, err
|
||||
}
|
||||
return Address{Module: module, Tool: tool, Node: node}, nil
|
||||
}
|
||||
|
||||
// CheckOwn is Parse for a caller that may also name a tool of its own module by its bare name (the runtime's mesh/ask).
|
||||
func CheckOwn(key string) error {
|
||||
name, _, err := cutNode(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.HasPrefix(name, "seat:") && !strings.Contains(name, ".") {
|
||||
return tokens(key, name)
|
||||
}
|
||||
_, err = Parse(key)
|
||||
return err
|
||||
}
|
||||
|
||||
// CheckNode refuses a node name that is not one subject token.
|
||||
func CheckNode(node string) error {
|
||||
if node == "" || strings.ContainsAny(node, ".*>@ \t\r\n") {
|
||||
return fmt.Errorf("%q is not a node name (one subject token: no '.', '*', '>', '@' or space)", node)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cutNode(key string) (name, node string, err error) {
|
||||
name, node, at := strings.Cut(key, "@")
|
||||
if !at {
|
||||
return key, "", nil
|
||||
}
|
||||
if strings.Contains(node, "@") {
|
||||
return "", "", fmt.Errorf("%q holds more than one '@': a key names at most one machine", key)
|
||||
}
|
||||
if err := CheckNode(node); err != nil {
|
||||
return "", "", fmt.Errorf("%q: %w", key, err)
|
||||
}
|
||||
return name, node, nil
|
||||
}
|
||||
|
||||
func tokens(key string, parts ...string) error {
|
||||
for _, p := range parts {
|
||||
if p == "" || strings.ContainsAny(p, ".*> \t\r\n") {
|
||||
return fmt.Errorf("%q: %q is not one subject token (no '.', '*', '>' or space)", key, p)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkTool refuses a tool name with a wildcard or white space; a dot is kept, as the tool runner serves it.
|
||||
func checkTool(key, tool string) error {
|
||||
if strings.ContainsAny(tool, "*> \t\r\n") || strings.HasPrefix(tool, ".") || strings.HasSuffix(tool, ".") {
|
||||
return fmt.Errorf("%q: %q is not one subject token (no '*', '>' or space)", key, tool)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package calladdress
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Issue 774 notes 84-87 (the lease write's stop asked of `seat:node-service-manager.stop@novox-be` published on a subject
|
||||
// nobody serves and timed out after 30 s): a key names its machine after '@', and every other malformed key is refused
|
||||
// before anything is published, naming why.
|
||||
func TestAKeyIsParsedOrRefusedBeforeItIsPublished(t *testing.T) {
|
||||
for _, row := range []struct {
|
||||
key, want string // want: "seat|<seat>|<verb>|<node>", "mod|<module>|<tool>|<node>", or a refusal's words
|
||||
}{
|
||||
{"seat:node-service-manager.stop@novox", "seat|node-service-manager|stop|novox"},
|
||||
{"seat:operator-channel.tell", "seat|operator-channel|tell|"},
|
||||
{"mesh-build-go.fetch@ace", "mod|mesh-build-go|fetch|ace"},
|
||||
{"nats.nats_users", "mod|nats|nats_users|"},
|
||||
{"seat:node-service-manager.stop@novox@g14", "more than one '@'"},
|
||||
{"seat:node-service-manager.stop@", "not a node name"},
|
||||
{"seat:node-service-manager.stop@nov.ox", "not a node name"},
|
||||
{"seat:node-files.*", "not one subject token"},
|
||||
{"mesh-build-go.fe tch", "not one subject token"},
|
||||
{"mesh>build.fetch", "not one subject token"},
|
||||
{"seat:noverb", "names no seat and verb"},
|
||||
{"nodot", "is not <module>.<tool>"},
|
||||
{"", "is not <module>.<tool>"},
|
||||
} {
|
||||
a, err := Parse(row.key)
|
||||
got := ""
|
||||
switch {
|
||||
case err != nil:
|
||||
got = err.Error()
|
||||
if !strings.Contains(got, row.want) {
|
||||
t.Errorf("Parse(%q) refused %q, want %q", row.key, got, row.want)
|
||||
}
|
||||
continue
|
||||
case a.Seat != "":
|
||||
got = "seat|" + a.Seat + "|" + a.Verb + "|" + a.Node
|
||||
default:
|
||||
got = "mod|" + a.Module + "|" + a.Tool + "|" + a.Node
|
||||
}
|
||||
if got != row.want {
|
||||
t.Errorf("Parse(%q) = %s, want %s", row.key, got, row.want)
|
||||
}
|
||||
}
|
||||
if a, err := Parse("seat:node-files.read@g14"); err != nil || a.Key() != "seat:node-files.read" {
|
||||
t.Errorf("the key without its machine: %q, %v", a.Key(), err)
|
||||
}
|
||||
if err := CheckOwn("list@ace"); err != nil {
|
||||
t.Errorf("a module's own tool by its bare name was refused: %v", err)
|
||||
}
|
||||
if err := CheckOwn("li*st"); err == nil {
|
||||
t.Error("a bare name that is not one token was accepted")
|
||||
}
|
||||
}
|
||||
@@ -24,13 +24,12 @@ func wantsVerbs() []Verb {
|
||||
{Name: "upgrade", Description: "Whether each module's new builds wait for a person: its policy (roll, together or record) " +
|
||||
"and where it comes from (a person, the module, the bus, its irreplaceable data, the default); the bus is never " +
|
||||
"rolled. With module, that one; with module, policy and why, a person's choice for it, recorded as a hand act; " +
|
||||
"with policy default, the choice taken back. With compare, every module whose policy differs from the one the " +
|
||||
"controller writes beside its compositions.",
|
||||
"with policy default, the choice taken back. With compare, each node's own composition and the assigner's findings.",
|
||||
Input: schema(map[string]string{
|
||||
"module": "one module",
|
||||
"policy": "with module: roll, together, record, or default to take a person's choice back",
|
||||
"why": "with policy: why, required and kept",
|
||||
"compare": "\"true\": every module whose policy at the assigner differs from the controller's",
|
||||
"compare": "\"true\": each node's own composition, by its digest, and the assigner's findings",
|
||||
}, nil, "compare")},
|
||||
{Name: "bus", Description: "What a bus upgrade would do: the fingerprint of the bus module each node runs against the " +
|
||||
"indexer's newest, and the last bus steps from the hand-act log and the assigner's puts of the bus. Read-only: " +
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// **Nothing may name the controller** (novox/hq ADR 0590 clause 5, issue 1126): the controller ended by its unassignment
|
||||
// on 2026-11-02 and its code left the mesh, so a manifest that reads one of its states (its lease too), invokes one of its
|
||||
// verbs or tools, consumes or waits on one of its events, uses, claims or requires it, or is it, depends on nothing that
|
||||
// answers. ManifestRefusals runs the check over a repository's manifests, in the test each repository of manifests keeps
|
||||
// (mesh-catalog, mesh-delivery, mesh-tools); a later decision that places a controller again must first lift it.
|
||||
|
||||
// EndedControllerName is the name the ended controller had, as its module and as its seat: no manifest may name it.
|
||||
const EndedControllerName = "mesh-controller"
|
||||
|
||||
// The kinds of a declaration that names the controller.
|
||||
const (
|
||||
DependsRead = "read"
|
||||
DependsInvoke = "invoke"
|
||||
DependsConsume = "consume"
|
||||
DependsUses = "uses"
|
||||
DependsWaits = "waits"
|
||||
DependsClaims = "claims"
|
||||
DependsRequires = "requires"
|
||||
DependsIs = "is"
|
||||
)
|
||||
|
||||
// Dependency is one declaration of a manifest that names the controller.
|
||||
type Dependency struct {
|
||||
Module string `json:"module"`
|
||||
// By is the account declaring it when it is not the module's own: `process <id>` or `tools <binary>`.
|
||||
By string `json:"by,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
What string `json:"what"`
|
||||
}
|
||||
|
||||
// onController says whether a name `<owner>.<rest>` is the controller's.
|
||||
func onController(name string) bool { return strings.HasPrefix(name, EndedControllerName+".") }
|
||||
|
||||
// invokesController says whether an invoke names a verb of the controller's seat or a tool of its module. `*` names none:
|
||||
// it is the console's grant to every tool.
|
||||
func invokesController(invoke string) bool {
|
||||
if seat, _, ok := SeatInvoke(invoke); ok {
|
||||
return seat == EndedControllerName
|
||||
}
|
||||
return onController(invoke)
|
||||
}
|
||||
|
||||
// Dependencies are every declaration naming the controller in the manifests given, by module.
|
||||
func Dependencies(manifests map[string]Manifest) []Dependency {
|
||||
var out []Dependency
|
||||
add := func(module, by, kind, what string) {
|
||||
if d := (Dependency{Module: module, By: by, Kind: kind, What: what}); !slices.Contains(out, d) {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
for name, m := range manifests {
|
||||
if name == EndedControllerName || m.Module == EndedControllerName {
|
||||
add(name, "", DependsIs, EndedControllerName)
|
||||
}
|
||||
for _, r := range m.Reads {
|
||||
if onController(r) {
|
||||
add(name, "", DependsRead, r)
|
||||
}
|
||||
}
|
||||
for _, i := range m.Invokes {
|
||||
if invokesController(i) {
|
||||
add(name, "", DependsInvoke, i)
|
||||
}
|
||||
}
|
||||
for _, c := range m.Consumes {
|
||||
if onController(c) {
|
||||
add(name, "", DependsConsume, c)
|
||||
}
|
||||
}
|
||||
for _, w := range m.Waits {
|
||||
if onController(w) {
|
||||
add(name, "", DependsWaits, w)
|
||||
}
|
||||
}
|
||||
for _, u := range m.Uses {
|
||||
if u == EndedControllerName {
|
||||
add(name, "", DependsUses, u)
|
||||
}
|
||||
}
|
||||
for _, c := range m.Claims {
|
||||
if c.Name == EndedControllerName {
|
||||
add(name, "", DependsClaims, c.Name)
|
||||
}
|
||||
}
|
||||
for _, r := range m.Requires {
|
||||
if r == EndedControllerName || onController(r) {
|
||||
add(name, "", DependsRequires, r)
|
||||
}
|
||||
}
|
||||
for _, a := range m.BusAccounts() {
|
||||
by := "process " + a.ID
|
||||
if a.Tools {
|
||||
by = "tools " + a.ID
|
||||
}
|
||||
for _, i := range a.Bus.Invokes {
|
||||
if invokesController(i) {
|
||||
add(name, by, DependsInvoke, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
a, b := out[i], out[j]
|
||||
if a.Module != b.Module {
|
||||
return a.Module < b.Module
|
||||
}
|
||||
if a.Kind != b.Kind {
|
||||
return a.Kind < b.Kind
|
||||
}
|
||||
if a.What != b.What {
|
||||
return a.What < b.What
|
||||
}
|
||||
return a.By < b.By
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// ManifestRefusals is the check over a repository's manifests, by module name: one reason per declaration naming the
|
||||
// controller, none when it passes. None given fails it, since nothing read can hide a declaration.
|
||||
func ManifestRefusals(manifests map[string]Manifest) []string {
|
||||
if len(manifests) == 0 {
|
||||
return []string{"no manifest was read: with nothing read, nothing naming the controller can be judged absent"}
|
||||
}
|
||||
var out []string
|
||||
for _, d := range Dependencies(manifests) {
|
||||
who := d.Module
|
||||
if d.By != "" {
|
||||
who += " (" + d.By + ")"
|
||||
}
|
||||
out = append(out, fmt.Sprintf("%s declares %s %s: the controller ended by its unassignment and nothing may name it "+
|
||||
"(ADR 0590 clause 5)", who, d.Kind, d.What))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Issue 1126 B1/AC1, B2/AC1 (ADR 0590 clause 5): the seat set holds no mesh-controller, and the dependence check refuses
|
||||
// every manifest that reads (the lease too), invokes, consumes, waits on, uses, claims, requires or is mesh-controller.
|
||||
func TestNothingMayNameTheController(t *testing.T) {
|
||||
for _, s := range DefaultSeats() {
|
||||
if s.Name == EndedControllerName {
|
||||
t.Errorf("the seat set still holds %s", s.Name)
|
||||
}
|
||||
}
|
||||
if _, ok := SeatNamed(EndedControllerName); ok {
|
||||
t.Errorf("SeatNamed still finds %s", EndedControllerName)
|
||||
}
|
||||
for _, row := range []struct {
|
||||
name, manifest string
|
||||
want []string // "<kind> <what>"
|
||||
}{
|
||||
{"a read of the lease", `{"module":"mod-l","version":"1","reads":["mesh-controller.lease"]}`, []string{"read mesh-controller.lease"}},
|
||||
{"the controller's own manifest", `{"module":"mesh-controller","version":"1"}`, []string{"is mesh-controller"}},
|
||||
{"a claim of its seat", `{"module":"a-controller","version":"1","claims":[{"name":"mesh-controller","scope":"mesh"}]}`,
|
||||
[]string{"claims mesh-controller"}},
|
||||
{"each declaration kind; every tool (*) is none",
|
||||
`{"module":"mod-i","version":"1","invokes":["seat:mesh-controller.root-free","mesh-controller.status","*","nats.nats_users"],` +
|
||||
`"consumes":["mesh-controller.artifacts-gone","mod-x.thing"],"uses":["mesh-controller"],"waits":["mesh-controller.checked"],` +
|
||||
`"requires":["mesh-controller"]}`,
|
||||
[]string{"consume mesh-controller.artifacts-gone", "invoke mesh-controller.status", "invoke seat:mesh-controller.root-free",
|
||||
"requires mesh-controller", "uses mesh-controller", "waits mesh-controller.checked"}},
|
||||
{"a process's own invoke", `{"module":"mod-p","version":"1","resources":[{"id":"account","type":"user","name":"mod-p"},` +
|
||||
`{"id":"worker","type":"process","run":["./w"],"user":"mod-p","bus":{"invokes":["seat:mesh-controller.held"]}}]}`,
|
||||
[]string{"invoke seat:mesh-controller.held"}},
|
||||
{"a module on its own seats", `{"module":"mod-ok","version":"1","reads":["mesh-grants.accounts"],"invokes":["*"]}`, nil},
|
||||
} {
|
||||
m, err := Decode([]byte(row.manifest))
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", row.name, err)
|
||||
}
|
||||
var got []string
|
||||
for _, d := range Dependencies(map[string]Manifest{m.Module: m}) {
|
||||
got = append(got, d.Kind+" "+d.What)
|
||||
}
|
||||
if !slices.Equal(got, row.want) {
|
||||
t.Errorf("%s: dependencies %q, want %q", row.name, got, row.want)
|
||||
}
|
||||
refused := ManifestRefusals(map[string]Manifest{m.Module: m})
|
||||
if len(refused) != len(row.want) || len(refused) > 0 && !strings.Contains(refused[0], "ADR 0590") {
|
||||
t.Errorf("%s: refused %q", row.name, refused)
|
||||
}
|
||||
}
|
||||
if got := ManifestRefusals(map[string]Manifest{}); len(got) != 1 || !strings.Contains(got[0], "no manifest") {
|
||||
t.Errorf("no manifest given: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -846,6 +846,11 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
// and nothing would say so.
|
||||
continue
|
||||
}
|
||||
if g.Sealed == "" {
|
||||
// Its offer presents no credential (form none, issue 1126): nothing to write, and an empty credential
|
||||
// file would be read as one that failed. Who asked still reaches the provider in its contributions.
|
||||
continue
|
||||
}
|
||||
first = append(first, ownedBy(r.provisionsAs(m), map[string]any{
|
||||
// One file per holder — the consumer's module with its local name after it
|
||||
// where it keeps several (ADR 0094); the lab found two files with one id.
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package catalogue
|
||||
|
||||
// **Joining a machine is the seat mesh-enrolment, held once** (novox/hq ADR 0587 §1, issue 774): held for the mesh by
|
||||
// the module mesh-enrolment, the only caller the vault's manifest names for its enrolment-token and node-account verbs
|
||||
// and a caller it names for `sign`. Six verbs, five events, none carrying a secret or anything derived from the token:
|
||||
// the one hash an event carries is the digest of the public keys a node presented. It mints and keeps no secret and
|
||||
// holds no signing key. Its state and its key layout are go/delivery's (enrolment.go).
|
||||
|
||||
// EnrolmentSeat is the seat mesh-enrolment.
|
||||
const EnrolmentSeat = "mesh-enrolment"
|
||||
|
||||
// The seat's six verbs (ADR 0587 §1, §4-§8).
|
||||
const (
|
||||
EnrolmentVerbIssue = "issue"
|
||||
EnrolmentVerbRedeem = "redeem"
|
||||
EnrolmentVerbConfirm = "confirm"
|
||||
EnrolmentVerbRevoke = "revoke"
|
||||
EnrolmentVerbTokens = "tokens"
|
||||
EnrolmentVerbAgain = "again"
|
||||
)
|
||||
|
||||
// The seat's five events (ADR 0587 §1, §2, §5): said by its holder on the seat, each carrying an enrolment key's whole
|
||||
// content except enrolment-expired, which names the node and the enrolment-issued event that carried it.
|
||||
const (
|
||||
EnrolmentIssuedEvent = "enrolment-issued"
|
||||
NodeEnrolledEvent = "node-enrolled"
|
||||
EnrolmentRefusedEvent = "enrolment-refused"
|
||||
EnrolmentRevokedEvent = "enrolment-revoked"
|
||||
EnrolmentExpiredEvent = "enrolment-expired"
|
||||
)
|
||||
|
||||
// EnrolmentEvents are the seat's events, in the order an enrolment says them; the last four each end one.
|
||||
var EnrolmentEvents = []string{EnrolmentIssuedEvent, NodeEnrolledEvent, EnrolmentRefusedEvent, EnrolmentRevokedEvent,
|
||||
EnrolmentExpiredEvent}
|
||||
|
||||
// enrolmentDecision is the record that made the seat.
|
||||
const enrolmentDecision = "novox/hq ADR 0587"
|
||||
|
||||
// enrolmentVerbs are the seat's verbs: what reads all it holds and does by hand all it does (ADR 0587 §8). Not optional:
|
||||
// the seat is new with its holder, so no holder of before serves fewer.
|
||||
func enrolmentVerbs() []Verb {
|
||||
str := func(description string) map[string]any {
|
||||
return map[string]any{"type": "string", "description": description}
|
||||
}
|
||||
return []Verb{
|
||||
{Name: EnrolmentVerbIssue, Description: "Issue a one-time token for a new machine, on the operator's Approve: " +
|
||||
"refused at once, naming every finding, for a name that is not a node name or that a node record, a placement " +
|
||||
"key or an open enrolment holds, and for every call before the controller's lease is handed away. Otherwise " +
|
||||
"reserves the lowest free private-network address, asks the operator under the seat's own name and, on the " +
|
||||
"warrant, adds the node to placement with its tunnel key, asks the vault for the token and says enrolment-issued. " +
|
||||
"The token itself is read only through tokens.",
|
||||
Input: schema(map[string]string{
|
||||
"node": "the new machine's name in the mesh",
|
||||
"why": "why it joins, shown to the operator with the ask",
|
||||
"expires": "how long the token lasts, a duration such as 1h or 24h: one hour when not given, at most 24 hours",
|
||||
// The operator's decision of 2026-11-02 01:05: the tunnel key is given at issue, so on the Approve the node
|
||||
// enters placement with it and the hub peers it before redeem.
|
||||
"overlay_key": "the new machine's private network (tunnel) public key, as `nox-mesh-host key` prints it",
|
||||
}, []string{"node", "why", "overlay_key"})},
|
||||
{Name: EnrolmentVerbRedeem, Description: "Spend a token: asked by a joining node-engine dialled as enrol.<node> " +
|
||||
"alone, with the public halves of its keys and its identity key's proof over them. Spends the token, asks the " +
|
||||
"vault for the node's account and certificate, writes the node into placement, and answers the account sealed " +
|
||||
"to the node, its certificate and the vault's public half, signed by the vault. Asked again with the same keys " +
|
||||
"it finishes what is not done and answers the same; with other keys it is refused.",
|
||||
Input: map[string]any{"type": "object", "additionalProperties": false,
|
||||
"required": []string{"node", "public_key", "sealing_key", "proof"},
|
||||
"properties": map[string]any{
|
||||
"node": str("the node the token was issued for"),
|
||||
"public_key": str("the identity key's public half, base64"),
|
||||
"overlay_key": str("the private network key's public half"),
|
||||
"sealing_key": str("the sealing key's public half, which the account is sealed to"),
|
||||
"serving_key": str("the serving key's public half, which the certificate binds"),
|
||||
"profile": map[string]any{"type": "object", "description": "what the machine is, as its record says it"},
|
||||
"tunnel": map[string]any{"type": "object", "description": "a tunnel found on the machine: everything but its private key"},
|
||||
"proof": str("the identity key's signature over the redeem's proof bytes (delivery.RedeemProof), base64"),
|
||||
}}},
|
||||
{Name: EnrolmentVerbConfirm, Description: "Confirm an enrolment: asked by the node-engine dialled as its own " +
|
||||
"node login alone, the runtime's proof of that caller being the confirmation. Says node-enrolled and ends the " +
|
||||
"enrolment.",
|
||||
Input: schema(map[string]string{}, nil)},
|
||||
{Name: EnrolmentVerbRevoke, Description: "End an enrolment at any stage, with why, without an Approve: withdraws " +
|
||||
"an open ask, deletes the node's key and its address, has the vault retire an account it already minted for " +
|
||||
"it, records the act as a hand act and says enrolment-revoked.",
|
||||
Input: schema(map[string]string{"node": "the node", "why": "why, recorded with the hand act"},
|
||||
[]string{"node", "why"})},
|
||||
{Name: EnrolmentVerbTokens, Description: "Read every open enrolment, or one: its stage, expiry, ask and revision, " +
|
||||
"the bucket's fill against its cap. For a node at live, its " +
|
||||
"token sealed to the operator key, asked of the vault at that moment and relayed as the vault sealed it. With " +
|
||||
"history, the ended enrolments from the seat's events, as far back as the events are kept.",
|
||||
Input: schema(map[string]string{"node": "one node", "history": "\"true\": the ended enrolments too"},
|
||||
nil, "history")},
|
||||
{Name: EnrolmentVerbAgain, Description: "Raise an issue's ask again when the operator's channel does not hold it, " +
|
||||
"and nothing more.",
|
||||
Input: schema(map[string]string{"node": "the node whose ask is raised again"}, []string{"node"})},
|
||||
}
|
||||
}
|
||||
@@ -744,12 +744,9 @@ func TestSSHIsOpenFromTheMeshEvenWhenNothingIsAssigned(t *testing.T) {
|
||||
if strings.Contains(nft, "\t\ttcp dport 22 accept") {
|
||||
t.Fatalf("ssh is open to everywhere on a machine that faces inward only:\n%s", nft)
|
||||
}
|
||||
}
|
||||
|
||||
// And from outside as well, on a machine that faces outward — because that is the way in when the
|
||||
// private network is the thing that broke.
|
||||
func TestSSHIsOpenFromOutsideOnAMachineThatFacesIt(t *testing.T) {
|
||||
nft := AsNftables(nil, []string{"198.51.100.2"}, true, nil, nil, "mesh0")
|
||||
// And from outside as well, on a machine that faces outward — because that is the way in when the
|
||||
// private network is the thing that broke.
|
||||
nft = AsNftables(nil, []string{"198.51.100.2"}, true, nil, nil, "mesh0")
|
||||
if !strings.Contains(nft, "\t\ttcp dport 22 accept") {
|
||||
t.Fatalf("a machine reachable from outside does not answer ssh there:\n%s", nft)
|
||||
}
|
||||
|
||||
@@ -66,6 +66,23 @@ func TestAGrantSecretIsOwnedByTheAccountThatProvisions(t *testing.T) {
|
||||
"root-owned 0600 file is one it cannot read — which is silent, because the harness "+
|
||||
"calls it \"not readable yet\"", fmtPath(secret), got, "operator")
|
||||
}
|
||||
// A grant whose offer presents no credential (form none, issue 1126) carries nothing sealed: no secret file is
|
||||
// written for it, as none is for a need with no credential, and the provider still learns who asked.
|
||||
out, err = r.Declaration(Rendering{Grants: []Grant{{
|
||||
Provision: "mongodb-database", Consumer: "workstation", From: "photos", Slug: "photos", Values: map[string]any{}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
asked := false
|
||||
for _, res := range out {
|
||||
if res["type"] == "file" && strings.HasSuffix(fmtPath(res), ".secret") {
|
||||
t.Errorf("a grant with no credential writes %v", res)
|
||||
}
|
||||
asked = asked || fmtPath(res) == "/var/lib/mongodb/grants/mesh.json"
|
||||
}
|
||||
if !asked {
|
||||
t.Errorf("the provider no longer learns who asked: %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// And a provider whose code still runs in a container keeps the owner it declares, so this
|
||||
|
||||
@@ -29,7 +29,7 @@ func conditionsVerbs() []Verb {
|
||||
{Name: "conditions", Description: "What is wrong with the mesh now: every open condition, urgent first, then oldest, " +
|
||||
"raised from the observers' findings (the watchman, the self-check, the healers, the assigner) and cleared when " +
|
||||
"the finding goes, never by hand. Given a key, that one whole with its evidence; with history, every transition " +
|
||||
"lately, the controller's before the take-over included.",
|
||||
"lately.",
|
||||
Input: schema(map[string]string{
|
||||
"key": "a condition's key: that one whole; with history, only its transitions",
|
||||
"scope": "only this scope: machine, plan, call, build, merge, provider, seat, bus, core, probe, mesh, delivery or module",
|
||||
@@ -46,17 +46,6 @@ func conditionsVerbs() []Verb {
|
||||
"why": "why, required and recorded",
|
||||
"cause": "the cause in a word (the condition's kind when absent)",
|
||||
}, []string{"key", "for", "why"})},
|
||||
{Name: "compare", Description: "The keeper's open conditions against the controller's: every key open in one and not " +
|
||||
"the other, or open in both with another severity, each with its reason; the kinds that end with the controller " +
|
||||
"and the keys held while their module is delivered listed apart and not counted. The take-over waits for it empty.",
|
||||
Input: schema(map[string]string{}, nil)},
|
||||
{Name: "take-over", Description: "Ask the operator's Approve to switch who the router, the grants part and " +
|
||||
"mesh-watcher hear from the controller to the keeper; refused while compare shows a difference. On the warrant " +
|
||||
"the keeper writes its take-over key.",
|
||||
Input: schema(map[string]string{"why": "why now, shown to the operator"}, []string{"why"})},
|
||||
{Name: "hand-back", Description: "Switch the readers back to the controller's conditions, until the controller's " +
|
||||
"lease is handed away; refused after it.",
|
||||
Input: schema(map[string]string{"why": "why, kept with the switch"}, []string{"why"})},
|
||||
{Name: "resync", Description: "Read every observer's findings again and raise and clear what differs from the open " +
|
||||
"conditions: the recovery of a keeper that missed a change.",
|
||||
Input: schema(map[string]string{}, nil)},
|
||||
@@ -78,8 +67,7 @@ func watchdogsVerbs() []Verb {
|
||||
Input: schema(map[string]string{"machine": "one machine"}, nil)},
|
||||
{Name: "dead-letters", Description: "Every message a consumer on the bus gave up on, kept in DEAD_LETTERS: whose " +
|
||||
"consumer, the subject, how often it was handed over and when it was given up, newest first. With id: that one " +
|
||||
"whole. With deliver or drop, a hand act with why, taken only once the controller's lease is handed away; " +
|
||||
"before, refused naming the controller's verb.",
|
||||
"whole. With deliver or drop, a hand act with why.",
|
||||
Input: schema(map[string]string{
|
||||
"id": "a dead letter's id, as the list gives it: that one whole, with its message",
|
||||
"consumer": "a consumer's name, or <stream>.<consumer>: only what that one gave up on",
|
||||
@@ -114,8 +102,7 @@ func selfCheckVerbs() []Verb {
|
||||
func healersVerbs() []Verb {
|
||||
return []Verb{
|
||||
{Name: "healers", Description: "The healers: each registered response to one kind of condition, its repair, its " +
|
||||
"budget and what happens when it is spent; every act taken lately with its outcome, the acts it would have " +
|
||||
"taken while the controller's lease names a controller, and the mesh-wide brake.",
|
||||
"budget and what happens when it is spent; every act taken lately with its outcome, and the mesh-wide brake.",
|
||||
Input: schema(map[string]string{"days": "how many days of acts back (default 7)"}, nil)},
|
||||
{Name: "heal", Description: "Take one healer's act for one open condition now, within its budget and the brake.",
|
||||
Input: schema(map[string]string{
|
||||
|
||||
@@ -12,15 +12,13 @@ package catalogue
|
||||
// PlacementSeat is the seat the assigner holds for placement.
|
||||
const PlacementSeat = "mesh-placement"
|
||||
|
||||
// The seat's seven verbs (ADR 0567 §10).
|
||||
// The seat's five verbs (ADR 0567 §10).
|
||||
const (
|
||||
PlacementVerbPlacement = "placement"
|
||||
PlacementVerbProposals = "proposals"
|
||||
PlacementVerbPropose = "propose"
|
||||
PlacementVerbWithdraw = "withdraw"
|
||||
PlacementVerbAgain = "again"
|
||||
PlacementVerbAdopt = "adopt"
|
||||
PlacementVerbHandBack = "hand-back"
|
||||
)
|
||||
|
||||
// placementVerbs are the seat's verbs: what reads all it keeps and does by hand all it does by itself (ADR 0567 §10).
|
||||
@@ -31,7 +29,10 @@ func placementVerbs() []Verb {
|
||||
"place (node, module, ports, bindings), take-off (node, module), pin (node, provision, provider {node, module}), " +
|
||||
"unpin (node, provision), hold (node, module, claim, scope, kind, site), unhold (node, module, claim), decide " +
|
||||
"(node, field, value: a decided field of the node, its address among them), config (node, module, mesh-wide, " +
|
||||
"layer, mesh-wide-values, values)",
|
||||
"layer, mesh-wide-values, values), add-node (node, decided, keys: a joining node, mesh-enrolment's alone, the " +
|
||||
"proposal's one change, with the enrolment's warrant, its tunnel key alone at the operator's yes; ADR 0587 §7), " +
|
||||
"remove-node (node: a joining node never confirmed, taken off when its enrolment ends; mesh-enrolment's alone, " +
|
||||
"on the same warrant)",
|
||||
"items": map[string]any{"type": "object", "required": []string{"op", "node"}}}
|
||||
return []Verb{
|
||||
{Name: PlacementVerbPlacement, Optional: true, Description: "Read placement: one node's key, the nodes one " +
|
||||
@@ -52,11 +53,17 @@ func placementVerbs() []Verb {
|
||||
"on one host port, a provider not placed, a seat held once held twice, two nodes with one address, two " +
|
||||
"hubs, a module the indexer does not hold, a node with no record). Otherwise the nodes it touches compose " +
|
||||
"it in shadow and answer, and on a passing verdict the operator is asked to approve it. Who proposes is the " +
|
||||
"caller, as the runtime proves it.",
|
||||
"caller, as the runtime proves it. An add-node from mesh-enrolment, whose warrant names its node and address, " +
|
||||
"is written with no ask of its own: its Approve is the enrolment's (ADR 0587 §7).",
|
||||
Input: map[string]any{"type": "object", "additionalProperties": false, "required": []string{"why", "changes"},
|
||||
"properties": map[string]any{
|
||||
"why": map[string]any{"type": "string", "description": "why, shown to the operator with the proposal"},
|
||||
"changes": changes,
|
||||
// ADR 0587 §7: an add-node carries the warrant of the enrolment that issued the node's token, whose
|
||||
// Approve is the proposal's; the holder writes it with no ask of its own.
|
||||
"warrant": map[string]any{"type": "object", "description": "for an add-node alone: the enrolment's " +
|
||||
"warrant {node, address, ask, warrant}, naming the change's node and address",
|
||||
"required": []string{"node", "address", "ask", "warrant"}},
|
||||
}}},
|
||||
{Name: PlacementVerbWithdraw, Optional: true, Description: "Withdraw an open proposal: its key and its " +
|
||||
"candidates are deleted and placement-withdrawn carries it whole. With why.",
|
||||
@@ -65,13 +72,5 @@ func placementVerbs() []Verb {
|
||||
"stand, and raise its ask again when its verdict passes and the operator's channel does not hold the ask. " +
|
||||
"Nothing more: the recovery of a proposal whose judgement or ask was lost.",
|
||||
Input: schema(map[string]string{"id": "the proposal's id"}, []string{"id"})},
|
||||
{Name: PlacementVerbAdopt, Optional: true, Description: "Take placement over from the controller: its " +
|
||||
"placement-export is proposed whole as the seed, every node's key, which every node must answer as the same. " +
|
||||
"Its Approve is the take-over; at the warrant the export is read again and a changed one is proposed anew.",
|
||||
Input: schema(map[string]string{"why": "why, shown to the operator"}, []string{"why"})},
|
||||
{Name: PlacementVerbHandBack, Optional: true, Description: "The operator's alone, recorded as a hand act: " +
|
||||
"delete every placement key, the history kept, so every node returns to the controller's compositions. " +
|
||||
"Refused after the lease write.",
|
||||
Input: schema(map[string]string{"why": "why, recorded with the hand act"}, []string{"why"})},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,15 +33,16 @@ type ProcessBus struct {
|
||||
Removes []string `json:"removes,omitempty"`
|
||||
}
|
||||
|
||||
// What a bus account may remove (novox/hq ADR 0564 Limits 4), each its own grant: the controller's consumer on
|
||||
// DEAD_LETTER_NOTICES (the lease write's step, clause 5), and keys of the node-engine's acts bucket. A further consumer
|
||||
// or bucket is a change of this set, through a decision.
|
||||
// What a bus account may remove (novox/hq ADR 0564 Limits 4), each its own grant: keys of the node-engine's acts
|
||||
// bucket, and the ended controller's leftovers on the bus (ADR 0590 clause 6): its consumers, its CONTROL and ASSIGNMENTS
|
||||
// streams, its buckets and the declaration files' object buckets, each by its name. A further consumer, stream or bucket
|
||||
// is a change of this set, through a decision.
|
||||
const (
|
||||
RemovesControllerDeadLetterConsumer = "DEAD_LETTER_NOTICES.controller"
|
||||
RemovesEngineActs = "mesh-host_acts"
|
||||
// RemovesAssignmentsStream is the stream ASSIGNMENTS, removed whole at the lease write (novox/hq research 072 row 33):
|
||||
// its 350 messages have no consumer, and the placement is the provisioner's from then on.
|
||||
RemovesAssignmentsStream = "ASSIGNMENTS"
|
||||
RemovesEngineActs = "mesh-host_acts"
|
||||
// RemovesControllerLeftovers is the closed set of the ended controller's leftovers (buscompose.ControllerLeftovers).
|
||||
// The word does not name the controller's module, so the check that refuses any manifest naming it (ADR 0590 clause
|
||||
// 5) holds over the bus module's manifest too.
|
||||
RemovesControllerLeftovers = "controller-leftovers"
|
||||
)
|
||||
|
||||
// BusAccount is one account of a module's own beside its `broker`: a process's (ADR 0436) or a tools bundle's (ADR 0564
|
||||
|
||||
@@ -13,7 +13,6 @@ func TestTheVerbsWorkedAroundSayWhatTheyReplace(t *testing.T) {
|
||||
ServiceManagerSeat + ".restart": "systemctl restart",
|
||||
ServiceManagerSeat + ".status": "systemctl status",
|
||||
"node-hostname.add": "edit /etc/hosts",
|
||||
ControllerSeatName + ".node": "hostnamectl",
|
||||
}
|
||||
for _, s := range DefaultSeats() {
|
||||
for _, v := range s.Serves {
|
||||
|
||||
@@ -372,7 +372,7 @@ func TestWhatAModuleOfItsOwnAccountReadsIsItsAccounts(t *testing.T) {
|
||||
with := Rendering{ArtifactStore: "anchor.internal:5101",
|
||||
Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}, "vault": {"broker": "own"}},
|
||||
Kept: &KeptExport{Export: 1, OperatorKey: "k"},
|
||||
Grants: []Grant{{Provision: "secret", Consumer: "anchor", From: "telegram",
|
||||
Grants: []Grant{{Provision: "secret", Consumer: "anchor", From: "telegram", Sealed: "sealed-to-anchor",
|
||||
Values: map[string]any{"name": "telegram"}}}}
|
||||
goRuntime := Manifest{Module: RuntimeModule, Version: "1",
|
||||
OwnSecrets: OwnSecrets{"broker": {Path: "/var/lib/mesh/" + RuntimeModule + "/broker"}},
|
||||
|
||||
+55
-51
@@ -100,37 +100,6 @@ type Receivable struct {
|
||||
//
|
||||
// In the order a person reads it: the mesh's own, then a node's.
|
||||
var defaultSeats = append([]Seat{
|
||||
// The control plane states what it did under the seat it holds (novox/hq ADR 0134): a role's
|
||||
// events belong to the role, so they keep their address while the holder is replaced. No accepts,
|
||||
// so no work queue is raised for it — only what its holder may say.
|
||||
// And it serves the mesh's own verbs as the seat's tools (novox/hq ADR 0154): `status`, `push`,
|
||||
// `assign` and the rest are a role's interface, not a container's, and stay addressable while
|
||||
// the control plane is replaced.
|
||||
{Name: ControllerSeatName, Scope: ScopeMesh, Decision: "novox/hq ADR 0079",
|
||||
// And what is wrong, as it changes, and the self-check's heartbeat (novox/hq to-be 45 §2, §4).
|
||||
Emits: []string{"applied", "refused", "built-before",
|
||||
"condition-raised", "condition-changed", "condition-cleared", "doctor-heartbeat",
|
||||
// A value given by hand, replaced after its module's first good start (novox/hq ADR 0228).
|
||||
"secret-replaced",
|
||||
// Every act a healer takes (novox/hq to-be 45 §7).
|
||||
"healer-acted",
|
||||
// A build put back after its gate failed (novox/hq ADR 0236, to-be 45 §8).
|
||||
"rolled-back",
|
||||
// A pull request's merge check, judged (novox/hq to-be 45 §9).
|
||||
"checked",
|
||||
// A walk kept in a new state, and a tier sent to a machine: kept until the catalogue's main consumes them no
|
||||
// more; nothing emits them (ADR 0539).
|
||||
"plan-moved",
|
||||
"sent",
|
||||
// A key of a record mirror written or removed (novox/hq ADR 0289, design 54 §3; design 57 F3).
|
||||
"assignment-changed", "declared-config-changed", "predefined-changed",
|
||||
// A builder's key not taken in because the artifact store does not hold an artifact it names (novox/hq
|
||||
// issue 1937): the builder builds it again.
|
||||
"artifacts-gone",
|
||||
// A bucket of a state this release deleted, deleted at the controller's first start (novox/hq ADR 0539
|
||||
// clause 13): the merge window's, the silent sends' and the building plans' hold, each said once.
|
||||
"bucket-deleted"},
|
||||
Serves: ControllerVerbs},
|
||||
// The store's first verbs (novox/hq ADR 0159): the smallest set that makes the store askable,
|
||||
// served by whichever module holds the seat with tools of these names.
|
||||
{Name: "mesh-store", Scope: ScopeMesh, Delivers: "postgres-database", Decision: "novox/hq ADR 0079",
|
||||
@@ -233,13 +202,59 @@ var defaultSeats = append([]Seat{
|
||||
"controller's, never anew for one the vault holds a value or an adopted hash for. Never the value.",
|
||||
Input: schema(map[string]string{"user": "the bus user, e.g. ace.mesh-build-go"}, []string{"user"})},
|
||||
{Name: "sign", Optional: true, Description: "Sign with the vault's key or authority (novox/hq ADR 0550 §2): " +
|
||||
"kind node-certificate signs the certificate of the calling machine's internal name for the serving key " +
|
||||
"its record states, built from bus state alone and asked by that machine's node-engine only; kind " +
|
||||
"enrolment signs an enrolment reply under its own context, for a caller the adoption record names. " +
|
||||
"Answers the certificate or the signature and the signer's fingerprint, and says signed; never a key.",
|
||||
"kind node-certificate signs the certificate of a machine's internal name, asked by that machine's " +
|
||||
"node-engine for the serving key its record states, built from bus state alone, or by mesh-enrolment for " +
|
||||
"a node it enrols, for the serving key the node presented at redeem (ADR 0587 §3); kind enrolment signs a " +
|
||||
"redeem answer under its own context, for mesh-enrolment alone. Answers the certificate or the signature " +
|
||||
"and the signer's fingerprint, and says signed; never a key.",
|
||||
Input: schema(map[string]string{"kind": "node-certificate or enrolment",
|
||||
"node": "the machine, for a node certificate: the caller's own",
|
||||
"reply": "the enrolment reply's bytes in base64, for an enrolment"}, []string{"kind"})},
|
||||
"node": "the machine, for a node certificate: the caller's own, or the node mesh-enrolment enrols",
|
||||
"serving_key": "from mesh-enrolment: the serving key's public half the enrolling node presented",
|
||||
"reply": "the redeem answer's bytes in base64, for an enrolment"}, []string{"kind"})},
|
||||
// Enrolment (novox/hq ADR 0561 §5, ADR 0587 §3, §5; issue 774): mesh-enrolment is the only caller the vault's
|
||||
// manifest names for these two.
|
||||
{Name: "enrolment-token", Optional: true, Description: "Make a one-time token for a node mesh-enrolment " +
|
||||
"issues on the operator's warrant: its secret kept with its copy sealed to the operator key and its hash " +
|
||||
"written as the bus user enrol.<node>. Answers only what the joining machine must trust (the bus's " +
|
||||
"address, the bus certificate's fingerprint, the vault's public signing key), never the token; with " +
|
||||
"sealed, the token sealed to the operator key, for tokens to relay; with drop, the token and its hash " +
|
||||
"dropped, at the enrolment's end. Asked by mesh-enrolment alone, only from the lease write. The token " +
|
||||
"carries the first tunnel mesh-enrolment built at issue (ADR 0587 §3, insight of 2026-11-02).",
|
||||
Input: withObject(schema(map[string]string{"node": "the node the token is for",
|
||||
"expires": "when the token expires (RFC 3339), as the enrolment's key says",
|
||||
"sealed": "\"true\": answer the token sealed to the operator key",
|
||||
"drop": "\"true\": drop the node's token and its hash"}, []string{"node"}, "sealed", "drop"),
|
||||
"tunnel", "the node's first tunnel, built by mesh-enrolment at issue and carried in the token: the hub's "+
|
||||
"public key, the hub's endpoint, the node's tunnel address, its own tunnel key and the network's range",
|
||||
map[string]string{"hub_key": "the hub's tunnel public key", "hub_endpoint": "the hub's endpoint, host:port",
|
||||
"address": "the joining node's tunnel address",
|
||||
"key": "the joining machine's own tunnel public key, as given at issue",
|
||||
"range": "the private network's range, a prefix"})},
|
||||
{Name: "node-account", Optional: true, Description: "Mint a joining node's bus account node.<node>, sealed " +
|
||||
"to the sealing key it presented, and write its hash; asked again for the same node and keys, answers the " +
|
||||
"account it already minted; with retire, the account it minted for a revoked enrolment retired. Writes the " +
|
||||
"node's node-logins key naming node.<node>. Asked by mesh-enrolment alone. Never the value in the clear.",
|
||||
Input: schema(map[string]string{"node": "the joining node",
|
||||
"sealing_key": "the sealing key's public half the node presented, which the account is sealed to",
|
||||
"keys_digest": "the digest of every public key the node presented (delivery.KeysDigest)",
|
||||
"retire": "\"true\": retire the account minted for this node"}, []string{"node"}, "retire")},
|
||||
// A node's account rotates over two logins (novox/hq ADR 0583 §2-§5; issue 774), one node at a time, the bus
|
||||
// node last; the state node-logins says where each node stands.
|
||||
{Name: "rotate-node", Optional: true, Description: "Begin a node's account rotation to its other login: " +
|
||||
"answered only for a call carrying a hand act recorded through mesh-hand-acts, which the vault reads back by " +
|
||||
"its id. Makes the other login's value sealed to that node, writes it rotating in node-logins and says " +
|
||||
"rotation-begun; says node-account-ready once the bus node's have shows a list holding both logins. One " +
|
||||
"node at a time across the mesh, the bus node last: a call while a rotation runs waits until it is done.",
|
||||
Input: schema(map[string]string{"node": "the node whose account rotates", "why": "why, in a sentence",
|
||||
"act": "the id of the hand act recorded for it in mesh-hand-acts"}, []string{"node", "why", "act"})},
|
||||
{Name: "node-account-confirm", Optional: true, Description: "Confirm a node's switch to its new login: " +
|
||||
"asked by the node-engine dialled as that login, the runtime's proof of the caller being the confirmation. " +
|
||||
"The old login's hash is then removed, and rotation-done said once the bus node's have shows the list " +
|
||||
"without it.",
|
||||
Input: schema(map[string]string{}, nil)},
|
||||
{Name: "abandon", Optional: true, Description: "Abandon a node's rotation before its old login is retired: " +
|
||||
"the new login and its value dropped, the old one kept; a node that switched switches back first.",
|
||||
Input: schema(map[string]string{"node": "the node whose rotation is abandoned"}, []string{"node"})},
|
||||
}},
|
||||
// The owner of every config value (novox/hq ADR 0348 §9, design 57 K3): its holder alone writes the buckets
|
||||
// mesh-config_values, _resolved, _predefined and _proposals, and serves the one verb `config` (§1, §10).
|
||||
@@ -248,21 +263,8 @@ var defaultSeats = append([]Seat{
|
||||
Serves: []Verb{
|
||||
{Name: "config", Optional: true, Description: "Set, clear, list or read the history of config values: " +
|
||||
"mesh config or node config for a predefined key, or a module's own value on one node, each set " +
|
||||
"with why, validated, and naming each module on each node whose value moves. A trusted value only " +
|
||||
"from the controller's terminal.",
|
||||
"with why, validated, and naming each module on each node whose value moves.",
|
||||
Input: configInput()},
|
||||
// The switch of config to its own sources (novox/hq ADR 0565 §4): optional until every mesh-config serves it.
|
||||
{Name: "compare", Optional: true, Description: "Every difference between config's own sources and the " +
|
||||
"controller's records, each with its reason: a module's declared config against the controller's mirror, " +
|
||||
"the indexer's predefined list against the mirror's, and each open pull request's config candidate against " +
|
||||
"what the controller told. The take-over waits for none.",
|
||||
Input: schema(map[string]string{}, nil)},
|
||||
{Name: "take-over", Optional: true, Description: "Ask the operator's Approve to switch config to its own " +
|
||||
"sources (the indexer); refused while compare shows a difference.",
|
||||
Input: schema(map[string]string{"why": "why, in a sentence"}, []string{"why"})},
|
||||
{Name: "hand-back", Optional: true, Description: "Switch config back to the controller's mirrors, until the " +
|
||||
"controller hands its lease away; refused after it.",
|
||||
Input: schema(map[string]string{"why": "why, in a sentence"}, []string{"why"})},
|
||||
}},
|
||||
// The provisioner's server (novox/hq ADR 0349 clause 3, design 57 P4): its holder keeps the registry of which
|
||||
// domain answers which kind of placeholder, and the buckets mesh-provisioner_needs, _applied and _collect,
|
||||
@@ -302,6 +304,8 @@ var defaultSeats = append([]Seat{
|
||||
// policy and the bus step at the assigner, and the hand-act log.
|
||||
{Name: WantsSeat, Scope: ScopeMesh, Decision: controlRecordsDecision, Emits: []string{"policy-changed"}, Serves: wantsVerbs()},
|
||||
{Name: HandActsSeat, Scope: ScopeMesh, Decision: controlRecordsDecision, Emits: []string{"act-recorded"}, Serves: handActsVerbs()},
|
||||
// Joining a machine (novox/hq ADR 0587 §1, enrolment_seat.go): held once by the module mesh-enrolment.
|
||||
{Name: EnrolmentSeat, Scope: ScopeMesh, Decision: enrolmentDecision, Emits: EnrolmentEvents, Serves: enrolmentVerbs()},
|
||||
// The artifact sweep (novox/hq ADR 0571 clause 2, artifact_sweep_seat.go).
|
||||
{Name: ArtifactSweepSeat, Scope: ScopeMesh, Decision: "novox/hq ADR 0571", Emits: []string{"artifacts-gone"},
|
||||
Serves: artifactSweepVerbs()},
|
||||
|
||||
@@ -31,6 +31,7 @@ func parsed(t *testing.T, m Manifest) error {
|
||||
}
|
||||
|
||||
func TestAnUnsetPreferenceTakesItsDefault(t *testing.T) {
|
||||
t.Run("the node over the mesh over the default", theNodeOverTheMeshOverTheDefault)
|
||||
m := notifier()
|
||||
for _, layers := range [][]Layer{nil, {{From: MeshWideLayer, Values: map[string]any{}}}} {
|
||||
file := map[string]any{}
|
||||
@@ -49,7 +50,7 @@ func TestAnUnsetPreferenceTakesItsDefault(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheNodeOverTheMeshOverTheDefault(t *testing.T) {
|
||||
func theNodeOverTheMeshOverTheDefault(t *testing.T) {
|
||||
m := notifier()
|
||||
layers := []Layer{
|
||||
{From: MeshWideLayer, Values: map[string]any{"width": float64(300)}},
|
||||
|
||||
@@ -42,7 +42,6 @@ const (
|
||||
StreamMostDeliveries = 10
|
||||
)
|
||||
|
||||
|
||||
// MeshStreamNames are the mesh's own streams by name. Written here as well as in the broker, which this
|
||||
// package cannot import; a test keeps them agreeing.
|
||||
var MeshStreamNames = []string{"CONTROL", "NODES", "EVENTS", "DEAD_LETTER_NOTICES", "DEAD_LETTERS", "PROV_APPLIED", "CHECKS_READY", "CHECKS_OTHER"}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package catalogue
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The vault's provision is one the controller itself dereferences — every minted credential is
|
||||
// sealed with it — so it is delivered by a seat of the mesh's own, and a second provider is a second
|
||||
@@ -65,6 +68,14 @@ func TestTheVaultsSeatPromisesItsIntakeVerbs(t *testing.T) {
|
||||
"secret-ask": {"node", "module", "name", "desk", "asked_by", "outcome", "call", "sealed"},
|
||||
"issued": {"provision", "provider", "module", "call", "sealed", "holders"},
|
||||
"rotate": {"node", "module", "name", "provider", "provision", "by"},
|
||||
// 774/S37/AC1: the enrolment verbs mesh-enrolment asks (ADR 0587 §3, §5).
|
||||
"enrolment-token": {"node", "expires", "sealed", "drop", "tunnel"},
|
||||
"node-account": {"node", "sealing_key", "keys_digest", "retire"},
|
||||
"sign": {"kind", "node", "serving_key", "reply"},
|
||||
// 774/S38: the rotation verbs (ADR 0583 §2, §3, §5).
|
||||
"rotate-node": {"node", "why", "act"},
|
||||
"node-account-confirm": {},
|
||||
"abandon": {"node"},
|
||||
}
|
||||
for name, args := range want {
|
||||
var verb *Verb
|
||||
@@ -89,6 +100,14 @@ func TestTheVaultsSeatPromisesItsIntakeVerbs(t *testing.T) {
|
||||
if props["value"] != nil {
|
||||
t.Errorf("%s takes a value in the clear", name)
|
||||
}
|
||||
// The first tunnel the token carries (ADR 0587 §3, insight of 2026-11-02): an object of the hub's key, its endpoint
|
||||
// and the node's address.
|
||||
if name == "enrolment-token" {
|
||||
tunnel, _ := props["tunnel"].(map[string]any)
|
||||
if tunnel["type"] != "object" || !slices.Equal(tunnel["required"].([]string), []string{"address", "hub_endpoint", "hub_key", "key", "range"}) {
|
||||
t.Errorf("enrolment-token's tunnel is %v", tunnel)
|
||||
}
|
||||
}
|
||||
}
|
||||
// The holders `issued` names (issue 749) are a list of {node, module}.
|
||||
for _, verb := range seat.Serves {
|
||||
@@ -105,7 +124,8 @@ func TestTheVaultsSeatPromisesItsIntakeVerbs(t *testing.T) {
|
||||
}
|
||||
serving := Manifest{Module: "mesh-vault", Provides: []Offer{{Name: "secret", Scope: ScopeMesh}},
|
||||
Claims: []Claim{{Name: "mesh-vault", Scope: ScopeMesh,
|
||||
Serves: []string{"get", "accept", "secret-ask", "issued", "rotate"}}}}
|
||||
Serves: []string{"get", "accept", "secret-ask", "issued", "rotate", "sign", "enrolment-token", "node-account",
|
||||
"rotate-node", "node-account-confirm", "abandon"}}}}
|
||||
if err := CanHold(serving, seat); err != nil {
|
||||
t.Fatalf("the vault serving its verbs was refused: %v", err)
|
||||
}
|
||||
|
||||
+10
-515
@@ -4,6 +4,8 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// A Verb is one tool a role serves: its name, what it does, and the schema of its arguments and of
|
||||
@@ -74,521 +76,6 @@ func VerbNames(verbs []Verb) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// RemovedControllerVerbs are the controller seat's verbs release 6 removed (novox/hq ADR 0539, ADR 0429 §8): no
|
||||
// delivery plan, batch, tier or push exists, so nothing starts, stops, retries, follows or reads one. Each stays in
|
||||
// ControllerVerbs as a row with no input, so a caller is answered by name with its replacement, given here.
|
||||
var RemovedControllerVerbs = map[string]string{
|
||||
"plans": "the delivery view (nox-delivery): each machine's want and have, read from the assigner's wants and the " +
|
||||
"node-engine's haves",
|
||||
"plan": "the node's compositions: mesh-controller_composed holds each module's digest and judgement per node, and " +
|
||||
"mesh-hand-compose prints one module's composition for one node",
|
||||
"push": "a want: the assigner writes want.<node>.<module> by its rules, and by hand the operator's credential writes " +
|
||||
"it, naming a kept composition (ADR 0539 clause 10)",
|
||||
"deliver": "nothing to start: a merged change reaches each machine by its want",
|
||||
"delivery-stop": "a bad fingerprint is held and its canary set back by the assigner (ADR 0429 §6, ADR 0539 clause 18)",
|
||||
"delivery-backups-again": "the assigner's backup ask, answered on the phone or in the delivery view (ADR 0539 clause 16)",
|
||||
"delivery-walks": "the delivery view (nox-delivery) and the wants, haves and compositions on the bus",
|
||||
"delivery-order": "no order to keep: each module reaches its machines by its own want, its providers first by rule 5",
|
||||
}
|
||||
|
||||
// removedInR6 is the row of a verb release 6 removed from the controller's seat: no input, refused by name.
|
||||
func removedInR6(name string) Verb {
|
||||
return Verb{Name: name, Description: "Removed in R6 (ADR 0539): refused by name, naming its replacement: " +
|
||||
RemovedControllerVerbs[name], Input: schema(nil, nil)}
|
||||
}
|
||||
|
||||
// ControllerSeatName is the seat the control plane holds, whose tools are the mesh's own verbs.
|
||||
const ControllerSeatName = "mesh-controller"
|
||||
|
||||
// ControllerVerbs are the mesh's own verbs, as the `mesh-controller` seat's tools (novox/hq ADR 0154).
|
||||
//
|
||||
// **The same function the command line calls, and nothing the tool adds** (ADR 0035): each of these
|
||||
// is a command the controller's binary already answers, run by the holder of the seat with the
|
||||
// arguments below and answered with what the command printed. A verb here is a contract every future
|
||||
// holder must implement, which is why the list is short and made of what an operator asks weekly.
|
||||
// Additive within a version (design 33 §7); a verb that would break a caller takes a new version.
|
||||
var ControllerVerbs = []Verb{
|
||||
{Name: "tools", Description: "Every seat's tools, from the mesh's own records: what each role " +
|
||||
"answers, whether or not its holder is up. The mesh's own verbs are the mesh-controller seat's.",
|
||||
Input: schema(nil, nil)},
|
||||
{Name: "calls", Description: "The calls this controller answered lately and what came of each — " +
|
||||
"one still running, one that finished after its caller was told it was running, one whose answer " +
|
||||
"the bus refused — and, given a call's id, its whole answer (novox/hq issue 265). A call that has " +
|
||||
"not finished within ten seconds answers that it is running, with its id; this is where it ends.",
|
||||
Input: schema(map[string]string{
|
||||
"call": "a call's id, as a running answer or `calls` gives it: that call and its whole answer",
|
||||
}, nil)},
|
||||
{Name: "status", Description: "What is wrong, what is quiet, what is out of date, and which " +
|
||||
"machines are behind what the mesh would send them.",
|
||||
Input: schema(nil, nil)},
|
||||
{Name: "nodes", Description: "Every machine the mesh knows, with whether it is converged or adopted.",
|
||||
Input: schema(nil, nil)},
|
||||
{Name: "node", Description: "What one machine reported it can do, what it is assigned, and why.",
|
||||
Input: schema(map[string]string{"node": "the machine's name"}, []string{"node"}),
|
||||
Replaces: []string{"hostnamectl", "uptime"}},
|
||||
{Name: "placement-export", Description: "Each machine's placement as the catalogue's placement file says it (novox/hq ADR 0555 " +
|
||||
"§6, issue 1126): what a person decided of the machine (endpoint, site, hub, address on the private network, public " +
|
||||
"domain, accounts, adoption, the tunnel found at adoption) and where its modules run (in order, with host ports, " +
|
||||
"bindings, config placement layers, pins and holdings), read by the controller's own code from its own store, as JSON. " +
|
||||
"Read-only; the one verb the controller gains for its exit (the operator, 2026-10-26 19:06:40), read once by the seed " +
|
||||
"of the placement files.",
|
||||
Input: schema(map[string]string{"node": "one machine's name; every machine when absent"}, nil)},
|
||||
{Name: "modules", Description: "Every module the mesh holds: version, the commit it was built from, " +
|
||||
"and which machines run it.",
|
||||
Input: schema(nil, nil)},
|
||||
{Name: "seats", Description: "Every seat the mesh defines, what it delivers, and who holds it.",
|
||||
Input: schema(nil, nil)},
|
||||
{Name: "builds", Description: "What has been built lately and what came of it, for every module or for one; " +
|
||||
"or, given a build's id, everything the build machine said while building it, line by line, from the bus.",
|
||||
Input: schema(map[string]string{
|
||||
"module": "one module's name; every module when absent",
|
||||
"log": "a build's id (as `builds` lists it): print what the build machine said, line by line",
|
||||
"limit": "how many builds to list (default 20); not with log",
|
||||
}, nil)},
|
||||
removedInR6("plans"),
|
||||
// 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: "Removed in R4e (ADR 0501 §5 / ADR 0503 §3): refused by name, naming its replacement",
|
||||
Input: schema(nil, nil)},
|
||||
removedInR6("delivery-order"),
|
||||
{Name: "delivery-check", Description: "Answers that the checker (mesh-delivery-checker) judges a group (novox/hq " +
|
||||
"ADR 0503 §3); asks nothing. Its inputs stay, since hq's repository check still names it (ADR 0513 §4).",
|
||||
Input: schema(map[string]string{"group": "the group's id (none for one head checked again)",
|
||||
"members": "the members, as JSON, as delivery-order takes them",
|
||||
"ready": "\"true\" when the pull request is marked ready: its check is queued before every other check (novox/hq ADR 0406 §3)"},
|
||||
[]string{"members"})},
|
||||
removedInR6("deliver"),
|
||||
removedInR6("delivery-stop"),
|
||||
removedInR6("delivery-backups-again"),
|
||||
removedInR6("delivery-walks"),
|
||||
removedInR6("plan"),
|
||||
{Name: "assign", Description: "Put a module on a machine. Refused with the mesh's own words when it cannot resolve there, " +
|
||||
"or when a seat its resources are applied through is held by nothing on the machine (novox/hq ADR 0207). " +
|
||||
"A module not registered yet is looked for (novox/hq issue 325, ADR 0261): while its build runs — a merge " +
|
||||
"that adds a module asks for one — the assignment is kept as pending and the controller makes it when the " +
|
||||
"build registers it; `status` lists it, and if the build fails it expires with why and raises a condition. " +
|
||||
"A module known and not built is said, and build asks for its build; a name with no module and no build " +
|
||||
"request kept is refused with the closest names.",
|
||||
Input: schema(map[string]string{"node": "the machine's name",
|
||||
"module": "the module's name; several comma-separated are judged together",
|
||||
"build": "\"true\": for a module known and not built (its last build failed, or was never asked), ask " +
|
||||
"for its build and keep this assignment pending until the build registers it"},
|
||||
[]string{"node", "module"}, "build")},
|
||||
{Name: "unassign", Description: "Take a module off a machine. Refused when it holds a seat a module left there depends on.",
|
||||
Input: schema(map[string]string{"node": "the machine's name",
|
||||
"module": "the module's name; several comma-separated are judged together"}, []string{"node", "module"})},
|
||||
{Name: "pin", Description: "Tell a machine which provider answers a provision for it — the module, and the node " +
|
||||
"it runs on, both. Asked for when more than one could answer; the refusal lists them.",
|
||||
Input: schema(map[string]string{
|
||||
"node": "the machine's name",
|
||||
"provision": "the provision, as the consumer requires it",
|
||||
"from": "the node the chosen provider runs on",
|
||||
"module": "the module providing it there",
|
||||
}, []string{"node", "provision", "from", "module"})},
|
||||
{Name: "unpin", Description: "Take that choice back, putting the question to the mesh again.",
|
||||
Input: schema(map[string]string{"node": "the machine's name", "provision": "the provision"}, []string{"node", "provision"})},
|
||||
removedInR6("push"),
|
||||
{Name: "rotate", Description: "Replace a credential. A pair credential, by provision (and a consuming machine and module, " +
|
||||
"else every holder): both ends are re-sent together. Or a module's own secret, by machine, module and " +
|
||||
"name: made anew and the machine sent, so the module starts again on it — for a secret its definition " +
|
||||
"says it reads at start, whether the mesh made the value or a person gave it (novox/hq ADR 0228); one " +
|
||||
"an outside party issued, or one the module applies to a backend, is refused with the reason.",
|
||||
Input: schema(map[string]string{
|
||||
"provision": "a pair credential: the provision whose credential to replace",
|
||||
"consumer": "with provision: only the holder on this machine (optional)",
|
||||
"node": "an own secret: the machine",
|
||||
"module": "an own secret: the module; with provision: only this consuming module's credential (optional)",
|
||||
"secret": "an own secret: its name in the module's definition",
|
||||
"why": "an own secret: why it is rotated — recorded in the hand-act log (optional)",
|
||||
"cause": "with why: the cause in a word, the word a second rotation for the same reason uses (optional)",
|
||||
}, nil)},
|
||||
{Name: "give", Description: "Take a module's own secret from the operator at their desk (novox/hq ADR 0259 " +
|
||||
"§10): the same as secret-ask with the desk named. A prompt that does not show what is typed opens on " +
|
||||
"the machine named by at, its answer comes back sealed to this call alone, and is sealed to the " +
|
||||
"module's machine as `secret accept` seals it. The value is never an argument and never in the answer: " +
|
||||
"the answer says it was taken, or why not. Recorded in the hand-act log as a value given at the desk. " +
|
||||
"The prompt waits 25 seconds; dismissed or unanswered, nothing changes. Then push the machine.",
|
||||
Input: schema(map[string]string{
|
||||
"node": "the machine the module runs on, which the secret is sealed to",
|
||||
"module": "the module's name",
|
||||
"secret": "the own secret's name in the module's definition",
|
||||
"at": "the machine the operator sits at, where the prompt opens",
|
||||
}, []string{"node", "module", "secret", "at"})},
|
||||
{Name: "issued", Description: "Take the credential a provider's software issued for a provision — a media " +
|
||||
"server's sign-in token — and keep it as that provision's one credential for every consumer (novox/hq ADR " +
|
||||
"0305): the controller asks the provider's own tool on its machine, in the controller's own name, for the " +
|
||||
"value sealed to a key made for this call alone, seals it to every holder and forgets it, and sends their " +
|
||||
"machines when it changed. Carries no value and answers none; a value every holder already holds is not " +
|
||||
"sealed again, so asking again restarts nothing. Refused for a provision no offer says its software issues.",
|
||||
Input: schema(map[string]string{
|
||||
"provision": "the provision whose provider's software issues its credential, e.g. plex-api",
|
||||
}, []string{"provision"})},
|
||||
{Name: "secret-move", Description: "Move every secret the controller holds into the vault, as it is (novox/hq " +
|
||||
"ADR 0349 clause 7, design 57's V3): each machine's node-engine opens what was sealed to it and hands it to " +
|
||||
"the vault, which takes it from that machine alone, never over a value it holds, and reads it back; the " +
|
||||
"record of asks at a desk is copied once. No value is rotated, in the arguments or in the answer, and " +
|
||||
"nothing points at the vault because of it. Answers, per machine, what moved, what was there already and " +
|
||||
"what did not move and why; a machine not answering is moved when it is next heard from. Run again, it " +
|
||||
"moves what is left.",
|
||||
Input: schema(map[string]string{
|
||||
"node": "only this machine's secrets (optional; every machine's without it)",
|
||||
}, nil)},
|
||||
{Name: "secret-ask", Description: "Ask the operator for a module's own secret (novox/hq ADR 0277): a prompt " +
|
||||
"that shows nothing of what is typed opens at the desk — the module's machine, or the one at names — " +
|
||||
"naming the module, the secret and who asked; the operator types the value there, never on a channel " +
|
||||
"and never in a verb's argument; the answer comes back sealed to this call alone and is sealed to the " +
|
||||
"module's machine as `secret accept` seals it. The answer says the value was taken, or why not. " +
|
||||
"Refused for a secret the mesh issues itself (the bus account, one the mesh may make) and for a module " +
|
||||
"that runs as an account of its own, whose value is typed at the controller's terminal. Bounded: one " +
|
||||
"open prompt per secret, three asks an hour. Recorded in the hand-act log, with who asked, and " +
|
||||
"announced on every channel. Then push the machine.",
|
||||
Input: schema(map[string]string{
|
||||
"node": "the machine the module runs on, which the secret is sealed to",
|
||||
"module": "the module's name",
|
||||
"secret": "the own secret's name in the module's definition",
|
||||
"at": "the machine the operator sits at, where the prompt opens; the module's machine when absent",
|
||||
}, []string{"node", "module", "secret"})},
|
||||
{Name: "issue", Description: "Give a module on a machine its account on the bus: minted, and sealed to the " +
|
||||
"machine as the module's own secret named broker, read at the next push of that machine. For a module " +
|
||||
"whose definition declares that secret; refused with the reason otherwise. Issued again, it replaces the account.",
|
||||
Input: schema(map[string]string{
|
||||
"node": "the machine that runs the module",
|
||||
"module": "the module's name",
|
||||
}, []string{"node", "module"})},
|
||||
{Name: "token", Description: "Refused through a verb since novox/hq ADR 0266: the one-time token a machine " +
|
||||
"joins with is answered to its caller, and whoever may call a verb includes agents. Issue it at the " +
|
||||
"controller's terminal: `mesh-controller token issue --new <name> --overlay-key <public half>`.",
|
||||
Input: schema(map[string]string{
|
||||
"node": "a machine the mesh already has a record for",
|
||||
"new": "or the name of a machine to create the record for",
|
||||
"overlay_key": "the public half of the machine's tunnel key",
|
||||
"for": "how long it may be used, as a duration (default 1h)",
|
||||
"adopted": "\"true\" when the machine is in use and joins adopted",
|
||||
}, nil, "adopted"),
|
||||
Replaces: []string{"mesh-controller token issue", "wg set"}},
|
||||
{Name: "settings", Description: "Read or set what an assignment is configured with: a module's settings for the whole " +
|
||||
"mesh, or for one machine. Without values or clear, answers the layer as it stands — read it before setting it — " +
|
||||
"then every value the module gives a default or a layer sets, with where it comes from: the module's default, " +
|
||||
"the mesh, or the machine (novox/hq ADR 0262); with list \"preferences\", or with no module, every " +
|
||||
"module's preferences and each machine's value; " +
|
||||
"with history, the layers it replaced. Setting replaces that layer whole and answers each key it adds (+), " +
|
||||
"changes (~) and removes (-); a set that would remove a key is refused unless replace says it is meant " +
|
||||
"(novox/hq ADR 0217). Takes effect at the next push. With clear, removes the layer and the module is back to " +
|
||||
"what its definition says; a cleared or replaced layer is kept in the history. A trusted setting — " +
|
||||
"places, accesses, what a provider serves, what a trusted file asks for, the whole layer of a module " +
|
||||
"with a trusted mergeable file — is refused through this verb (novox/hq issue 339, 340) and PROPOSED " +
|
||||
"instead (novox/hq ADR 0277): with propose, the values (or clear) are put to the operator on a channel " +
|
||||
"that proves who answers, with every key and its exact new value, and the layer is set only on their " +
|
||||
"Approve; the answer names the proposal, its fingerprint and when it expires, and nothing changes until " +
|
||||
"then. With proposals, every proposal and where each stands; with proposal, one whole. Once move-config has " +
|
||||
"run, a module in the config form forwards to mesh-config (issue 664): show lists what config lists, set and " +
|
||||
"clear set and clear each value as the module's own on the machine (none for the whole mesh), placement " +
|
||||
"staying beside the assignment, and propose proposes each value through mesh-config.",
|
||||
Input: schema(map[string]string{
|
||||
"module": "the module's name; with list, only that module's",
|
||||
"values": "the settings as a JSON object, for set or propose",
|
||||
"node": "one machine; the whole mesh when absent",
|
||||
"clear": "\"true\" to remove the layer instead of setting it, or to propose its removal; not with values",
|
||||
"replace": "\"true\": with values, the set (or the proposal) is meant to remove the keys the layer had and it does not name",
|
||||
"history": "\"true\": without values or clear, the layers this one replaced, the latest first",
|
||||
"list": "\"preferences\": every module's preferences — key, default and why — and the value on each " +
|
||||
"machine it is assigned to with where it comes from; module and node narrow it (novox/hq ADR 0262)",
|
||||
"propose": "\"true\": propose the values (or clear) to the operator instead of setting them (novox/hq ADR 0277)",
|
||||
"proposals": "\"true\": every settings proposal, newest first, and where each stands",
|
||||
"proposal": "a proposal's id: that proposal whole, its values and the layer it was shown against",
|
||||
}, nil, "clear", "replace", "history", "propose", "proposals")},
|
||||
{Name: "command", Description: "Run one reading command line of the controller's own, as you would type it at " +
|
||||
"its shell — `node show ace`, `module list`, `plans`, `conditions show <key>` — and answer what it " +
|
||||
"printed. The generic verb beside the named ones (novox/hq ADR 0154), and since ADR 0266 it only reads: " +
|
||||
"status, version, help, seats, healers, hand-acts, durations, collection, images, artifacts, data, builds, " +
|
||||
"plan, plans (not stop/close/go), doctor (not run), conditions list/show/history, node list/show, " +
|
||||
"module list, settings show/preferences, retire list, cleanup list, delivery plan/walks, bus, mirrors (not " +
|
||||
"--record/--confirm). What writes has its named verb; what sets a key, issues a credential or a token, or " +
|
||||
"accepts, recovers or exports a secret is the controller's terminal's alone.",
|
||||
Input: schema(map[string]string{
|
||||
"command": "the command line, as the controller's binary takes it; quotes group a word with spaces",
|
||||
}, []string{"command"})},
|
||||
// The build queue went with build-agent (novox/hq ADR 0525 §9): asks are the build seats' streams, their backlog the
|
||||
// streams' own state, shown in the delivery view. The verbs are refused by name for one release, naming that.
|
||||
{Name: "queue", Description: "Removed in R4g (ADR 0525 §9): refused by name, naming its replacement: the build seats' backlog in the delivery view",
|
||||
Input: schema(nil, nil)},
|
||||
{Name: "cancel", Description: "Removed in R4g (ADR 0525 §9): refused by name, naming its replacement", Input: schema(nil, nil)},
|
||||
{Name: "clear", Description: "Removed in R4g (ADR 0525 §9): refused by name, naming its replacement", Input: schema(nil, nil)},
|
||||
{Name: "replay", Description: "Removed in R4g (ADR 0525 §9): refused by name, naming its replacement", Input: schema(nil, nil)},
|
||||
// novox/hq issue 686: a builder ran a branch's store tests for the first time in its pull request's merge check.
|
||||
{Name: "try", Description: "Try a pushed branch or commit before its pull request (novox/hq issue 686): the " +
|
||||
"checker (mesh-delivery-checker) merges the head onto the repository's trunk and runs its levels on the merge, " +
|
||||
"as it judges a pull request (ADR 0503 §3), its levels asks on the build seats (ADR 0525 §1); no merge-check.sh is run. Answers at " +
|
||||
"once with an id; asked again with that id it waits up to 20 s and answers pass, fail or error, how long it " +
|
||||
"took and the lines naming what failed — or that it is still waiting or running, to ask again. Sets no " +
|
||||
"status on the forge, registers and builds nothing for delivery. One try of a commit at a time — a second " +
|
||||
"ask answers the first's id. Only for a repository of the mesh; the ref must be on the forge.",
|
||||
Input: schema(map[string]string{
|
||||
"repository": "the repository on the forge, owner/name; with ref",
|
||||
"ref": "the branch or commit to try, as pushed to the forge",
|
||||
"base": "the branch it is merged into (the repository's default branch when none is named)",
|
||||
"tests": "only the tests this `go test -run` pattern names",
|
||||
"store-only": "\"true\": only the integration level",
|
||||
"id": "instead of a repository: the id a try answered, to read what came of it",
|
||||
}, nil, "store-only"),
|
||||
Replaces: []string{"sh merge-check.sh", "docker run postgres"}},
|
||||
{Name: "kill", Description: "Removed in R4g (ADR 0525 §9): refused by name, naming its replacement: a build seat's ask ends where it runs", Input: schema(nil, nil)},
|
||||
{Name: "pause", Description: "Removed in R4g (ADR 0525 §9): refused by name, naming its replacement: <node>/node-build-<language>.pause", Input: schema(nil, nil)},
|
||||
{Name: "resume", Description: "Removed in R4g (ADR 0525 §9): refused by name, naming its replacement: <node>/node-build-<language>.resume", Input: schema(nil, nil)},
|
||||
// Acts done by hand, and what the bounds are set from (novox/hq to-be 45 §7, Phase 0).
|
||||
{Name: "hand-act", Description: "Record an act done by hand outside the mesh — a container restarted, a file " +
|
||||
"edited, a service started on a machine — with why and its cause, in the hand-act log beside the pushes and " +
|
||||
"plans ended by hand (novox/hq to-be 45 §7). A cause recorded twice in a fortnight is a healer wanted.",
|
||||
Input: schema(map[string]string{
|
||||
"what": "what was done, in a line",
|
||||
"why": "why it had to be done by hand",
|
||||
"cause": "the cause in a word, or a condition's kind — the word a second act for the same reason uses",
|
||||
"condition": "the key of the condition it addressed, if any (optional)",
|
||||
}, []string{"what", "why", "cause"})},
|
||||
{Name: "drill", Description: "Record a drill — something broken on purpose to see the mesh raise and clear it: " +
|
||||
"a module stopped, a process killed — with what it tests, in the hand-act log. A drill is a person's " +
|
||||
"deliberate test, never a repair: no healer is wanted for it however often it is run (S15).",
|
||||
Input: schema(map[string]string{
|
||||
"what": "what was done on purpose, in a line",
|
||||
"why": "what the drill tests",
|
||||
"condition": "the key of the condition the drill is meant to raise, if any (optional)",
|
||||
}, []string{"what", "why"})},
|
||||
{Name: "warranted", Description: "Record in the hand-act log what a module did on the operator's warrant (novox/hq " +
|
||||
"ADR 0274, ADR 0259): who chose, through which channel, with which proofs and which answer are read from the " +
|
||||
"router's own record of that module's ask, never from the caller; recorded once per ask however often it is " +
|
||||
"asked; the caller names the ask and says nothing else. Refused for an ask still open, ended without a choice, " +
|
||||
"or not that module's.",
|
||||
Input: schema(map[string]string{
|
||||
"asker": "the module that asked, e.g. claude-code",
|
||||
"ask": "its ask's id",
|
||||
}, []string{"asker", "ask"})},
|
||||
{Name: "hand-acts", Description: "What was done by hand lately — pushes, plans ended, consumers re-made, acts " +
|
||||
"recorded — who, why and the cause of each, and which causes repeat: each repeat is a healer the mesh lacks.",
|
||||
Input: schema(map[string]string{"days": "how many days back (default 14)"}, nil)},
|
||||
{Name: "durations", Description: "How long things take, as the controller measured them: a send to its machine's " +
|
||||
"report (apply), a plan's tier, a build, and each phase of an " +
|
||||
"ended walk per class (walk-phase, novox/hq ADR 0282) — per machine, repository, module or class/phase, with " +
|
||||
"median, p90 and max. What the core's bounds are set from (novox/hq to-be 45 Phase 0).",
|
||||
Input: schema(map[string]string{
|
||||
"kind": "one kind: apply, plan-tier, build or walk-phase; every kind when absent",
|
||||
"days": "how many days back (default 14)",
|
||||
}, nil)},
|
||||
// What is wrong, and the self-check (novox/hq to-be 45 §2, §4).
|
||||
{Name: "conditions", Description: "What is wrong with the mesh now: every open condition, urgent first, " +
|
||||
"then oldest — raised by the watchdogs of the signals table, the self-check's probes and the providers' " +
|
||||
"own words, and cleared when observation says it is resolved, never by hand. Given a key, that one " +
|
||||
"whole with its evidence; with history, every transition lately; with silence, stop one's messages " +
|
||||
"for a while — a hand act, which says why (novox/hq to-be 45 §2).",
|
||||
Input: schema(map[string]string{
|
||||
"key": "a condition's key: that one whole; with history, only its transitions",
|
||||
"scope": "only this scope: machine, plan, call, build, merge, provider, seat, bus, core, probe or mesh",
|
||||
"severity": "only urgent, or only warning",
|
||||
"machine": "only those about this machine",
|
||||
"history": "\"true\": every raising, change, silence and clearing lately, oldest first",
|
||||
"days": "with history: how many days back (default 7, at most 90)",
|
||||
"silence": "a condition's key: send no message for it for a while; it stays open and in status",
|
||||
"for": "with silence: how long — 30m, 4h, 2d; at most 7d",
|
||||
"why": "with silence: why — required, and recorded in the hand-act log",
|
||||
"cause": "with silence: the cause in a word (the condition's kind when absent)",
|
||||
}, nil, "history")},
|
||||
// What the healers did (novox/hq to-be 45 §7).
|
||||
{Name: "healers", Description: "The healers (novox/hq to-be 45 §7): each registered response to one kind " +
|
||||
"of condition — its repair (the ordinary path again), its budget, what happens when it is spent — every act " +
|
||||
"they took lately with its outcome, and the mesh-wide brake. A heal is never a hand act; whether it " +
|
||||
"repaired anything is the condition's clearing to say. Each act is also in its condition's tried.",
|
||||
Input: schema(map[string]string{"days": "how many days of acts back (default 7)"}, nil)},
|
||||
{Name: "doctor", Description: "The self-check (novox/hq to-be 45 §4): the last run's verdict at once — " +
|
||||
"each probe of the design's live invariants passed, failed or could not run, and how long ago. With " +
|
||||
"run, a run now; with probes, the registry; with signals, every row of the signals table and the age " +
|
||||
"of its newest signal. Runs every five minutes on its own; each failure is an open condition.",
|
||||
Input: schema(map[string]string{
|
||||
"run": "\"true\": run every probe now and answer the verdict",
|
||||
"probes": "\"true\": the registry — what each probe asserts, and the condition it raises",
|
||||
"signals": "\"true\": the signals table, each row with the age of its newest signal",
|
||||
"probe": "one probe's id (D14): its verdict alone, with every finding — the verdict shows at most twenty a probe",
|
||||
}, nil, "run", "probes", "signals")},
|
||||
// How a module's new builds reach its machines, and the bus's planned step (novox/hq ADR 0236).
|
||||
{Name: "upgrade", Description: "Whether each module's new builds wait for a person (novox/hq ADR 0236, ADR 0539 clause 14): " +
|
||||
"for a module of policy record, the assigner writes no want naming a composition judged to recreate a container or move " +
|
||||
"data until the operator answers its ask; with where the policy comes from (a person, the module, the bus, its " +
|
||||
"irreplaceable data, the default) and why. With module, that one; with policy, a person's choice for it — roll-out, " +
|
||||
"record (with why) or default to take the choice back. The bus is never moved by the assigner (ADR 0539 clause 15).",
|
||||
Input: schema(map[string]string{
|
||||
"module": "one module",
|
||||
"policy": "with module: roll-out, record or default",
|
||||
"why": "with policy: why — required for record, kept",
|
||||
}, nil)},
|
||||
{Name: "bus", Description: "The bus as a person's step (novox/hq to-be 45 §8, ADR 0236, ADR 0539 clause 15): what a bus " +
|
||||
"upgrade would do — the bus's build on each machine against the one the mesh holds — and how the last step went. " +
|
||||
"With upgrade, check one: a person's act with why, after the streams are snapshotted (snapshot-taken says where, " +
|
||||
"while the mesh takes none itself), saying first whether it can be reverted; then it answers the want the operator " +
|
||||
"writes with the operator's credential, want.<node>.<bus module>, recorded — nothing is sent, and the assigner never " +
|
||||
"moves the bus.",
|
||||
Input: schema(map[string]string{
|
||||
"upgrade": "\"true\": start the bus's upgrade",
|
||||
"why": "with upgrade: why — required, recorded in the hand-act log",
|
||||
"cause": "with upgrade: the cause in a word (default bus-upgrade)",
|
||||
"reversible": "\"true\": with upgrade, the new version can be undone by putting the old one back",
|
||||
"irreversible": "\"true\": with upgrade, it cannot — your explicit word that it runs anyway",
|
||||
"snapshot-taken": "with upgrade: where the streams' snapshot you took is",
|
||||
}, nil, "upgrade", "reversible", "irreversible")},
|
||||
// A consumer the mesh stopped asking for: retired, waiting for a person, deleted only by one
|
||||
// (novox/hq ADR 0230).
|
||||
{Name: "retire", Description: "A consumer the mesh stops asking for is retired by its provider — access " +
|
||||
"disabled, data kept — after the same answer in five passes; more than three at once, or more than half " +
|
||||
"of those held, waits for a person. With no answer, every provider that waits and what it would retire. " +
|
||||
"With answer approve or reject, a node and a module: retire exactly what that provider waits with, or " +
|
||||
"keep it active — a hand act, which says why (novox/hq ADR 0230).",
|
||||
Input: schema(map[string]string{
|
||||
"answer": "approve or reject: answer what the provider waits with (needs node, module and why)",
|
||||
"node": "with answer: the machine the provider runs on",
|
||||
"module": "with answer: the provider module",
|
||||
"why": "with answer: why — required, and recorded in the hand-act log",
|
||||
"cause": "with answer: the cause in a word (retire-waiting when absent)",
|
||||
}, nil)},
|
||||
{Name: "cleanup", Description: "Every consumer a provider holds retired — its age, its size where the " +
|
||||
"backend knows, and why it was retired — and every module's own data retired on its machine (ADR 0233). " +
|
||||
"With consumer (and node, module): the provider deletes that one retired consumer — never an active one; " +
|
||||
"for a module's own retired item, consumer names the item and the machine's backup holder takes a last " +
|
||||
"restore point of it, then deletes it. With older-than: every retired consumer older than that many " +
|
||||
"days, listed; deleted only with confirm. Deleting is a hand act, which says why (novox/hq ADR 0230).",
|
||||
Input: schema(map[string]string{
|
||||
"node": "with consumer: the machine the provider runs on",
|
||||
"module": "with consumer: the provider module",
|
||||
"consumer": "delete this retired consumer (needs node, module and why)",
|
||||
"older-than": "delete every consumer retired more than this many days (needs why; lists only without confirm)",
|
||||
"confirm": "\"true\": with older-than, delete what is listed",
|
||||
"why": "with consumer or older-than: why — required, and recorded in the hand-act log",
|
||||
"cause": "with consumer or older-than: the cause in a word (cleanup-waiting when absent)",
|
||||
}, nil, "confirm")},
|
||||
{Name: "judge-directories", Description: "Read-only: which of the directories one machine is declared its " +
|
||||
"node-engine's walk that follows no link another account could make would refuse (novox/hq issue 358), one line " +
|
||||
"each: placed, would be refused and why (the link and who could replace it), refused already by the placement " +
|
||||
"guard, or below a home. The engine judges the declaration it keeps and makes, chmods and chowns nothing.",
|
||||
Input: schema(map[string]string{
|
||||
"node": "the machine's name",
|
||||
}, []string{"node"})},
|
||||
// What a consumer gave up on (novox/hq issue 330): kept in DEAD_LETTERS until a person acts on it.
|
||||
{Name: "root-free", Description: "Whether each machine named is root-free now (novox/hq ADR 0259 §8): no agent " +
|
||||
"there can become root without a person. Judged when asked, never from a condition: free only when the machine " +
|
||||
"names an account its agents run as, its node-engine judged that account unable to become root within the " +
|
||||
"last 15 minutes, and the login shell's execute is not served there. Anything else, a read that failed " +
|
||||
"included, is not free and says why. The router asks it before an answer from a channel proving its sender " +
|
||||
"may approve. Only reads.",
|
||||
Input: listed(schema(map[string]string{
|
||||
"machines": "the machines to judge, by name: a list, or one text separated by commas",
|
||||
}, []string{"machines"}), "machines")},
|
||||
{Name: "dead-letters", Description: "Every message a consumer on the bus gave up on after handing it over " +
|
||||
"as often as it may, kept in DEAD_LETTERS: whose consumer, the subject, how often it was handed over and " +
|
||||
"when it was given up, newest first. With id: that one whole, with what it said. With deliver: hand it " +
|
||||
"again to the consumer that gave it up, and nobody else; with drop: let it go for good. Delivering and " +
|
||||
"dropping are hand acts, which say why (novox/hq issue 330).",
|
||||
Input: schema(map[string]string{
|
||||
"id": "a dead letter's id, as the list gives it: that one whole, with its message",
|
||||
"consumer": "a consumer's name, or <stream>.<consumer>: only what that one gave up on",
|
||||
"limit": "how many to list, newest first (default 50); only when listing",
|
||||
"deliver": "a dead letter's id: deliver it again to the consumer that gave it up (needs why)",
|
||||
"drop": "a dead letter's id: drop it for good (needs why)",
|
||||
"why": "with deliver or drop: why — required, and recorded in the hand-act log",
|
||||
"cause": "with deliver or drop: the cause in a word (dead-letter when absent)",
|
||||
}, nil)},
|
||||
// The data every machine declares (novox/hq ADR 0233).
|
||||
{Name: "data", Description: "Every item of data every machine declares, as the self-check last measured it: " +
|
||||
"its class (irreplaceable, rebuildable, cache), where it is, its size, its newest write, its newest good " +
|
||||
"backup and the bound on it — and what is retired: kept after its module left the machine, removed only by " +
|
||||
"`cleanup delete` (novox/hq ADR 0233).",
|
||||
Input: schema(map[string]string{
|
||||
"machine": "one machine (optional)",
|
||||
"retired": "\"true\": only what is retired",
|
||||
}, nil, "retired")},
|
||||
{Name: "build", Description: "Removed in R4e (ADR 0501 §5 / ADR 0503 §3): refused by name, naming its replacement",
|
||||
Input: schema(nil, nil)},
|
||||
// What the records say the registries may keep, asked on demand (novox/hq ADR 0251, to-be 51).
|
||||
{Name: "artifacts", Description: "Every artifact the mesh recorded making that lives in its artifact store, " +
|
||||
"each with its repository, digest, kind (image or archive) and state: kept — and why: a definition names " +
|
||||
"it, or one of the five most recent builds of a module the mesh holds — or eligible: made, kept for no " +
|
||||
"reason, not yet let go. Collected ones are counted, and listed only with collected. Reads only " +
|
||||
"(novox/hq ADR 0251).",
|
||||
Input: schema(map[string]string{
|
||||
"repository": "only this repository's references, as <module>/<artifact> (the counts stay whole)",
|
||||
"collected": "\"true\": list the collected references too (they only grow; counted always)",
|
||||
}, nil, "collected")},
|
||||
// Whether the artifact store holds what a builder's done key names, before the builder reuses it (novox/hq issue
|
||||
// 1937): the store's collector deletes a blob nothing holds, and a key the controller never took in holds nothing.
|
||||
{Name: "held", Description: "Whether the artifact store holds each reference given, asked with one HEAD each: " +
|
||||
"held, gone (the store answered 404) or unknown with why. The builder asks it before it reuses a done key, and " +
|
||||
"builds a key whose artifact is gone again. Reads only: nothing is held or written (novox/hq issue 1937).",
|
||||
Input: listed(schema(map[string]string{
|
||||
"references": "the artifacts' references, artifact-store://<module>/<artifact>/blobs/sha256:<hex>: a list, " +
|
||||
"or one text separated by commas",
|
||||
}, []string{"references"}), "references")},
|
||||
{Name: "collect", Description: "The artifact store's sweep, now: what the mesh made and keeps for no reason " +
|
||||
"is let go of. Without confirm a dry run — every kept archive asked about with HEADs, what would be let go " +
|
||||
"listed, nothing held or deleted. With confirm and why: every kept archive held first, then each eligible " +
|
||||
"artifact let go of, oldest first, and recorded collected — a hand act, recorded with its why. A confirmed " +
|
||||
"collect lets an index go with the platform manifests no kept index names (read first; any read that " +
|
||||
"fails lets nothing go); the sweep after a build leaves an eligible index for such a collect (novox/hq ADR " +
|
||||
"0257). Bounded by " +
|
||||
"most (500 by default, at most 5000) and 45 seconds; what is left is said. Bytes are reclaimed by the " +
|
||||
"store's nightly collector. Never touches a digest the mesh did not record (novox/hq ADR 0189, ADR 0251).",
|
||||
Input: schema(map[string]string{
|
||||
"confirm": "\"true\": let go of what is eligible (needs why); without it nothing is done",
|
||||
"why": "with confirm: why — required, and recorded in the hand-act log",
|
||||
"most": "let go of at most this many (500 by default, at most 5000)",
|
||||
}, nil, "confirm")},
|
||||
{Name: "move-placement", Description: "The move of placement out of the settings layers, run once after the " +
|
||||
"delivery that holds it (novox/hq ADR 0348 clause 11, issue 736): every placement key a settings layer still " +
|
||||
"holds — endpoints, expose, reach and ports, which a layer set before then keeps among its settings — is put " +
|
||||
"beside the assignment, where places, accesses and networks already are. Without confirm it says which layers " +
|
||||
"hold which keys and moves nothing. With confirm and why: in one transaction, each such layer is kept whole " +
|
||||
"in its history first (why: moved to placement), its keys are laid over the placement beside it, and the " +
|
||||
"layer keeps its other keys, when it was set and who set it — and the act is recorded in the hand-act log. " +
|
||||
"Answers each layer moved: its machine (empty for the whole mesh), its module and its keys. Nothing a " +
|
||||
"machine is told changes: composition reads the keys as placement wherever they are. Asked again, it " +
|
||||
"moves nothing.",
|
||||
Input: schema(map[string]string{
|
||||
"confirm": "\"true\": move them (needs why); without it nothing is moved",
|
||||
"why": "with confirm: why — required, and recorded in the hand-act log",
|
||||
}, nil, "confirm")},
|
||||
{Name: "move-config", Description: "The move of config: every config value a settings layer holds is set in " +
|
||||
"mesh-config, run once after the delivery that holds it and after move-placement (novox/hq ADR 0348 §9, " +
|
||||
"issue 664). A machine's layer becomes the module's own value on that machine; a whole-mesh layer becomes " +
|
||||
"mesh config for the predefined key its value falls back to where that module alone falls back to it, and " +
|
||||
"otherwise the module's own value on each machine it is assigned to, laid under that machine's layer; a key " +
|
||||
"a layer says by its file's name is set under its config key. Without confirm it says each value it would " +
|
||||
"set, where from, each layer that stays (its module is in the settings form) and anything that refuses the " +
|
||||
"move, and sets nothing. With confirm and why: each value is set through mesh-config in the controller's " +
|
||||
"name, every module's resolved values are read back and compared with what its layers lay today, and only " +
|
||||
"then, in one transaction, is each layer moved kept whole in its history first (why: moved to mesh-config) " +
|
||||
"and emptied, and the controller switched to read mesh-config where it read the layers — the act recorded " +
|
||||
"in the hand-act log. Nothing a machine is told changes. Asked again, it sets nothing set already.",
|
||||
Input: schema(map[string]string{
|
||||
"confirm": "\"true\": set the values and switch (needs why); without it nothing is set",
|
||||
"why": "with confirm: why — required, and recorded in the hand-act log and with each value",
|
||||
}, nil, "confirm")},
|
||||
{Name: "mirrors", Description: "The bases the mesh copied into its artifact store for builds to stand on, as " +
|
||||
"the records hold them: each with its state — kept because a kept build stood on it, or eligible — and " +
|
||||
"which build or person recorded it. With record: references of copies no record names (a copy made " +
|
||||
"before copies were recorded, or by a build that failed before then) to record as the mesh's, so the " +
|
||||
"sweep keeps or lets go of them like any other; only a repository the mirror writes is taken " +
|
||||
"(upstream/<host>/<path>, or a module's <module>/on-<argument>), and each must be in the store. Without " +
|
||||
"confirm a dry run; with confirm and why, recorded — a hand act. A recorded copy is eligible: the next " +
|
||||
"sweep lets it go unless a kept build stood on it. Records only: this verb deletes nothing " +
|
||||
"(novox/hq ADR 0257).",
|
||||
Input: schema(map[string]string{
|
||||
"record": "references to record, artifact-store://<repository>@sha256:<hex>, separated by spaces or commas",
|
||||
"confirm": "\"true\": record them (needs why); without it nothing is recorded",
|
||||
"why": "with confirm: why — required, and recorded with each copy and in the hand-act log",
|
||||
}, nil, "confirm")},
|
||||
{Name: "images", Description: "Every container image one machine's declaration names — the declaration the " +
|
||||
"mesh would send it now and the one it was last sent — each with the resources naming it: what the " +
|
||||
"machine keeps when it prunes its images. sent_known is false when the last one sent is not kept with " +
|
||||
"its images. Reads only (novox/hq ADR 0251).",
|
||||
Input: schema(map[string]string{
|
||||
"node": "the machine's name",
|
||||
}, []string{"node"})},
|
||||
}
|
||||
|
||||
// schema is a JSON schema for an object of string properties, which is every argument the verbs
|
||||
// above take. Kept small on purpose: a schema an agent cannot read is a tool it cannot call. The
|
||||
// switches are the properties that are "true" or "false" and nothing else, said in the schema as an
|
||||
@@ -596,6 +83,14 @@ var ControllerVerbs = []Verb{
|
||||
//
|
||||
// **The schema is the whole of what a verb takes** (novox/hq issue 244): an argument it does not
|
||||
// name is refused when the verb is called, never passed over.
|
||||
// withObject adds to a verb's input s the property name, an object of the string fields given, each required.
|
||||
func withObject(s map[string]any, name, description string, fields map[string]string) map[string]any {
|
||||
inner := schema(fields, slices.Sorted(maps.Keys(fields)))
|
||||
inner["description"], inner["additionalProperties"] = description, false
|
||||
s["properties"].(map[string]any)[name] = inner
|
||||
return s
|
||||
}
|
||||
|
||||
func schema(properties map[string]string, required []string, switches ...string) map[string]any {
|
||||
isSwitch := map[string]bool{}
|
||||
for _, s := range switches {
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 1887/A9b: the build queue's verbs are refused by name, naming what replaced them, and the verbs R4e removed are gone.
|
||||
func TestTheBuildQueueVerbsAreRefusedByName(t *testing.T) {
|
||||
rows := map[string]Verb{}
|
||||
for _, v := range ControllerVerbs {
|
||||
rows[v.Name] = v
|
||||
}
|
||||
for _, gone := range []string{"rebuild", "forget-checks"} {
|
||||
if _, ok := rows[gone]; ok {
|
||||
t.Errorf("the controller still lists %s", gone)
|
||||
}
|
||||
}
|
||||
for _, refused := range []string{"queue", "cancel", "clear", "kill", "pause", "resume", "replay"} {
|
||||
v, ok := rows[refused]
|
||||
if !ok || !strings.HasPrefix(v.Description, "Removed in R4g (ADR 0525 §9): refused by name") {
|
||||
t.Errorf("%s = %q, want refused by name", refused, v.Description)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(rows["queue"].Description, "delivery view") || !strings.Contains(rows["pause"].Description, "node-build-") {
|
||||
t.Errorf("queue and pause do not name their replacements: %q, %q", rows["queue"].Description, rows["pause"].Description)
|
||||
}
|
||||
if strings.Contains(rows["try"].Description, "build-agent") {
|
||||
t.Errorf("try still names build-agent: %q", rows["try"].Description)
|
||||
}
|
||||
}
|
||||
@@ -5,58 +5,6 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 1943/AC4: the controller's seat no longer offers a verb that starts, stops, retries, follows or reads a delivery
|
||||
// plan, nor a push: each is a row with no input whose description refuses it by name and names its replacement, and
|
||||
// each is in the removed list the holder answers from. upgrade keeps its policy and drops the plan's backlog.
|
||||
func TestTheControllerSeatHasNoPlanVerbs(t *testing.T) {
|
||||
rows := map[string]Verb{}
|
||||
for _, v := range ControllerVerbs {
|
||||
rows[v.Name] = v
|
||||
}
|
||||
gone := []string{"plans", "plan", "push", "deliver", "delivery-stop", "delivery-backups-again", "delivery-walks", "delivery-order"}
|
||||
for _, name := range gone {
|
||||
v, ok := rows[name]
|
||||
if !ok {
|
||||
t.Errorf("%s is not in the table, so a caller is not answered by name", name)
|
||||
continue
|
||||
}
|
||||
replacement, removed := RemovedControllerVerbs[name]
|
||||
if !removed || replacement == "" {
|
||||
t.Errorf("%s is not in the removed list with its replacement", name)
|
||||
}
|
||||
if !strings.HasPrefix(v.Description, "Removed in R6 (ADR 0539): refused by name") || !strings.Contains(v.Description, replacement) {
|
||||
t.Errorf("%s = %q, want refused by name, naming %q", name, v.Description, replacement)
|
||||
}
|
||||
if props, _ := v.Input["properties"].(map[string]any); len(props) > 0 {
|
||||
t.Errorf("%s still takes %v", name, props)
|
||||
}
|
||||
}
|
||||
props, _ := rows["upgrade"].Input["properties"].(map[string]any)
|
||||
for _, arg := range []string{"together", "backlog", "release-backlog", "cause"} {
|
||||
if _, has := props[arg]; has {
|
||||
t.Errorf("upgrade still takes %s, a plan's", arg)
|
||||
}
|
||||
}
|
||||
for _, arg := range []string{"module", "policy", "why"} {
|
||||
if _, has := props[arg]; !has {
|
||||
t.Errorf("upgrade no longer takes %s", arg)
|
||||
}
|
||||
}
|
||||
for _, word := range []string{"rolled", "gate", "backlog", "push"} {
|
||||
if strings.Contains(rows["upgrade"].Description, word) {
|
||||
t.Errorf("upgrade's description still speaks of %q: %q", word, rows["upgrade"].Description)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(rows["bus"].Description, "want") {
|
||||
t.Errorf("bus does not say the operator writes the bus's want: %q", rows["bus"].Description)
|
||||
}
|
||||
for name := range RemovedControllerVerbs {
|
||||
if v := rows[name]; !strings.HasPrefix(v.Description, "Removed in R6") {
|
||||
t.Errorf("%s is in the removed list and its row says %q", name, v.Description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1943/AC4: the delivery seat keeps the merge's verbs and no longer offers times, what-if, release, stop or
|
||||
// retire-history: each is a row with no input, optional so the holder of before and the holder of release 6 both hold
|
||||
// the seat, refusing it by name; mark-ready, served by nobody since issue 1022, is gone.
|
||||
@@ -101,15 +49,3 @@ func TestTheDeliverySeatHasNoPlanVerbs(t *testing.T) {
|
||||
t.Errorf("the gate of release 5 cannot hold the seat: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1943 (for 1945/AC14): the controller's seat says a bucket it deleted at its first start as its event, bucket-deleted
|
||||
// (ADR 0539 clause 13).
|
||||
func TestTheControllerSaysABucketItDeleted(t *testing.T) {
|
||||
seat, ok := SeatNamed(ControllerSeatName)
|
||||
if !ok {
|
||||
t.Fatal("no controller seat")
|
||||
}
|
||||
if !SeatSays(seat.Emits, "bucket-deleted") {
|
||||
t.Errorf("the controller's seat does not emit bucket-deleted: %v", seat.Emits)
|
||||
}
|
||||
}
|
||||
|
||||
+25
-18
@@ -1,6 +1,7 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -25,6 +26,30 @@ func TestAServedVerbIsANameOrADefinition(t *testing.T) {
|
||||
|
||||
// Holding a mesh seat that serves verbs requires serving them, and the refusal names the verbs.
|
||||
func TestHoldingAMeshSeatRequiresServingItsVerbs(t *testing.T) {
|
||||
// 774/S34/AC1: the seat mesh-enrolment is held by a module serving its six verbs, and a seventh is refused.
|
||||
enrolment, ok := SeatNamed(EnrolmentSeat)
|
||||
if !ok || enrolment.Scope != ScopeMesh || len(enrolment.Emits) != 5 {
|
||||
t.Fatalf("mesh-enrolment: in the set %v, %+v", ok, enrolment)
|
||||
}
|
||||
six := []string{"issue", "redeem", "confirm", "revoke", "tokens", "again"}
|
||||
// The tunnel key is given at issue (the operator, 2026-11-02 01:05).
|
||||
if issue := enrolment.Serves[0]; issue.Name != "issue" || !slices.Contains(issue.Input["required"].([]string), "overlay_key") {
|
||||
t.Errorf("issue requires %v", issue.Input["required"])
|
||||
}
|
||||
holder := func(serves ...string) Manifest {
|
||||
return Manifest{Module: "mesh-enrolment", Tools: serves,
|
||||
Claims: []Claim{{Name: EnrolmentSeat, Scope: ScopeMesh, Serves: serves}}}
|
||||
}
|
||||
if err := CanHold(holder(six...), enrolment); err != nil {
|
||||
t.Errorf("the holder serving the six verbs was refused: %v", err)
|
||||
}
|
||||
if err := CanHold(holder(six[:5]...), enrolment); err == nil || !strings.Contains(err.Error(), "does not serve again") {
|
||||
t.Errorf("a holder missing again: %v", err)
|
||||
}
|
||||
if err := CanHold(holder(append(six, "mint")...), enrolment); err == nil || !strings.Contains(err.Error(), "mint") {
|
||||
t.Errorf("a holder saying it serves mint: %v", err)
|
||||
}
|
||||
|
||||
was := Seats()
|
||||
t.Cleanup(func() { UseSeats(was) })
|
||||
UseSeats([]Seat{{Name: "mesh-controller", Scope: ScopeMesh, Decision: "test",
|
||||
@@ -43,21 +68,3 @@ func TestHoldingAMeshSeatRequiresServingItsVerbs(t *testing.T) {
|
||||
t.Fatalf("a holder serving every verb was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The mesh's own verbs are declared in full: an agent cannot call a name without a schema.
|
||||
func TestEveryControllerVerbIsDescribedWithASchema(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for _, v := range ControllerVerbs {
|
||||
if v.Description == "" || v.Input == nil || v.Input["type"] != "object" {
|
||||
t.Errorf("%s: no description or no object schema", v.Name)
|
||||
}
|
||||
if seen[v.Name] {
|
||||
t.Errorf("%s declared twice", v.Name)
|
||||
}
|
||||
seen[v.Name] = true
|
||||
}
|
||||
seat, _ := SeatNamed(ControllerSeatName)
|
||||
if len(seat.Serves) != len(ControllerVerbs) {
|
||||
t.Fatalf("the compiled mesh-controller seat serves %d verbs, the table has %d", len(seat.Serves), len(ControllerVerbs))
|
||||
}
|
||||
}
|
||||
|
||||
-201
@@ -1,201 +0,0 @@
|
||||
// Package cli is the local protocol between `mesh-cli` and the node-engine on its machine (novox/hq ADR 0272).
|
||||
//
|
||||
// `mesh-cli <command line>` asks the controller to run one of its command lines. It holds no credential and
|
||||
// reads no file of the mesh: it connects to the node-engine's socket, writes one Request, and reads one Answer.
|
||||
// The node-engine reads the connecting process's account from the kernel and asks the controller over its own
|
||||
// link; that request is the node-engine's and the controller's, and lives in neither this package nor any SDK
|
||||
// (ADR 0039 as ADR 0188 reads it: the transport lives in the runtime).
|
||||
//
|
||||
// One request and one answer per connection, each one line of JSON. Standard library only.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Socket is where the node-engine listens for mesh-cli: a file under its runtime directory, with no network
|
||||
// address (ADR 0272 §3).
|
||||
const Socket = "/run/nox-mesh-host/cli.sock"
|
||||
|
||||
// MaxStdin bounds the standard input mesh-cli carries for a line: a secret, a key, a settings file — kilobytes. More
|
||||
// is refused by mesh-cli before anything is asked, and by the node-engine and the controller again.
|
||||
const MaxStdin = 64 << 10
|
||||
|
||||
// MaxRequest bounds what the node-engine reads of a request: a command line of a few hundred bytes, settings given
|
||||
// inline of a few kilobytes, and standard input of at most MaxStdin, which JSON writes as base64 (4 bytes for every 3).
|
||||
const MaxRequest = 64<<10 + MaxStdin*4/3 + 4
|
||||
|
||||
// MaxAnswer bounds what mesh-cli reads of an answer. The controller cuts what it runs to fit one bus message, and
|
||||
// says so; this is the local bound above that.
|
||||
const MaxAnswer = 16 << 20
|
||||
|
||||
// AskWithin is how long mesh-cli waits for its answer: the controller's own bound on a call (five minutes),
|
||||
// and a minute for the link on either side.
|
||||
const AskWithin = 6 * time.Minute
|
||||
|
||||
// Request is what mesh-cli asks: the controller's command line, as words, and what mesh-cli's own standard input
|
||||
// held when it was not a terminal.
|
||||
//
|
||||
// **Stdin is for the controller's terminal alone** (novox/hq ADR 0259 §10, ADR 0272): a secret given with
|
||||
// `secret accept … --from -`, so it never stands on a command line. The controller gives it to a line that runs as
|
||||
// its terminal and refuses any other line that carries it; nobody keeps it — not the node-engine, not the
|
||||
// controller's record of calls, not its journal.
|
||||
type Request struct {
|
||||
Line []string `json:"line"`
|
||||
Stdin []byte `json:"stdin,omitempty"`
|
||||
}
|
||||
|
||||
// Answer is what comes back: what the command printed, how it exited, and whether it ran as the controller's
|
||||
// terminal and why. Refused is set when nothing ran at all, and says why; Exit is then 1.
|
||||
type Answer struct {
|
||||
Stdout []byte `json:"stdout,omitempty"`
|
||||
Stderr []byte `json:"stderr,omitempty"`
|
||||
Exit int `json:"exit"`
|
||||
Terminal bool `json:"terminal"`
|
||||
// Why says whether the line ran as the terminal, and why or why not, in the controller's words.
|
||||
Why string `json:"why,omitempty"`
|
||||
// Refused says why nothing ran: the account, the node, the controller unreachable.
|
||||
Refused string `json:"refused,omitempty"`
|
||||
// Cut says the output was longer than one bus message and was cut there.
|
||||
Cut bool `json:"cut,omitempty"`
|
||||
// Unknown says the line was asked and its outcome is not known: no answer came in time, and it may have
|
||||
// run. Never said as a refusal, which means nothing ran.
|
||||
Unknown string `json:"unknown,omitempty"`
|
||||
}
|
||||
|
||||
// Refusal is an answer saying that nothing ran, and why.
|
||||
func Refusal(why string) Answer { return Answer{Exit: 1, Refused: why} }
|
||||
|
||||
// NotKnown is an answer saying the line was asked and what came of it is not known: it may have run. call is
|
||||
// the controller's call the line runs as, when it is known, which `calls` answers.
|
||||
func NotKnown(call, why string) Answer {
|
||||
where := "the controller's `calls` lists what came of each line"
|
||||
if call != "" {
|
||||
where = "see the call " + call + " (`calls " + call + "`)"
|
||||
}
|
||||
return Answer{Exit: 1, Unknown: why + "; the line may have run — " + where}
|
||||
}
|
||||
|
||||
// ErrTooLong is a request or an answer over its bound.
|
||||
var ErrTooLong = errors.New("longer than the protocol allows")
|
||||
|
||||
// WriteRequest writes one request, as one line.
|
||||
func WriteRequest(w io.Writer, r Request) error { return writeLine(w, r) }
|
||||
|
||||
// ReadRequest reads one request, refusing one over MaxRequest or one that names no command.
|
||||
func ReadRequest(r io.Reader) (Request, error) {
|
||||
var req Request
|
||||
if err := readLine(r, MaxRequest, &req); err != nil {
|
||||
return Request{}, err
|
||||
}
|
||||
if len(req.Line) == 0 {
|
||||
return Request{}, errors.New("the request names no command")
|
||||
}
|
||||
if len(req.Stdin) > MaxStdin {
|
||||
return Request{}, fmt.Errorf("%w: standard input of more than %d bytes", ErrTooLong, MaxStdin)
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// WriteAnswer writes one answer, as one line.
|
||||
func WriteAnswer(w io.Writer, a Answer) error { return writeLine(w, a) }
|
||||
|
||||
// ReadAnswer reads one answer, refusing one over MaxAnswer.
|
||||
func ReadAnswer(r io.Reader) (Answer, error) {
|
||||
var a Answer
|
||||
err := readLine(r, MaxAnswer, &a)
|
||||
return a, err
|
||||
}
|
||||
|
||||
// Ask is mesh-cli's side: connect to the socket, ask, and wait for the answer within AskWithin or ctx.
|
||||
func Ask(ctx context.Context, socket string, line []string) (Answer, error) {
|
||||
return AskWith(ctx, socket, Request{Line: line})
|
||||
}
|
||||
|
||||
// AskWith asks a whole request: a line, and the standard input it carries, at most MaxStdin.
|
||||
func AskWith(ctx context.Context, socket string, req Request) (Answer, error) {
|
||||
line := req.Line
|
||||
if len(line) == 0 {
|
||||
return Answer{}, errors.New("no command given")
|
||||
}
|
||||
if len(req.Stdin) > MaxStdin {
|
||||
return Answer{}, fmt.Errorf("standard input is more than %d bytes, which mesh-cli does not carry; nothing was asked", MaxStdin)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, AskWithin)
|
||||
defer cancel()
|
||||
var d net.Dialer
|
||||
conn, err := d.DialContext(ctx, "unix", socket)
|
||||
if err != nil {
|
||||
return Answer{}, fmt.Errorf("the node-engine on this machine does not answer at %s: %w", socket, err)
|
||||
}
|
||||
defer conn.Close()
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
_ = conn.SetDeadline(deadline)
|
||||
}
|
||||
if err := WriteRequest(conn, req); err != nil {
|
||||
// The engine may have answered before it read the request — a refusal (busy, an unknown account) written
|
||||
// and the connection closed — and the write then fails on the close. That answer is already waiting, and
|
||||
// it says why where the write error says only that the pipe broke (novox/hq issue 444, found under issue
|
||||
// 434). So read it, within the deadline the connection already has, and only when none came say the write.
|
||||
// Only a refusal is taken: the request was never wholly written, so nothing can have run, and any other
|
||||
// answer here is not one a correct engine gives — passing it on as success would hide the failure.
|
||||
a, rerr := ReadAnswer(conn)
|
||||
if rerr != nil {
|
||||
return Answer{}, fmt.Errorf("asking the node-engine: %w; and no answer came (%v)", err, rerr)
|
||||
}
|
||||
if a.Refused == "" {
|
||||
return Answer{}, fmt.Errorf("asking the node-engine: %w; and the answer that came is not a refusal, "+
|
||||
"though the request was never wholly written", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
a, err := ReadAnswer(conn)
|
||||
if err != nil {
|
||||
return Answer{}, fmt.Errorf("reading the node-engine's answer: %w", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func writeLine(w io.Writer, v any) error {
|
||||
body, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = w.Write(append(body, '\n'))
|
||||
return err
|
||||
}
|
||||
|
||||
// readLine reads one line of JSON of at most limit bytes. A line over the limit is refused rather than read
|
||||
// whole: the reader is the side that pays for it.
|
||||
func readLine(r io.Reader, limit int, into any) error {
|
||||
br := bufio.NewReaderSize(io.LimitReader(r, int64(limit)+1), 64<<10)
|
||||
var body []byte
|
||||
for {
|
||||
chunk, err := br.ReadSlice('\n')
|
||||
body = append(body, chunk...)
|
||||
if len(body) > limit {
|
||||
return fmt.Errorf("%w: more than %d bytes", ErrTooLong, limit)
|
||||
}
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if errors.Is(err, bufio.ErrBufferFull) {
|
||||
continue
|
||||
}
|
||||
if errors.Is(err, io.EOF) && len(body) > 0 {
|
||||
break
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := json.Unmarshal(body, into); err != nil {
|
||||
return fmt.Errorf("not one: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A request and an answer survive a round trip, bytes and all.
|
||||
func TestARequestAndAnAnswerSurviveTheirRoundTrip(t *testing.T) {
|
||||
var b bytes.Buffer
|
||||
line := []string{"settings", "set", "zsh", `{"execute":"withhold"}`, "--node", "a"}
|
||||
if err := WriteRequest(&b, Request{Line: line}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ReadRequest(&b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(got.Line, "\x00") != strings.Join(line, "\x00") {
|
||||
t.Fatalf("the line came back as %q", got.Line)
|
||||
}
|
||||
|
||||
b.Reset()
|
||||
sent := Answer{Stdout: []byte("ok\n\xff"), Stderr: []byte("said"), Exit: 3, Terminal: true, Why: "the terminal", Cut: true,
|
||||
Unknown: "no answer; the line may have run — see the call call-1"}
|
||||
if err := WriteAnswer(&b, sent); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a, err := ReadAnswer(&b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(a.Stdout, sent.Stdout) || string(a.Stderr) != "said" || a.Exit != 3 || !a.Terminal || a.Why != sent.Why || !a.Cut ||
|
||||
a.Unknown != sent.Unknown {
|
||||
t.Fatalf("the answer came back as %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
// A request over the bound is refused, not read whole; one naming no command is refused.
|
||||
func TestARequestOverTheBoundOrNamingNothingIsRefused(t *testing.T) {
|
||||
long := Request{Line: []string{strings.Repeat("x", MaxRequest)}}
|
||||
big := Request{Line: []string{"secret"}, Stdin: bytes.Repeat([]byte("s"), MaxStdin+1)}
|
||||
var bb bytes.Buffer
|
||||
if err := WriteRequest(&bb, big); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ReadRequest(&bb); !errors.Is(err, ErrTooLong) {
|
||||
t.Fatalf("standard input over its bound was read: %v", err)
|
||||
}
|
||||
if _, err := AskWith(context.Background(), "/nonexistent.sock", big); err == nil || !strings.Contains(err.Error(), "nothing was asked") {
|
||||
t.Fatalf("mesh-cli would ask with standard input over its bound: %v", err)
|
||||
}
|
||||
var b bytes.Buffer
|
||||
if err := WriteRequest(&b, long); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ReadRequest(&b); !errors.Is(err, ErrTooLong) {
|
||||
t.Fatalf("a request over the bound was read: %v", err)
|
||||
}
|
||||
if _, err := ReadRequest(strings.NewReader(`{"line":[]}` + "\n")); err == nil {
|
||||
t.Fatal("a request naming no command was taken")
|
||||
}
|
||||
if _, err := ReadRequest(strings.NewReader("not json\n")); err == nil {
|
||||
t.Fatal("a request that is not one was taken")
|
||||
}
|
||||
}
|
||||
|
||||
// Ask speaks the protocol over a socket, as mesh-cli does to the node-engine.
|
||||
func TestAskSpeaksTheProtocolOverASocket(t *testing.T) {
|
||||
socket := filepath.Join(t.TempDir(), "cli.sock")
|
||||
l, err := net.Listen("unix", socket)
|
||||
if err != nil {
|
||||
t.Skipf("no unix socket here: %v", err)
|
||||
}
|
||||
defer l.Close()
|
||||
go func() {
|
||||
conn, err := l.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
req, err := ReadRequest(conn)
|
||||
if err != nil {
|
||||
_ = WriteAnswer(conn, Refusal(err.Error()))
|
||||
return
|
||||
}
|
||||
_ = WriteAnswer(conn, Answer{Stdout: []byte(strings.Join(req.Line, " "))})
|
||||
}()
|
||||
a, err := Ask(context.Background(), socket, []string{"status"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(a.Stdout) != "status" || a.Exit != 0 || a.Refused != "" {
|
||||
t.Fatalf("asked status and heard %+v", a)
|
||||
}
|
||||
if _, err := Ask(context.Background(), filepath.Join(t.TempDir(), "none.sock"), []string{"status"}); err == nil ||
|
||||
!strings.Contains(err.Error(), "does not answer") {
|
||||
t.Fatalf("a missing socket was not said: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// An outcome not known is never a refusal: it says the line may have run, and exits non-zero.
|
||||
func TestAnUnknownOutcomeIsNotARefusal(t *testing.T) {
|
||||
a := NotKnown("call-7", "no answer within 6m0s")
|
||||
if a.Refused != "" || a.Exit == 0 || !strings.Contains(a.Unknown, "may have run") || !strings.Contains(a.Unknown, "call-7") {
|
||||
t.Fatalf("an unknown outcome reads as %+v", a)
|
||||
}
|
||||
if b := NotKnown("", "no answer"); strings.Contains(b.Unknown, "call ") {
|
||||
t.Fatalf("an unknown outcome with no call names one: %+v", b)
|
||||
}
|
||||
}
|
||||
|
||||
// Standard input up to its bound travels whole, beside the line: a secret given with --from -.
|
||||
func TestStandardInputTravelsWithItsLine(t *testing.T) {
|
||||
given := append(bytes.Repeat([]byte{0, 0xff, '\n', 'x'}, MaxStdin/4-1), "end"...)
|
||||
var b bytes.Buffer
|
||||
if err := WriteRequest(&b, Request{Line: []string{"secret", "accept", "anchor", "telegram", "telegram-token", "--from", "-"},
|
||||
Stdin: given}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if b.Len() > MaxRequest {
|
||||
t.Fatalf("a request with standard input at its bound is %d bytes, over MaxRequest %d", b.Len(), MaxRequest)
|
||||
}
|
||||
got, err := ReadRequest(&b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(got.Stdin, given) || got.Line[1] != "accept" {
|
||||
t.Fatalf("standard input did not travel whole: %d bytes, want %d", len(got.Stdin), len(given))
|
||||
}
|
||||
none, _ := json.Marshal(Request{Line: []string{"status"}})
|
||||
if strings.Contains(string(none), "stdin") {
|
||||
t.Fatalf("a request without standard input says it has one: %s", none)
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// anEngineThatClosesFirst listens like the node-engine before mesh-host#80: it writes its answer, if any, and
|
||||
// closes without reading the request (novox/hq issue 434). It answers one connection.
|
||||
func anEngineThatClosesFirst(t *testing.T, answer *Answer) string {
|
||||
t.Helper()
|
||||
socket := filepath.Join(t.TempDir(), "cli.sock")
|
||||
l, err := net.Listen("unix", socket)
|
||||
if err != nil {
|
||||
t.Skipf("no unix socket here: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { l.Close() })
|
||||
go func() {
|
||||
conn, err := l.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if answer != nil {
|
||||
_ = WriteAnswer(conn, *answer)
|
||||
}
|
||||
conn.Close()
|
||||
}()
|
||||
return socket
|
||||
}
|
||||
|
||||
// aLineTheSocketCannotHold is a request larger than a unix socket buffers, so its write cannot finish before
|
||||
// the engine reads it — and an engine that closes first makes it fail every time, not only when it loses a race.
|
||||
func aLineTheSocketCannotHold() []string { return []string{"status", strings.Repeat("x", 4<<20)} }
|
||||
|
||||
// An engine that refuses and closes before reading the request is heard: its refusal reaches the caller, not
|
||||
// the broken pipe its close left the client's write (novox/hq issue 444).
|
||||
func TestARefusalWrittenBeforeTheRequestIsReadReachesTheCaller(t *testing.T) {
|
||||
busy := Refusal("the node-engine is busy: too many lines at once")
|
||||
socket := anEngineThatClosesFirst(t, &busy)
|
||||
a, err := Ask(context.Background(), socket, aLineTheSocketCannotHold())
|
||||
if err != nil {
|
||||
t.Fatalf("the engine said %q and the caller heard: %v", busy.Refused, err)
|
||||
}
|
||||
if a.Refused != busy.Refused || a.Exit != 1 {
|
||||
t.Fatalf("the engine said %q and the caller heard %+v", busy.Refused, a)
|
||||
}
|
||||
}
|
||||
|
||||
// When the write fails and no answer is waiting, the error says both: what failed, and that no answer came.
|
||||
func TestAFailedWriteWithNoAnswerSaysBoth(t *testing.T) {
|
||||
socket := anEngineThatClosesFirst(t, nil)
|
||||
start := time.Now()
|
||||
_, err := Ask(context.Background(), socket, aLineTheSocketCannotHold())
|
||||
if err == nil {
|
||||
t.Fatal("a request the engine never read, and never answered, came back without an error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "asking the node-engine") || !strings.Contains(err.Error(), "no answer came") {
|
||||
t.Fatalf("the error does not say what failed and that no answer came: %v", err)
|
||||
}
|
||||
if waited := time.Since(start); waited > 30*time.Second {
|
||||
t.Fatalf("waited %s on a connection the engine had closed", waited)
|
||||
}
|
||||
}
|
||||
|
||||
// After a failed write only a refusal is taken: nothing can have run, so an answer saying it ran and exited 0
|
||||
// is an error, never a success (novox/hq issue 444).
|
||||
func TestAnAnswerOtherThanARefusalAfterAFailedWriteIsAnError(t *testing.T) {
|
||||
ran := Answer{Exit: 0, Stdout: []byte("ok")}
|
||||
socket := anEngineThatClosesFirst(t, &ran)
|
||||
a, err := Ask(context.Background(), socket, aLineTheSocketCannotHold())
|
||||
if err == nil {
|
||||
t.Fatalf("a request never wholly written came back as success: %+v", a)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not a refusal") {
|
||||
t.Fatalf("the error does not say the answer was not a refusal: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Reading after a failed write never waits longer than the ask already allowed: a peer that never reads and
|
||||
// never answers is given up on at the context's deadline, not later (novox/hq issue 444).
|
||||
func TestAFailedWriteWaitsNoLongerThanTheDeadline(t *testing.T) {
|
||||
socket := filepath.Join(t.TempDir(), "cli.sock")
|
||||
l, err := net.Listen("unix", socket)
|
||||
if err != nil {
|
||||
t.Skipf("no unix socket here: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { l.Close() })
|
||||
held := make(chan net.Conn, 1)
|
||||
go func() {
|
||||
conn, err := l.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
held <- conn // neither read nor answered, nor closed, until the test ends
|
||||
}()
|
||||
t.Cleanup(func() {
|
||||
select {
|
||||
case c := <-held:
|
||||
c.Close()
|
||||
default:
|
||||
}
|
||||
})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
start := time.Now()
|
||||
_, err = Ask(ctx, socket, aLineTheSocketCannotHold())
|
||||
waited := time.Since(start)
|
||||
if err == nil {
|
||||
t.Fatal("a peer that never read nor answered gave no error")
|
||||
}
|
||||
if !errors.Is(err, os.ErrDeadlineExceeded) {
|
||||
t.Fatalf("the error is not the deadline: %v", err)
|
||||
}
|
||||
if waited > time.Second {
|
||||
t.Fatalf("waited %s with a deadline of 50ms", waited)
|
||||
}
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
// mesh-hand-compose composes one module for one node by hand, from the inputs the controller keeps, and signs it with the
|
||||
// operator's key, for the hand path of novox/hq ADR 0539 clause 10: a fingerprint the controller never composed (a
|
||||
// hand build), while the controller cannot compose its own fix. It composes with mesh-sdk's composition, the one the
|
||||
// controller writes with (compose.Compositions), and prints the composition, its key and its digest; only with -write
|
||||
// does it write them, with the operator's credential: the composition into the node's bucket composed-<node>, each
|
||||
// file over 64 KiB into the node's object bucket, and the digest into mesh-controller_composed. The want is the
|
||||
// operator's next act, naming that digest; each act is recorded in the hand-act log.
|
||||
//
|
||||
// mesh-hand-compose -input <inputs.json> -node <node> -module <module> -fp <src2:...> -key <operator key>
|
||||
// [-write -nats <url> -creds <operator credential>]
|
||||
//
|
||||
// The inputs are a compose.Input as JSON. The key is the operator's ed25519 private key, PKCS#8 in PEM; the
|
||||
// node-engine trusts it beside the controller's (ADR 0539 clause 3).
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/kvlimit"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(os.Args[1:], os.Stdout); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "mesh-hand-compose:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// printed is what mesh-hand-compose prints: where the composition goes, its digest, the composition, and whether it was
|
||||
// written.
|
||||
type printed struct {
|
||||
Bucket string `json:"bucket"`
|
||||
Key string `json:"key"`
|
||||
Digest string `json:"digest"`
|
||||
Composition json.RawMessage `json:"composition"`
|
||||
Objects []string `json:"objects,omitempty"`
|
||||
Written bool `json:"written"`
|
||||
Next string `json:"next"`
|
||||
}
|
||||
|
||||
func run(args []string, stdout io.Writer) error {
|
||||
fs := flag.NewFlagSet("mesh-hand-compose", flag.ContinueOnError)
|
||||
input := fs.String("input", "", "the controller's kept inputs, a compose.Input as JSON")
|
||||
node := fs.String("node", "", "the node to compose for")
|
||||
module := fs.String("module", "", "the module to compose")
|
||||
fp := fs.String("fp", "", "the module's fingerprint, src2: and 64 hex digits")
|
||||
keyFile := fs.String("key", "", "the operator's ed25519 private key, PKCS#8 in PEM")
|
||||
write := fs.Bool("write", false, "write the composition, its files and its digest, with the operator's credential")
|
||||
url := fs.String("nats", nats.DefaultURL, "with -write: the bus")
|
||||
creds := fs.String("creds", "", "with -write: the operator's credential file")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
switch {
|
||||
case *input == "" || *node == "" || *module == "" || *fp == "":
|
||||
return errors.New("-input, -node, -module and -fp are each required")
|
||||
case *keyFile == "":
|
||||
return errors.New("-key is required: a composition made by hand is signed by the operator's key, and the node-engine applies no other")
|
||||
case *write && *creds == "":
|
||||
return errors.New("-write needs -creds: the operator's credential is the only hand writer of a composition")
|
||||
}
|
||||
key, err := readKey(*keyFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key2, err := delivery.ComposedKey(*node, *module, *fp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bucket, err := delivery.ComposedBucketFor(*node)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := os.ReadFile(*input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var in compose.Input
|
||||
if err := json.Unmarshal(raw, &in); err != nil {
|
||||
return fmt.Errorf("the inputs in %s are not a compose.Input: %w", *input, err)
|
||||
}
|
||||
all, _, err := compose.Compositions(in, *node, map[string]string{*module: *fp}, compose.Reading)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c, ok := all[key2]
|
||||
if !ok {
|
||||
return fmt.Errorf("%s does not run %s: its composition holds none of it", *node, *module)
|
||||
}
|
||||
c, objects, err := c.ByDigest()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if c, err = delivery.SignComposed(c, key); err != nil {
|
||||
return err
|
||||
}
|
||||
encoded, err := delivery.EncodeComposed(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
digest, err := delivery.ComposedDigestOf(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := printed{Bucket: bucket, Key: key2, Digest: digest, Composition: encoded,
|
||||
Next: fmt.Sprintf("write want.%s.%s naming fp %s and composed %s with the operator's credential, and record each act in the hand-act log", *node, *module, *fp, digest)}
|
||||
for d := range objects {
|
||||
out.Objects = append(out.Objects, d)
|
||||
}
|
||||
if *write {
|
||||
if err := writeIt(*url, *creds, *node, bucket, key2, encoded, digest, objects); err != nil {
|
||||
return err
|
||||
}
|
||||
out.Written = true
|
||||
}
|
||||
enc := json.NewEncoder(stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(out)
|
||||
}
|
||||
|
||||
func readKey(path string) (ed25519.PrivateKey, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
block, _ := pem.Decode(raw)
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("%s holds no PEM block", path)
|
||||
}
|
||||
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
key, ok := parsed.(ed25519.PrivateKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s is not an ed25519 key", path)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// writeIt writes the files by digest, then the composition, then its digest: a reader that finds the digest finds the
|
||||
// composition and its files already there.
|
||||
func writeIt(url, creds, node, bucket, key string, encoded []byte, digest string, objects map[string][]byte) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
nc, err := nats.Connect(url, nats.UserCredentials(creds), nats.Name("mesh-hand-compose"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer nc.Close()
|
||||
js, err := jetstream.New(nc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(objects) > 0 {
|
||||
filesBucket, err := declaration.FileBucketFor(node)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
store, err := js.ObjectStore(ctx, filesBucket)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the node's object bucket %s: %w", filesBucket, err)
|
||||
}
|
||||
for d, body := range objects {
|
||||
name, err := declaration.ObjectName(d)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := store.PutBytes(ctx, name, body); err != nil {
|
||||
return fmt.Errorf("the file %s: %w", d, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
composed, err := js.KeyValue(ctx, bucket)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the bucket %s: %w", bucket, err)
|
||||
}
|
||||
if _, err := kvlimit.Put(ctx, composed, key, encoded); err != nil {
|
||||
return fmt.Errorf("%s in %s: %w", key, bucket, err)
|
||||
}
|
||||
value, err := delivery.EncodeComposedDigest(delivery.ComposedDigest{Digest: digest, Judgement: delivery.JudgedCannot,
|
||||
Words: "composed by hand with mesh-hand-compose (ADR 0539 clause 10): the operator writes the want"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
digests, err := js.KeyValue(ctx, delivery.ControllerComposedBucket)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the bucket %s: %w", delivery.ControllerComposedBucket, err)
|
||||
}
|
||||
if _, err := kvlimit.Put(ctx, digests, key, value); err != nil {
|
||||
return fmt.Errorf("%s in %s: %w", key, delivery.ControllerComposedBucket, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/overlay"
|
||||
)
|
||||
|
||||
// aMesh is the Input of mesh-controller's aMesh, as mesh-sdk's compose tests make it (compose/fixture_test.go): two
|
||||
// machines on the private network, the module the mesh provides for itself assigned to both. The manifest is the one
|
||||
// the controller provides (overlay.Manifest), read back as the catalogue reads it.
|
||||
func aMesh(t *testing.T) compose.Input {
|
||||
t.Helper()
|
||||
var m catalogue.Manifest
|
||||
b, _ := json.Marshal(overlay.Manifest())
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in := compose.Input{Catalogue: map[string]catalogue.Manifest{overlay.Name: m}}
|
||||
for i, name := range []string{"anchor", "laptop"} {
|
||||
pub, _, _ := ed25519.GenerateKey(rand.Reader)
|
||||
in.Nodes = append(in.Nodes, compose.Node{Name: name,
|
||||
Place: compose.Place{Key: base64.StdEncoding.EncodeToString(pub), Endpoint: name + ".example:51820", Site: "here", Hub: name == "anchor", Address: []string{"10.77.0.1", "10.77.0.2"}[i]},
|
||||
Assigned: []string{overlay.Name}, Capabilities: map[string]bool{"container-runtime": true, "wireguard": true, "systemd": true}})
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
func writeKey(t *testing.T, dir string) ed25519.PublicKey {
|
||||
t.Helper()
|
||||
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
|
||||
der, err := x509.MarshalPKCS8PrivateKey(priv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "operator.key"), pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return pub
|
||||
}
|
||||
|
||||
// 1943/AC5: mesh-hand-compose, given the kept inputs, a node, a module and its fingerprint, prints that module's
|
||||
// composition for that node signed by the operator's key and its digest: its resources are the controller's for that
|
||||
// module on that node (compose.Compose), and it verifies against the operator's key. Without --write it writes nothing.
|
||||
func TestMeshComposeGivesTheControllersResourcesSignedByTheOperator(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
in := aMesh(t)
|
||||
raw, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
input := filepath.Join(dir, "input.json")
|
||||
if err := os.WriteFile(input, raw, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
operator := writeKey(t, dir)
|
||||
fp := delivery.SourcePrefix2 + strings.Repeat("a", 64)
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := run([]string{"-input", input, "-node", "laptop", "-module", overlay.Name, "-fp", fp,
|
||||
"-key", filepath.Join(dir, "operator.key")}, &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var printed struct {
|
||||
Key string `json:"key"`
|
||||
Bucket string `json:"bucket"`
|
||||
Digest string `json:"digest"`
|
||||
Composition json.RawMessage `json:"composition"`
|
||||
Written bool `json:"written"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &printed); err != nil {
|
||||
t.Fatalf("%s: %v", out.String(), err)
|
||||
}
|
||||
c, err := delivery.DecodeComposed(printed.Composition)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if digest, err := delivery.VerifyComposed(c, []ed25519.PublicKey{operator}); err != nil || digest != printed.Digest {
|
||||
t.Errorf("the composition verifies %q, %v against the operator's key; printed %s", digest, err, printed.Digest)
|
||||
}
|
||||
wantKey, _ := delivery.ComposedKey("laptop", overlay.Name, fp)
|
||||
if printed.Key != wantKey || printed.Bucket != "composed-laptop" || printed.Written {
|
||||
t.Errorf("printed key %s in %s, written %v", printed.Key, printed.Bucket, printed.Written)
|
||||
}
|
||||
|
||||
d, _, err := compose.Compose(in, "laptop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var want, got []string
|
||||
for _, r := range d.Resources {
|
||||
want = append(want, r["id"].(string))
|
||||
}
|
||||
for _, r := range c.Resources {
|
||||
var head struct{ ID string }
|
||||
_ = json.Unmarshal(r, &head)
|
||||
got = append(got, head.ID)
|
||||
}
|
||||
sort.Strings(want)
|
||||
sort.Strings(got)
|
||||
if len(got) == 0 || strings.Join(got, " ") != strings.Join(want, " ") {
|
||||
t.Errorf("mesh-hand-compose gives %v, the controller's composition %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// 1943/AC5: mesh-hand-compose refuses what it cannot compose, naming why: no key, a fingerprint not of the new form, a
|
||||
// module the node does not run, a node the mesh does not have.
|
||||
func TestMeshComposeRefuses(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
raw, _ := json.Marshal(aMesh(t))
|
||||
input := filepath.Join(dir, "input.json")
|
||||
_ = os.WriteFile(input, raw, 0o600)
|
||||
writeKey(t, dir)
|
||||
key := filepath.Join(dir, "operator.key")
|
||||
fp := delivery.SourcePrefix2 + strings.Repeat("a", 64)
|
||||
for _, c := range []struct {
|
||||
args []string
|
||||
why string
|
||||
}{
|
||||
{[]string{"-input", input, "-node", "laptop", "-module", overlay.Name, "-fp", fp}, "-key"},
|
||||
{[]string{"-input", input, "-node", "laptop", "-module", overlay.Name, "-fp", "src2:1", "-key", key}, "fingerprint"},
|
||||
{[]string{"-input", input, "-node", "laptop", "-module", "mesh-mail", "-fp", fp, "-key", key}, "does not run mesh-mail"},
|
||||
{[]string{"-input", input, "-node", "nowhere", "-module", overlay.Name, "-fp", fp, "-key", key}, "nowhere"},
|
||||
{[]string{"-input", input, "-node", "laptop", "-module", overlay.Name, "-fp", fp, "-key", key, "-write"}, "-creds"},
|
||||
} {
|
||||
var out bytes.Buffer
|
||||
if err := run(c.args, &out); err == nil || !strings.Contains(err.Error(), c.why) {
|
||||
t.Errorf("mesh-hand-compose %v = %v, want a refusal saying %q", c.args, err, c.why)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
// the controller composes it, split into each module's composition at the fingerprint fps gives it and the node's own
|
||||
// (`<node>.node`), by key (delivery.ComposedKey). Each resource says its module, whatever the node reported it reads.
|
||||
// A module fps gives no fingerprint for is not composed and is named in skipped, so a caller composing one module
|
||||
// (mesh-hand-compose) gets that one; but for a module the node runs inside its own composition (Node.InOwn, and the
|
||||
// gets that one; but for a module the node runs inside its own composition (Node.InOwn, and the
|
||||
// private network's always), which fps names at no fingerprint of the new form: its resources go into the node's own,
|
||||
// their module taken off and their ids still naming it, and its filled paths with them, as the controller's composed
|
||||
// writer folds them (issue 1126). Each module's Data says whether its manifest declares data. The compositions are not
|
||||
|
||||
@@ -105,7 +105,12 @@ func (c *composer) grantsFor(node string) ([]catalogue.Grant, []catalogue.Overfl
|
||||
}
|
||||
}
|
||||
sealed := s.ForProvider
|
||||
if sealed == "" {
|
||||
switch {
|
||||
case credentiallessRow(plan, s, node):
|
||||
// Its offer presents no credential (form none): the grant carries the consumer and nothing sealed, and
|
||||
// nothing stands in for it, so neither end waits for a value nobody makes (issue 1126).
|
||||
sealed = ""
|
||||
case sealed == "":
|
||||
sealed = catalogue.ForeseenSealed
|
||||
foreseen = append(foreseen, fmt.Sprintf("the grant of %s for %s on %s", s.Provision, s.ConsumerModule, s.Consumer))
|
||||
}
|
||||
@@ -116,6 +121,18 @@ func (c *composer) grantsFor(node string) ([]catalogue.Grant, []catalogue.Overfl
|
||||
return out, withheld, unbound, foreseen, nil
|
||||
}
|
||||
|
||||
// credentialless is whether a need's provider offers it with no credential (catalogue.CredentialFormNone): the vault
|
||||
// mints nothing for it, so nothing is asked of the vault, sealed into a grant or a file, or stood in for (issue 1126).
|
||||
func credentialless(n catalogue.Needed) bool { return n.CredentialForm == catalogue.CredentialFormNone }
|
||||
|
||||
// credentiallessRow is whether the consumer's resolution answers row's need from provider with an offer of form none.
|
||||
func credentiallessRow(consumer catalogue.Resolution, row Issued, provider string) bool {
|
||||
return slices.ContainsFunc(consumer.Needs, func(n catalogue.Needed) bool {
|
||||
return n.Name == row.Provision && n.For == row.ConsumerModule && n.Local == row.Local && n.From == provider &&
|
||||
credentialless(n)
|
||||
})
|
||||
}
|
||||
|
||||
// rowsFor is every pair credential this node's declaration must carry a grant for, in the order the controller reads them
|
||||
// (SecretsFrom: by provision, consumer, consumer module and local name): the ones the Input says were made, and the ones the same send
|
||||
// would make before it composes this node -- every need of a node that resolves (this one too), whose provider is this node, that is not
|
||||
|
||||
@@ -19,7 +19,8 @@ type CredentialKey struct {
|
||||
// as a consumer, each need of its resolution a provider's credential answers (a need answered by a record is a licence's,
|
||||
// not a pair's); as a provider, each credential it must carry a grant for (rowsFor). A node that composes its own asks the
|
||||
// vault for exactly these, sealed to its key, and composes with them; one it does not get is stood in for, which the
|
||||
// node's own apply refuses (CompositionsApplied).
|
||||
// node's own apply refuses (CompositionsApplied). A need whose provider offers it with no credential (form none) is
|
||||
// in neither list: the vault mints nothing for it (issue 1126).
|
||||
func CredentialsNeeded(in Input, node string) (asConsumer, asProvider []CredentialKey, err error) {
|
||||
c := newComposer(in)
|
||||
resolved, err := c.resolution(node)
|
||||
@@ -27,7 +28,7 @@ func CredentialsNeeded(in Input, node string) (asConsumer, asProvider []Credenti
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, n := range resolved.Needs {
|
||||
if n.ByRecord || n.From == "" {
|
||||
if n.ByRecord || n.From == "" || credentialless(n) {
|
||||
continue
|
||||
}
|
||||
asConsumer = append(asConsumer, CredentialKey{Provision: n.Name, Consumer: node, ConsumerModule: n.For,
|
||||
@@ -38,6 +39,10 @@ func CredentialsNeeded(in Input, node string) (asConsumer, asProvider []Credenti
|
||||
return nil, nil, fmt.Errorf("compose: the credentials %s provides: %w", node, err)
|
||||
}
|
||||
for _, r := range rows {
|
||||
// A grant whose offer presents no credential (form none) has none to ask for (issue 1126).
|
||||
if consumer, err := c.planFor(r.Consumer); err == nil && credentiallessRow(consumer.Plan, r, node) {
|
||||
continue
|
||||
}
|
||||
asProvider = append(asProvider, CredentialKey{Provision: r.Provision, Consumer: r.Consumer,
|
||||
ConsumerModule: r.ConsumerModule, Provider: node, Local: r.Local})
|
||||
}
|
||||
|
||||
@@ -384,30 +384,74 @@ func TestNoSecretValueOfTheInputIsInWhatTheCompositionSays(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Round 1, finding 2: a consumer the same send adds has no credential in the Input yet, and the provider's declaration of that send
|
||||
// carries its grant, composed with a stand-in and named foreseen.
|
||||
func TestAConsumerNoCredentialWasMadeForYetIsGrantedWithAStandInAndNamedForeseen(t *testing.T) {
|
||||
in := aGrantMesh(t)
|
||||
in.Issued = nil
|
||||
got := rendered(t, in, "anchor", Foreseeing)
|
||||
if len(got.Rendering.Grants) != 1 {
|
||||
t.Fatalf("grants are %+v", got.Rendering.Grants)
|
||||
}
|
||||
g := got.Rendering.Grants[0]
|
||||
if g.Provision != "s3-bucket" || g.Consumer != "laptop" || g.From != "files" || g.Sealed != catalogue.ForeseenSealed {
|
||||
t.Errorf("the grant is %+v", g)
|
||||
}
|
||||
if !has(got.Foreseen, "the grant of s3-bucket for files on laptop") {
|
||||
t.Errorf("foreseen %v", got.Foreseen)
|
||||
}
|
||||
// Only the provider's own: the consumer is granted nothing, and nothing in the Input changed.
|
||||
if len(rendered(t, in, "laptop", Foreseeing).Rendering.Grants) != 0 {
|
||||
t.Error("the consumer is granted its own credential")
|
||||
}
|
||||
if len(in.Issued) != 0 {
|
||||
t.Error("a row was added to the Input")
|
||||
// A consumer no credential was made for yet is granted with a stand-in and named foreseen, for every form whose credential
|
||||
// the vault mints or takes in; an offer of form none has no credential (issue 1126, 2026-11-02: mesh-cli on ace held for
|
||||
// "the grant of mcp-endpoint", whose provider node-tools offers form none): its grant carries the consumer and no
|
||||
// credential, nothing stands in on either side, and neither end asks the vault for one.
|
||||
func TestAConsumerNoCredentialWasMadeForYetIsGrantedWithAStandInUnlessItsOfferHasNone(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
form string
|
||||
cred *catalogue.OfferCredential
|
||||
}{
|
||||
{"undeclared", nil},
|
||||
{catalogue.CredentialFormPair, &catalogue.OfferCredential{Form: catalogue.CredentialFormPair}},
|
||||
{catalogue.CredentialFormShared, &catalogue.OfferCredential{Own: "access-key"}},
|
||||
{catalogue.CredentialFormNone, &catalogue.OfferCredential{Form: catalogue.CredentialFormNone}},
|
||||
} {
|
||||
in := aGrantMesh(t)
|
||||
in.Issued = nil
|
||||
provider := in.Catalogue["objects"]
|
||||
provider.Provides[0].Credential = tc.cred
|
||||
if tc.cred != nil && tc.cred.Own != "" {
|
||||
provider.OwnSecrets = catalogue.OwnSecrets{"access-key": {Path: "/var/lib/mesh/objects/access-key"}}
|
||||
}
|
||||
in.Catalogue["objects"] = provider
|
||||
none := tc.form == catalogue.CredentialFormNone
|
||||
got := rendered(t, in, "anchor", Foreseeing)
|
||||
if len(got.Rendering.Grants) != 1 {
|
||||
t.Fatalf("%s: grants are %+v", tc.form, got.Rendering.Grants)
|
||||
}
|
||||
g, wantSealed := got.Rendering.Grants[0], catalogue.ForeseenSealed
|
||||
if none {
|
||||
wantSealed = ""
|
||||
}
|
||||
if g.Provision != "s3-bucket" || g.Consumer != "laptop" || g.From != "files" || g.Sealed != wantSealed {
|
||||
t.Errorf("%s: the grant is %+v", tc.form, g)
|
||||
}
|
||||
if has(got.Foreseen, "the grant of s3-bucket for files on laptop") == none {
|
||||
t.Errorf("%s: foreseen %v", tc.form, got.Foreseen)
|
||||
}
|
||||
consumer, asProvider, err := CredentialsNeeded(in, "laptop")
|
||||
_, anchorProvides, err2 := CredentialsNeeded(in, "anchor")
|
||||
if err != nil || err2 != nil || len(asProvider) != 0 || (len(consumer) == 0) != none || (len(anchorProvides) == 0) != none {
|
||||
t.Errorf("%s: laptop asks %v, anchor provides %v (%v %v)", tc.form, consumer, anchorProvides, err, err2)
|
||||
}
|
||||
resolved, err := Resolve(in, "laptop")
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", tc.form, err)
|
||||
}
|
||||
if n := theFilesNeed(t, resolved); (n.Sealed == "") != none || (len(resolved.Foreseen) == 0) != none {
|
||||
t.Errorf("%s: the consumer's credential is %q, foreseen %v", tc.form, n.Sealed, resolved.Foreseen)
|
||||
}
|
||||
if lap := rendered(t, in, "laptop", Foreseeing); none && len(lap.Foreseen) != 0 {
|
||||
t.Errorf("%s: the consumer stands in for %v", tc.form, lap.Foreseen)
|
||||
}
|
||||
// Only the provider's own: the consumer is granted nothing, and nothing in the Input changed.
|
||||
if len(rendered(t, in, "laptop", Foreseeing).Rendering.Grants) != 0 {
|
||||
t.Errorf("%s: the consumer is granted its own credential", tc.form)
|
||||
}
|
||||
if len(in.Issued) != 0 {
|
||||
t.Errorf("%s: a row was added to the Input", tc.form)
|
||||
}
|
||||
// A row made long ago for an offer of form none (the controller's) carries no credential either.
|
||||
in.Issued = []Issued{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", ForProvider: "sealed-to-anchor"}}
|
||||
if g := rendered(t, in, "anchor", Foreseeing).Rendering.Grants; none && (len(g) != 1 || g[0].Sealed != "") {
|
||||
t.Errorf("%s: an old row's grant is %+v", tc.form, g)
|
||||
}
|
||||
}
|
||||
// A credential only a person gives and nobody has is no grant.
|
||||
in := aGrantMesh(t)
|
||||
in.Issued = nil
|
||||
in.Credentials = []Credential{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", State: CredentialNotGiven}}
|
||||
if got := rendered(t, in, "anchor", Foreseeing); len(got.Rendering.Grants) != 0 {
|
||||
t.Errorf("a credential nobody gave is granted: %+v", got.Rendering.Grants)
|
||||
|
||||
+147
-45
@@ -223,9 +223,56 @@ func taken(name string, raw []byte, m delivery.Module) (man catalogue.Manifest,
|
||||
// manifest is refused is read for its offer alone (unusable). A manifest the bus does not answer for still ends the
|
||||
// read: that is the bus's, not the module's.
|
||||
func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module string, err error), wanted map[string]string) (compose.Input, error) {
|
||||
b, err := readBase(ctx, r, mesh, s)
|
||||
b.refuseOwn(refuse)
|
||||
if err != nil {
|
||||
return compose.Input{}, err
|
||||
}
|
||||
return b.finish(ctx, r, r.Build, mesh, s, b.in, refuse, wanted)
|
||||
}
|
||||
|
||||
// base is the part of a node's input that the fingerprints it composes its modules at leave alone (issue 1954): read once
|
||||
// for its plans at the wanted builds and at the newest, whose builds alone differ (PlansHeldTwice).
|
||||
type base struct {
|
||||
in compose.Input
|
||||
placements map[string]delivery.Placement
|
||||
records map[string]delivery.NodeRecord
|
||||
modules map[string]delivery.Module
|
||||
// ownRefused is each module placed on the composing node whose manifest the indexer refused, with why, in the order
|
||||
// read: every scope holds it.
|
||||
ownRefused [][2]string
|
||||
}
|
||||
|
||||
// refuseOwn hands refuse each module placed on the composing node whose manifest is refused.
|
||||
func (b base) refuseOwn(refuse func(module string, err error)) {
|
||||
if refuse == nil {
|
||||
return
|
||||
}
|
||||
for _, r := range b.ownRefused {
|
||||
refuse(r[0], errors.New(r[1]))
|
||||
}
|
||||
}
|
||||
|
||||
// cloned is the input with its own catalogue, nodes and kept manifests, what finishing it sets: two scopes finished over
|
||||
// one base never see each other's builds.
|
||||
func (b base) cloned() compose.Input {
|
||||
in := b.in
|
||||
in.Catalogue = maps.Clone(b.in.Catalogue)
|
||||
in.Nodes = slices.Clone(b.in.Nodes)
|
||||
for i := range in.Nodes {
|
||||
in.Nodes[i].Kept = maps.Clone(in.Nodes[i].Kept)
|
||||
}
|
||||
in.KeptCoreBoxes = slices.Clone(b.in.KeptCoreBoxes)
|
||||
in.OwnSecrets = slices.Clone(b.in.OwnSecrets)
|
||||
return in
|
||||
}
|
||||
|
||||
// readBase is input up to the builds: placement, records, the indexer's modules and manifests, each node's values.
|
||||
func readBase(ctx context.Context, r Reader, mesh Mesh, s Scope) (base, error) {
|
||||
var b base
|
||||
placements, err := r.Placements(ctx)
|
||||
if err != nil {
|
||||
return compose.Input{}, fmt.Errorf("frombus: placement: %w", err)
|
||||
return b, fmt.Errorf("frombus: placement: %w", err)
|
||||
}
|
||||
if len(s.Placements) > 0 {
|
||||
given := make(map[string]delivery.Placement, len(placements)+len(s.Placements))
|
||||
@@ -238,7 +285,7 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
|
||||
continue
|
||||
}
|
||||
if p.Node != node {
|
||||
return compose.Input{}, fmt.Errorf("frombus: the placement given for %s names the node %q", node, p.Node)
|
||||
return b, fmt.Errorf("frombus: the placement given for %s names the node %q", node, p.Node)
|
||||
}
|
||||
given[node] = *p
|
||||
}
|
||||
@@ -246,14 +293,14 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
|
||||
}
|
||||
records, err := r.Records(ctx)
|
||||
if err != nil {
|
||||
return compose.Input{}, fmt.Errorf("frombus: node records: %w", err)
|
||||
return b, fmt.Errorf("frombus: node records: %w", err)
|
||||
}
|
||||
modules, err := r.Modules(ctx)
|
||||
if err != nil {
|
||||
return compose.Input{}, fmt.Errorf("frombus: the indexer's modules: %w", err)
|
||||
return b, fmt.Errorf("frombus: the indexer's modules: %w", err)
|
||||
}
|
||||
if _, ok := placements[s.Node]; !ok && !s.Every {
|
||||
return compose.Input{}, fmt.Errorf("frombus: %s has no placement in the catalogue", s.Node)
|
||||
return b, fmt.Errorf("frombus: %s has no placement in the catalogue", s.Node)
|
||||
}
|
||||
|
||||
in := compose.Input{OverlayCIDR: mesh.OverlayCIDR, InternalSuffix: mesh.InternalSuffix, Authority: mesh.Authority,
|
||||
@@ -279,18 +326,6 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
|
||||
in.Authority = delivery.AuthorityBundle(in.Authority, vault)
|
||||
}
|
||||
}
|
||||
parse := parser(mesh)
|
||||
read := func(module, digest string) (catalogue.Manifest, error) {
|
||||
raw, err := r.Manifest(ctx, digest)
|
||||
if err != nil {
|
||||
return catalogue.Manifest{}, fmt.Errorf("frombus: the manifest of %s (digest %s): %w", module, digest, err)
|
||||
}
|
||||
man, err := parse(raw)
|
||||
if err != nil {
|
||||
return catalogue.Manifest{}, fmt.Errorf("frombus: the manifest of %s (digest %s) does not decode: %w", module, digest, err)
|
||||
}
|
||||
return man, nil
|
||||
}
|
||||
named := func(field *map[string]string, module, why string) {
|
||||
if *field == nil {
|
||||
*field = map[string]string{}
|
||||
@@ -309,7 +344,7 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
|
||||
raw, err := r.Manifest(ctx, m.Manifest)
|
||||
if err != nil {
|
||||
// Not read is no refusal of the module: a bus that does not answer is waited out by the caller.
|
||||
return compose.Input{}, fmt.Errorf("frombus: the manifest of %s (digest %s): %w", name, m.Manifest, err)
|
||||
return b, fmt.Errorf("frombus: the manifest of %s (digest %s): %w", name, m.Manifest, err)
|
||||
}
|
||||
// One module's manifest the indexer refused refuses that module alone, by the rule a build that cannot be read
|
||||
// follows (issue 774: power's bar item refused every node's composition, 2026-10-30; ADR 0578 §3: one refused
|
||||
@@ -318,8 +353,8 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
|
||||
man, why := taken(name, raw, m)
|
||||
if why != "" {
|
||||
named(&in.Refused, name, why)
|
||||
if refuse != nil && ownPlaced[name] {
|
||||
refuse(name, errors.New(why))
|
||||
if ownPlaced[name] {
|
||||
b.ownRefused = append(b.ownRefused, [2]string{name, why})
|
||||
}
|
||||
} else if aside := man.SetAside(); aside != "" {
|
||||
named(&in.SetAside, name, aside)
|
||||
@@ -332,11 +367,11 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
|
||||
for _, name := range delivery.ProvidedModules() {
|
||||
raw, err := json.Marshal(overlay.Manifest())
|
||||
if err != nil {
|
||||
return compose.Input{}, err
|
||||
return b, err
|
||||
}
|
||||
var private catalogue.Manifest
|
||||
if err := json.Unmarshal(raw, &private); err != nil {
|
||||
return compose.Input{}, fmt.Errorf("frombus: the manifest mesh-sdk provides for %s: %w", name, err)
|
||||
return b, fmt.Errorf("frombus: the manifest mesh-sdk provides for %s: %w", name, err)
|
||||
}
|
||||
in.Catalogue[name] = private
|
||||
}
|
||||
@@ -357,13 +392,13 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
|
||||
var values map[string]any
|
||||
if withValues {
|
||||
if values, err = r.Values(ctx, node, m.Module); err != nil {
|
||||
return compose.Input{}, fmt.Errorf("frombus: %s's config values on %s: %w", m.Module, node, err)
|
||||
return b, fmt.Errorf("frombus: %s's config values on %s: %w", m.Module, node, err)
|
||||
}
|
||||
} else if keys := catalogue.ServedSettings(in.Catalogue[m.Module]); len(keys) > 0 {
|
||||
// What a provider elsewhere serves from its settings is told to every consumer: the composing node reads
|
||||
// those keys of it, and no other (issue 1126: mailu's smtp domain, a consumer's on shanks).
|
||||
if values, err = r.Served(ctx, node, m.Module, keys); err != nil {
|
||||
return compose.Input{}, fmt.Errorf("frombus: what %s serves on %s (%s): %w", m.Module, node,
|
||||
return b, fmt.Errorf("frombus: what %s serves on %s (%s): %w", m.Module, node,
|
||||
strings.Join(keys, ", "), err)
|
||||
}
|
||||
}
|
||||
@@ -436,14 +471,35 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
|
||||
in.CarriedPeers = carriedPeers(in.Adopted.Tunnel, placements[in.Adopted.Hub].Decided.PeerNames, records)
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return compose.Input{}, fmt.Errorf("frombus: modules are placed on %s, whose agent has written no record yet",
|
||||
return b, fmt.Errorf("frombus: modules are placed on %s, whose agent has written no record yet",
|
||||
strings.Join(missing, ", "))
|
||||
}
|
||||
b.in, b.placements, b.records, b.modules = in, placements, records, modules
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// finish is input from its base on: the composing node's modules from their builds (build reads one), its own secrets.
|
||||
// in is the base's input, or a copy of it (base.cloned) where one base is finished more than once.
|
||||
func (b base) finish(ctx context.Context, r Reader, build func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error),
|
||||
mesh Mesh, s Scope, in compose.Input, refuse func(module string, err error), wanted map[string]string) (compose.Input, error) {
|
||||
var err error
|
||||
parse := parser(mesh)
|
||||
read := func(module, digest string) (catalogue.Manifest, error) {
|
||||
raw, err := r.Manifest(ctx, digest)
|
||||
if err != nil {
|
||||
return catalogue.Manifest{}, fmt.Errorf("frombus: the manifest of %s (digest %s): %w", module, digest, err)
|
||||
}
|
||||
man, err := parse(raw)
|
||||
if err != nil {
|
||||
return catalogue.Manifest{}, fmt.Errorf("frombus: the manifest of %s (digest %s) does not decode: %w", module, digest, err)
|
||||
}
|
||||
return man, nil
|
||||
}
|
||||
if !s.Every {
|
||||
if _, ok := records[s.Node]; !ok {
|
||||
if _, ok := b.records[s.Node]; !ok {
|
||||
return compose.Input{}, fmt.Errorf("frombus: %s has written no record of itself yet", s.Node)
|
||||
}
|
||||
if err := fromBuilds(ctx, r, &in, s, placements[s.Node], modules, read, parse, refuse, wanted); err != nil {
|
||||
if err := fromBuilds(ctx, r, build, &in, s, b.placements[s.Node], b.modules, read, parse, refuse, wanted); err != nil {
|
||||
return compose.Input{}, err
|
||||
}
|
||||
// Its own modules' secrets, sealed to its own key by the vault: a composition carries them sealed (ADR 0550 §5).
|
||||
@@ -545,10 +601,16 @@ func PlansHeld(ctx context.Context, r Reader, mesh Mesh, s Scope) (plans map[str
|
||||
// plansHeldOf is the plans of node over in, every module in held held, and each module a refusal is of held too until
|
||||
// the plans compose (culprit's rule over the Planning mode: its stub first, then its bare stub).
|
||||
func plansHeldOf(in compose.Input, node string, held map[string]string) (map[string]catalogue.Plan, error) {
|
||||
return heldOf(in, node, held, func(in compose.Input) (map[string]catalogue.Plan, error) { return plansIn(in, node, compose.Planning) })
|
||||
}
|
||||
|
||||
// heldOf composes compose1 over in with every module in held held, and each module a refusal is of held too until it
|
||||
// composes (culprit's rule: its stub first, then its bare stub); what compose1 answers of a held module is left out.
|
||||
// Plans and data items are composed so (PlansHeld, DataItemsHeld): one module never ends the read (novox/hq ADR 0578 §3).
|
||||
func heldOf[V any](in compose.Input, node string, held map[string]string, compose1 func(compose.Input) (map[string]V, error)) (map[string]V, error) {
|
||||
for _, m := range sortedKeys(held) {
|
||||
in = stubbed(in, node, m, "")
|
||||
}
|
||||
compose1 := func(in compose.Input) (map[string]catalogue.Plan, error) { return plansIn(in, node, compose.Planning) }
|
||||
// Each try holds one module more or leaves out all of one held module's offer: bounded by twice the modules placed.
|
||||
for tries := 0; tries <= 2*len(assignedOn(in, node))+1; tries++ {
|
||||
plans, err := compose1(in)
|
||||
@@ -585,7 +647,7 @@ func plansHeldOf(in compose.Input, node string, held map[string]string) (map[str
|
||||
}
|
||||
in = stubbed(in, node, module, leave)
|
||||
}
|
||||
return nil, fmt.Errorf("frombus: the plans of %s do not compose with %d module(s) held: %s", node, len(held),
|
||||
return nil, fmt.Errorf("frombus: what %s composes does not compose with %d module(s) held: %s", node, len(held),
|
||||
strings.Join(sortedKeys(held), ", "))
|
||||
}
|
||||
|
||||
@@ -597,15 +659,8 @@ func PlansOf(in compose.Input, node string) (map[string]catalogue.Plan, error) {
|
||||
|
||||
// plansIn is the controller's handedPlans in a mode: Foreseeing is the controller's own.
|
||||
func plansIn(in compose.Input, node string, mode compose.Mode) (map[string]catalogue.Plan, error) {
|
||||
resolved, err := compose.Resolve(in, node)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("frombus: the plans of %s: %w", node, err)
|
||||
}
|
||||
rendered, err := compose.Render(in, node, mode)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("frombus: the plans of %s: %w", node, err)
|
||||
}
|
||||
gens, err := compose.Generators(in)
|
||||
// One composition for the three, so each node of the mesh is resolved once (issue 1954).
|
||||
resolved, rendered, gens, err := compose.Composing(in, node, mode)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("frombus: the plans of %s: %w", node, err)
|
||||
}
|
||||
@@ -667,7 +722,8 @@ func ranOf(ctx context.Context, r Reader, mesh Mesh, running []delivery.Composed
|
||||
// module it keeps at another build (Scope.KeptAt, into its node's Kept), read from the builder's done key and resolved
|
||||
// by the one take-in rule (takein.Manifest), a templates archive read in the artifact store as the node reaches it
|
||||
// (issue 1126).
|
||||
func fromBuilds(ctx context.Context, r Reader, in *compose.Input, s Scope, p delivery.Placement, modules map[string]delivery.Module,
|
||||
func fromBuilds(ctx context.Context, r Reader, build func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error),
|
||||
in *compose.Input, s Scope, p delivery.Placement, modules map[string]delivery.Module,
|
||||
read func(module, digest string) (catalogue.Manifest, error), parse func([]byte) (catalogue.Manifest, error),
|
||||
refuse func(module string, err error), wanted map[string]string) error {
|
||||
// stub keeps a refused module in the composition as a stub for this node alone (compose.Stub): what it provides
|
||||
@@ -710,6 +766,24 @@ func fromBuilds(ctx context.Context, r Reader, in *compose.Input, s Scope, p del
|
||||
if b.Manifest == "" {
|
||||
return catalogue.Manifest{}, fmt.Errorf("frombus: the build of %s at %s names no manifest", module, fp)
|
||||
}
|
||||
// A take-in kept by the reader is read back as below, never shared: each composition holds its own manifest.
|
||||
keeper, keeps := r.(takenInKeeper)
|
||||
if keeps && !addressed {
|
||||
address, addressErr = compose.ArtifactStoreAddress(*in, s.Node)
|
||||
addressed = true
|
||||
}
|
||||
// Kept by the store's address too: a node that reaches no store takes in as before, and fails where it did.
|
||||
key, keyed := takenInKey(module, fp, address, *b)
|
||||
keyed = keyed && addressErr == nil && address != ""
|
||||
if keeps && keyed {
|
||||
if raw, ok := keeper.takenIn(key); ok {
|
||||
resolved, err := parse(raw)
|
||||
if err != nil {
|
||||
return catalogue.Manifest{}, fmt.Errorf("frombus: the built manifest of %s at %s: %w", module, fp, err)
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
}
|
||||
src, err := read(module, b.Manifest)
|
||||
if err != nil {
|
||||
return catalogue.Manifest{}, err
|
||||
@@ -727,6 +801,9 @@ func fromBuilds(ctx context.Context, r Reader, in *compose.Input, s Scope, p del
|
||||
if resolved, err = parse(raw); err != nil {
|
||||
return catalogue.Manifest{}, fmt.Errorf("frombus: the built manifest of %s at %s: %w", module, fp, err)
|
||||
}
|
||||
if keeps && keyed {
|
||||
keeper.keepTakenIn(key, raw)
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
// A module wanted at another build than the trunk's is composed from the build its want names (issue 774), as one
|
||||
@@ -760,7 +837,7 @@ func fromBuilds(ctx context.Context, r Reader, in *compose.Input, s Scope, p del
|
||||
if man, ok := in.Catalogue[pm.Module]; !ok || (man.Build == nil && !man.Templated()) {
|
||||
continue // nothing is built of it: its manifest is what it composes from
|
||||
}
|
||||
b, err := r.Build(ctx, pm.Module, m.FP2)
|
||||
b, err := build(ctx, pm.Module, m.FP2)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("frombus: the build of %s at %s: %w", pm.Module, m.FP2, err)
|
||||
if refuse == nil {
|
||||
@@ -795,7 +872,7 @@ func fromBuilds(ctx context.Context, r Reader, in *compose.Input, s Scope, p del
|
||||
}
|
||||
for _, module := range sortedKeys(keptAt) {
|
||||
fp := keptAt[module]
|
||||
b, err := r.Build(ctx, module, fp)
|
||||
b, err := build(ctx, module, fp)
|
||||
switch {
|
||||
case err != nil:
|
||||
err = fmt.Errorf("frombus: the build of %s at %s: %w", module, fp, err)
|
||||
@@ -1057,9 +1134,34 @@ func DataItemsHeld(ctx context.Context, r Reader, mesh Mesh, s Scope) (items map
|
||||
if err != nil {
|
||||
return nil, nil, held, err
|
||||
}
|
||||
items, unplaced, err = compose.DataItems(in, s.Node)
|
||||
for m := range held {
|
||||
delete(items, m)
|
||||
}
|
||||
// Held module by module, as PlansHeld holds them: a refused stub or one module's composition refusal (an item's
|
||||
// template) holds that module and never ends the read.
|
||||
items, err = heldOf(in, s.Node, held, func(in compose.Input) (map[string][]catalogue.PlacedDataItem, error) {
|
||||
var err error
|
||||
items, unplaced, err = compose.DataItems(withoutHeldData(in, s.Node), s.Node)
|
||||
return items, err
|
||||
})
|
||||
return items, unplaced, held, err
|
||||
}
|
||||
|
||||
// withoutHeldData is in with no data item of a module held on node: none of a held module's items is measured, so its
|
||||
// data never refuses the rest (its stub keeps its data for the backups the plans compose).
|
||||
func withoutHeldData(in compose.Input, node string) compose.Input {
|
||||
out := in
|
||||
out.Nodes = append([]compose.Node(nil), in.Nodes...)
|
||||
for i := range out.Nodes {
|
||||
n := &out.Nodes[i]
|
||||
if n.Name != node || len(n.Held) == 0 {
|
||||
continue
|
||||
}
|
||||
kept := maps.Clone(n.Kept)
|
||||
for m := range n.Held {
|
||||
if man, ok := kept[m]; ok {
|
||||
man.Data = nil
|
||||
kept[m] = man
|
||||
}
|
||||
}
|
||||
n.Kept = kept
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -2,10 +2,14 @@ package frombustest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose/frombus"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose/fromruntime"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
@@ -29,16 +33,25 @@ func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) {
|
||||
// refused is the indexer's verdict on bad, a problem its judge found (novox/hq ADR 0578 §1): Plans then holds
|
||||
// backup too, and never ends the read.
|
||||
refused string
|
||||
// dataHeld says the data items' read holds backup too: a refusal it meets (a refused manifest, an unusable build,
|
||||
// an item composition refuses), not one only a plan meets.
|
||||
dataHeld bool
|
||||
// dataOnly checks the data items' read alone: the other reads blame restic, whose templates collect the item.
|
||||
dataOnly bool
|
||||
}{
|
||||
{name: "every module composes"},
|
||||
{name: "a built manifest that cannot be used",
|
||||
bad: `{"module":"backup","version":"1","build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`,
|
||||
built: `{"module":"backup","version":"1","requires":"amqp"}`, why: "backup"},
|
||||
built: `{"module":"backup","version":"1","requires":"amqp"}`, why: "backup", dataHeld: true},
|
||||
{name: "a plan that refuses",
|
||||
bad: `{"module":"backup","version":"1","resources":[{"id":"conf","type":"file","path":"/etc/backup.conf",` +
|
||||
`"mode":"0600","content":"key=${secret:nope}"}]}`, why: "${secret:nope}"},
|
||||
{name: "an indexer's manifest the mesh refuses", bad: unusableItem, why: `"shows" is none of them`,
|
||||
refused: `"shows" is none of them`},
|
||||
refused: `"shows" is none of them`, dataHeld: true},
|
||||
// ADR 0578 §3: an accepted manifest whose data item its composition refuses is held by the culprit rule, in every read.
|
||||
{name: "a data item its composition refuses",
|
||||
bad: `{"module":"backup","version":"1","data":{"own":[{"id":"d","path":"${dir:nope}","class":"cache"}]}}`,
|
||||
why: "nope", dataHeld: true, dataOnly: true},
|
||||
{name: "an indexer's manifest the mesh refuses, placed elsewhere", bad: unusableItem, elsewhere: true,
|
||||
refused: `"shows" is none of them`},
|
||||
} {
|
||||
@@ -65,6 +78,13 @@ func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) {
|
||||
b.Builds["backup "+fp] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: builtDigest,
|
||||
Artifacts: []delivery.BuiltArtifact{{Name: "files", Kind: "archive", Reference: "artifact-store://backup/files/blobs/x", Digest: "sha256:x"}}}
|
||||
}
|
||||
if row.dataOnly {
|
||||
items, _, iheld, err := frombus.DataItemsHeld(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node})
|
||||
if why, ok := iheld["backup"]; err != nil || !ok || !strings.Contains(why, row.why) || len(items["restic"]) == 0 {
|
||||
t.Errorf("%s: data items %v held %v: %v", row.name, items, iheld, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
plans, err := frombus.Plans(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node})
|
||||
switch {
|
||||
case row.refused != "" && (err != nil || !planned(plans, "restic") || planned(plans, "backup")):
|
||||
@@ -99,6 +119,18 @@ func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) {
|
||||
restic.Provisions[0].Provider != Node || restic.Provisions[0].ProviderModule != "postgres" {
|
||||
t.Fatalf("%s: the plans are %+v (held %v)", row.name, plans, held)
|
||||
}
|
||||
// Issue 1954: the plans at the wanted builds and at the newest, over one read, are what PlansHeld answers for each,
|
||||
// and where the time went is said by phase.
|
||||
keptAt := FPs()
|
||||
at, newest, took := frombus.PlansHeldTwice(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node, KeptAt: keptAt})
|
||||
wantAt, heldAt, errAt := frombus.PlansHeld(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node, KeptAt: keptAt})
|
||||
if !reflect.DeepEqual(at, frombus.HeldPlans{Plans: wantAt, Held: heldAt, Err: errAt}) ||
|
||||
!reflect.DeepEqual(newest, frombus.HeldPlans{Plans: plans, Held: held}) {
|
||||
t.Errorf("%s: twice composed %+v and %+v, want %v %v %v and %v %v", row.name, at, newest, wantAt, heldAt, errAt, plans, held)
|
||||
}
|
||||
if said := frombus.PhasesSaid(took); !strings.HasPrefix(said, "read ") || !strings.Contains(said, "plans at newest ") {
|
||||
t.Errorf("%s: the phases are said as %q", row.name, said)
|
||||
}
|
||||
if row.bad == "" || row.elsewhere {
|
||||
if len(held) > 0 {
|
||||
t.Errorf("%s: held %v", row.name, held)
|
||||
@@ -111,7 +143,123 @@ func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) {
|
||||
if _, planned := plans["backup"]; planned {
|
||||
t.Errorf("%s: the held module has a plan", row.name)
|
||||
}
|
||||
// The data items are read by the same rule (ADR 0578 §3: disk-load's read): the bad module held, the read whole.
|
||||
items, _, iheld, err := frombus.DataItemsHeld(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node})
|
||||
// A refusal only a plan meets (a file's secret) holds nothing here: data items render no such file.
|
||||
mustHold := row.dataHeld
|
||||
if why, ok := iheld["backup"]; err != nil || ok != mustHold || mustHold && !strings.Contains(why, row.why) || items["backup"] != nil {
|
||||
t.Errorf("%s: data items %v held %v: %v", row.name, items, iheld, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0580 clause 1: the watched reader reads a state it composes from out of the copy, a build by key each time it is
|
||||
// asked, and a manifest once per digest.
|
||||
byKey := map[string]int{}
|
||||
ws := stdio.WatchedStatesOver(func(state string, handle func(stdio.StateChange) error) error {
|
||||
byKey[state+" watched"]++
|
||||
return handle(stdio.StateChange{Key: "placement." + Node, Op: stdio.OpPut, Value: []byte(`{}`), Current: true})
|
||||
}, func(state, key string) ([]byte, bool, error) { byKey[state]++; return []byte(key), true, nil })
|
||||
w := fromruntime.Watched{WatchedStates: ws}
|
||||
for range 2 {
|
||||
_, _, _ = w.Get(frombus.BuildsRead, "restic."+fp)
|
||||
_, _, _ = w.Get(frombus.ManifestsRead, "sha256-d")
|
||||
if _, found, _ := w.Get(frombus.PlacementRead, "placement."+Node); !found {
|
||||
t.Error("the watched reader does not answer the placement from its copy")
|
||||
}
|
||||
}
|
||||
if byKey[frombus.BuildsRead] != 2 || byKey[frombus.ManifestsRead] != 1 || byKey[frombus.PlacementRead] != 0 ||
|
||||
byKey[frombus.PlacementRead+" watched"] != 1 {
|
||||
t.Errorf("the watched reader read %v, want a build by key each time, a manifest once, the placement watched once", byKey)
|
||||
}
|
||||
fetched := 0
|
||||
keep := fromruntime.KeptFetch(func(context.Context, string) ([]byte, error) { fetched++; return []byte("tar"), nil })
|
||||
for range 2 {
|
||||
if body, err := keep(context.Background(), "http://store/restic/files/blobs/sha256:a"); err != nil || string(body) != "tar" {
|
||||
t.Fatalf("the kept fetch answered %q, %v", body, err)
|
||||
}
|
||||
}
|
||||
if fetched != 1 {
|
||||
t.Errorf("a templates archive was fetched %d times, want once", fetched)
|
||||
}
|
||||
|
||||
// Issue 1954: the states reader takes a build in once per its content: composed again, its archive is not read again
|
||||
// and the plans are the same.
|
||||
b, err := Provisioned()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reader, archives := frombus.StatesReader(statesOf(t, b), func(_ context.Context, url string) ([]byte, error) {
|
||||
fetched++
|
||||
_, digest, _ := strings.Cut(url, "/blobs/")
|
||||
return b.Archives[digest], nil
|
||||
}), fetched
|
||||
var first map[string]catalogue.Plan
|
||||
for i := range 2 {
|
||||
plans, held, err := frombus.PlansHeld(context.Background(), reader, frombus.Mesh{}, frombus.Scope{Node: Node})
|
||||
if err != nil || len(held) > 0 || (i == 1 && !reflect.DeepEqual(plans, first)) || !planned(plans, "restic") {
|
||||
t.Fatalf("composed %v (held %v): %v", plans, held, err)
|
||||
}
|
||||
first = plans
|
||||
}
|
||||
if fetched-archives != 1 {
|
||||
t.Errorf("restic's archive was read %d times over two compositions, want once", fetched-archives)
|
||||
}
|
||||
}
|
||||
|
||||
// statesOf is the bus b as a module's read states hold it.
|
||||
func statesOf(t *testing.T, b *Bus) frombus.States {
|
||||
t.Helper()
|
||||
s := states{}
|
||||
put := func(state, key string, raw []byte, err error) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s[state] == nil {
|
||||
s[state] = map[string][]byte{}
|
||||
}
|
||||
s[state][key] = raw
|
||||
}
|
||||
for node, p := range b.Placement {
|
||||
raw, err := delivery.EncodePlacement(p)
|
||||
put(frombus.PlacementRead, "placement."+node, raw, err)
|
||||
}
|
||||
for node, rec := range b.Record {
|
||||
raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Record: &rec})
|
||||
put(frombus.HaveRead, node+".node", raw, err)
|
||||
}
|
||||
for module, m := range b.Module {
|
||||
key, _ := delivery.ModulesKey(module)
|
||||
raw, err := delivery.EncodeModule(m)
|
||||
put(frombus.ModulesRead, key, raw, err)
|
||||
}
|
||||
for digest, man := range b.Manifests {
|
||||
key, _ := delivery.ManifestsKey(digest)
|
||||
raw, err := delivery.EncodeManifest(delivery.Manifest{Module: "m", JSON: string(man)})
|
||||
put(frombus.ManifestsRead, key, raw, err)
|
||||
}
|
||||
for at, build := range b.Builds {
|
||||
module, fp, _ := strings.Cut(at, " ")
|
||||
key, _ := delivery.BuilderBuildKey(module, fp)
|
||||
raw, err := delivery.EncodeBuilderBuild(*build)
|
||||
put(frombus.BuildsRead, key, raw, err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// states are read states in memory.
|
||||
type states map[string]map[string][]byte
|
||||
|
||||
func (s states) Get(state, key string) ([]byte, bool, error) {
|
||||
v, ok := s[state][key]
|
||||
return v, ok, nil
|
||||
}
|
||||
|
||||
func (s states) Keys(state string) ([]string, error) {
|
||||
var keys []string
|
||||
for k := range s[state] {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func planned[P any](plans map[string]P, module string) bool { _, ok := plans[module]; return ok }
|
||||
|
||||
@@ -43,13 +43,22 @@ type States interface {
|
||||
type Fetch func(ctx context.Context, url string) ([]byte, error)
|
||||
|
||||
// StatesReader is the Reader over s; fetch reads a templates archive, nil for a holder that reads none.
|
||||
func StatesReader(s States, fetch Fetch) Reader { return statesReader{s: s, fetch: fetch} }
|
||||
// It keeps what each build's take-in made, by the build's content (issue 1954), so a holder composing again and again
|
||||
// takes in only a build it has not seen.
|
||||
func StatesReader(s States, fetch Fetch) Reader {
|
||||
return statesReader{s: s, fetch: fetch, kept: &keptTakeIns{}}
|
||||
}
|
||||
|
||||
type statesReader struct {
|
||||
s States
|
||||
fetch Fetch
|
||||
kept *keptTakeIns
|
||||
}
|
||||
|
||||
func (r statesReader) takenIn(key string) ([]byte, bool) { return r.kept.takenIn(key) }
|
||||
|
||||
func (r statesReader) keepTakenIn(key string, raw []byte) { r.kept.keepTakenIn(key, raw) }
|
||||
|
||||
func (r statesReader) each(read string, visit func(key string, value []byte)) error {
|
||||
keys, err := r.s.Keys(read)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package frombus
|
||||
|
||||
// **A node's plans at two sets of builds, over one read** (issue 1954; novox/hq ADR 0570 clause 2, ADR 0580 clause 2):
|
||||
// the seat mesh-provisioner's holder composes each node at the builds its wants name and at the newest build of each
|
||||
// placed module. Both are composed from one read of the bus (placement, records, the indexer's modules and manifests,
|
||||
// values: base); only the builds the node's modules are composed from differ, each build read once for both, and a
|
||||
// build taken in once per content by a reader that keeps it (StatesReader). Where the time went is said by phase.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// HeldPlans is one scope's plans, as PlansHeld answers them: err a refusal that is no single module's.
|
||||
type HeldPlans struct {
|
||||
Plans map[string]catalogue.Plan
|
||||
Held map[string]string
|
||||
Err error
|
||||
}
|
||||
|
||||
// Phase is one step of a composition and how long it took.
|
||||
type Phase struct {
|
||||
Name string `json:"phase"`
|
||||
Took time.Duration `json:"took"`
|
||||
}
|
||||
|
||||
// PlansHeldTwice is PlansHeld at s and at s with no kept builds (each placed module at its newest build), over one
|
||||
// read of the bus: what each answers is what PlansHeld answers for that scope. A read that fails is both scopes' err.
|
||||
// took names each phase in order: the read, then each scope's builds and its plans.
|
||||
func PlansHeldTwice(ctx context.Context, r Reader, mesh Mesh, s Scope) (at, newest HeldPlans, took []Phase) {
|
||||
start := time.Now()
|
||||
mark := func(name string) {
|
||||
now := time.Now()
|
||||
took = append(took, Phase{Name: name, Took: now.Sub(start)})
|
||||
start = now
|
||||
}
|
||||
b, err := readBase(ctx, r, mesh, s)
|
||||
mark("read")
|
||||
builds := onceBuilds(r)
|
||||
newestScope := s
|
||||
newestScope.KeptAt = nil
|
||||
for _, sc := range []struct {
|
||||
scope Scope
|
||||
into *HeldPlans
|
||||
name string
|
||||
}{{s, &at, "wanted"}, {newestScope, &newest, "newest"}} {
|
||||
held := map[string]string{}
|
||||
sc.into.Held = held
|
||||
refuse := func(module string, err error) { held[module] = err.Error() }
|
||||
b.refuseOwn(refuse)
|
||||
if err != nil {
|
||||
sc.into.Err = err
|
||||
continue
|
||||
}
|
||||
in, err := b.finish(ctx, r, builds, mesh, sc.scope, b.cloned(), refuse, nil)
|
||||
mark("builds at " + sc.name)
|
||||
if err != nil {
|
||||
sc.into.Err = err
|
||||
continue
|
||||
}
|
||||
sc.into.Plans, sc.into.Err = plansHeldOf(in, s.Node, held)
|
||||
mark("plans at " + sc.name)
|
||||
}
|
||||
return at, newest, took
|
||||
}
|
||||
|
||||
// onceBuilds is r's Build, each module's build at a fingerprint read once: both scopes of one composition read the
|
||||
// builds of the same moment.
|
||||
func onceBuilds(r Reader) func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error) {
|
||||
type answer struct {
|
||||
b *delivery.BuilderBuild
|
||||
err error
|
||||
}
|
||||
read := map[string]answer{}
|
||||
return func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error) {
|
||||
key := module + " " + fp
|
||||
if a, ok := read[key]; ok {
|
||||
return a.b, a.err
|
||||
}
|
||||
b, err := r.Build(ctx, module, fp)
|
||||
read[key] = answer{b, err}
|
||||
return b, err
|
||||
}
|
||||
}
|
||||
|
||||
// takenInKeeper is a Reader that keeps what a build's take-in made (StatesReader does): the resolved manifest's bytes,
|
||||
// by the content it was made from, so a composition made again on every change takes in only a build it has not seen.
|
||||
type takenInKeeper interface {
|
||||
takenIn(key string) ([]byte, bool)
|
||||
keepTakenIn(key string, raw []byte)
|
||||
}
|
||||
|
||||
// takenInKey is what a take-in is made from: the artifact store's address and the build's record (its manifest's digest, its artifacts by digest), read
|
||||
// whole; an archive is checked against its digest on every read, so the same record takes in to the same bytes.
|
||||
func takenInKey(module, fp, address string, b delivery.BuilderBuild) (string, bool) {
|
||||
raw, err := json.Marshal(b)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
sum := sha256.Sum256(append([]byte(module+" "+fp+" "+address+" "), raw...))
|
||||
return hex.EncodeToString(sum[:]), true
|
||||
}
|
||||
|
||||
// takenInAtMost bounds the take-ins a reader keeps; past it they are emptied, which costs only a take-in again.
|
||||
const takenInAtMost = 512
|
||||
|
||||
// keptTakeIns is a reader's kept take-ins.
|
||||
type keptTakeIns struct {
|
||||
mu sync.Mutex
|
||||
kept map[string][]byte
|
||||
}
|
||||
|
||||
func (k *keptTakeIns) takenIn(key string) ([]byte, bool) {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
raw, ok := k.kept[key]
|
||||
return raw, ok
|
||||
}
|
||||
|
||||
func (k *keptTakeIns) keepTakenIn(key string, raw []byte) {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
if k.kept == nil || len(k.kept) >= takenInAtMost {
|
||||
k.kept = map[string][]byte{}
|
||||
}
|
||||
k.kept[key] = raw
|
||||
}
|
||||
|
||||
// PhasesSaid says the phases in one line, each in milliseconds.
|
||||
func PhasesSaid(took []Phase) string {
|
||||
out := ""
|
||||
for i, p := range took {
|
||||
if i > 0 {
|
||||
out += ", "
|
||||
}
|
||||
out += fmt.Sprintf("%s %d ms", p.Name, p.Took.Milliseconds())
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose/frombus"
|
||||
@@ -28,9 +29,60 @@ func (States) Get(state, key string) ([]byte, bool, error) {
|
||||
// Keys are a state's keys.
|
||||
func (States) Keys(state string) ([]string, error) { return stdio.State(state).Keys() }
|
||||
|
||||
// Reader is frombus's Reader over this bundle's read states, fetching archives over HTTP.
|
||||
// Reader is frombus's Reader over this bundle's read states key by key, fetching archives over HTTP. A holder that
|
||||
// composes again and again reads through Watched instead (novox/hq ADR 0580).
|
||||
func Reader() frombus.Reader { return frombus.StatesReader(States{}, HTTPFetch) }
|
||||
|
||||
// Watched is frombus's States over a watched copy (novox/hq ADR 0580 clause 1): the states a composition reads whole
|
||||
// (placement, haves, modules, resolved values) from the copy, a build by key when asked, a manifest once per digest.
|
||||
type Watched struct{ *stdio.WatchedStates }
|
||||
|
||||
// Get is a key's value: a build read now, a manifest kept once read, any other state's from the copy.
|
||||
func (w Watched) Get(state, key string) ([]byte, bool, error) {
|
||||
switch state {
|
||||
case frombus.BuildsRead:
|
||||
return w.Direct(state, key)
|
||||
case frombus.ManifestsRead:
|
||||
return w.Once(state, key)
|
||||
}
|
||||
return w.WatchedStates.Get(state, key)
|
||||
}
|
||||
|
||||
// WatchedReader is frombus's Reader over w, fetching each templates archive over HTTP once (KeptFetch): a composition
|
||||
// made again on every change reads it again otherwise.
|
||||
func WatchedReader(w *stdio.WatchedStates) frombus.Reader {
|
||||
return frombus.StatesReader(Watched{w}, KeptFetch(HTTPFetch))
|
||||
}
|
||||
|
||||
// archivesAtMost bounds the archives KeptFetch keeps; past it they are emptied, which costs only a fetch again.
|
||||
const archivesAtMost = 64
|
||||
|
||||
// KeptFetch is fetch kept once per URL: an archive's URL names its blob by digest, so its body never changes under it
|
||||
// (frombus checks the digest on every read). A failed fetch is not kept.
|
||||
func KeptFetch(fetch frombus.Fetch) frombus.Fetch {
|
||||
var mu sync.Mutex
|
||||
kept := map[string][]byte{}
|
||||
return func(ctx context.Context, url string) ([]byte, error) {
|
||||
mu.Lock()
|
||||
body, ok := kept[url]
|
||||
mu.Unlock()
|
||||
if ok {
|
||||
return body, nil
|
||||
}
|
||||
body, err := fetch(ctx, url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mu.Lock()
|
||||
if len(kept) >= archivesAtMost {
|
||||
clear(kept)
|
||||
}
|
||||
kept[url] = body
|
||||
mu.Unlock()
|
||||
return body, nil
|
||||
}
|
||||
}
|
||||
|
||||
// HTTPFetch reads an archive over HTTP, at most 256 MiB.
|
||||
func HTTPFetch(ctx context.Context, url string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
|
||||
@@ -27,6 +27,23 @@ type composer struct {
|
||||
// unhostable is, from the last look at the rest of the mesh, each module assigned to another node that this
|
||||
// composition finds that node cannot run: what it would have offered is missing here (issue 1126, probe #532).
|
||||
unhostable []string
|
||||
|
||||
// What one composition looks at again and again, each worked out once over its Input, which it never changes (issue
|
||||
// 1954: a node's plans resolved every machine of the mesh some two hundred times, 11.7 s of CPU for novox): who is
|
||||
// on the private network, and the rest of the mesh as each node sees it.
|
||||
onNetwork []Node
|
||||
onNetworkSet bool
|
||||
rest map[string]restOf
|
||||
plans map[string]planOf
|
||||
}
|
||||
|
||||
// restOf is theRestOfTheMesh's answer for one excluded node, kept: what it found unhostable beside it.
|
||||
type restOf struct {
|
||||
world catalogue.World
|
||||
passes Passes
|
||||
left []LeftOut
|
||||
unhostable []string
|
||||
err error
|
||||
}
|
||||
|
||||
func newComposer(in Input) *composer { return &composer{in: in, seen: map[string]bool{}} }
|
||||
@@ -249,6 +266,14 @@ func (c *composer) whereEveryoneIs() (map[string]string, error) {
|
||||
// same rule as network(), so its name and its address stay where the tunnel to it does (issue 1833); one without is not
|
||||
// counted, and said.
|
||||
func (c *composer) onTheNetwork() []Node {
|
||||
if !c.onNetworkSet {
|
||||
c.onNetwork, c.onNetworkSet = c.onTheNetworkNow(), true
|
||||
}
|
||||
return slices.Clone(c.onNetwork)
|
||||
}
|
||||
|
||||
// onTheNetworkNow is onTheNetwork worked out: every placed machine's set resolved.
|
||||
func (c *composer) onTheNetworkNow() []Node {
|
||||
var out []Node
|
||||
for _, n := range c.in.sorted() {
|
||||
if n.Place.Address == "" {
|
||||
|
||||
@@ -35,6 +35,26 @@ func Render(in Input, node string, mode Mode) (Rendered, error) {
|
||||
return newComposer(in).render(node, mode)
|
||||
}
|
||||
|
||||
// Composing is Resolve, Render and Generators of one node over one composition, which resolves each node of the mesh
|
||||
// once for all three (issue 1954: three compositions resolved the mesh again each, 11.7 s for one node's plans). Each
|
||||
// answers as it does alone; the first refusal ends it, in that order, with what follows it empty.
|
||||
func Composing(in Input, node string, mode Mode) (Resolved, Rendered, map[string]catalogue.Generator, error) {
|
||||
c := newComposer(in)
|
||||
resolved, err := c.planFor(node)
|
||||
if err != nil {
|
||||
return Resolved{}, Rendered{}, nil, err
|
||||
}
|
||||
rendered, err := c.render(node, mode)
|
||||
if err != nil {
|
||||
return resolved, Rendered{}, nil, err
|
||||
}
|
||||
gens, err := c.generators()
|
||||
if err != nil {
|
||||
return resolved, rendered, nil, err
|
||||
}
|
||||
return resolved, rendered, gens, nil
|
||||
}
|
||||
|
||||
func (c *composer) render(nodeName string, mode Mode) (Rendered, error) {
|
||||
resolved, err := c.planFor(nodeName)
|
||||
if err != nil {
|
||||
|
||||
+43
-1
@@ -36,8 +36,46 @@ func Resolve(in Input, node string) (Resolved, error) {
|
||||
// IsUnresolvable reports whether err is a node's own set failing to compose, rather than the mesh being unable to answer.
|
||||
func IsUnresolvable(err error) bool { return unresolvable(err) }
|
||||
|
||||
// planFor is the controller's planFor.
|
||||
// planFor is the controller's planFor, worked out once per node in one composition (issue 1954): each caller has its
|
||||
// own copy of what a caller may set (a need's fields, as Resolution.Compose sets its Serves), the rest only read.
|
||||
func (c *composer) planFor(nodeName string) (Resolved, error) {
|
||||
p, ok := c.plans[nodeName]
|
||||
if !ok {
|
||||
p.resolved, p.err = c.planForNow(nodeName)
|
||||
if c.plans == nil {
|
||||
c.plans = map[string]planOf{}
|
||||
}
|
||||
c.plans[nodeName] = p
|
||||
}
|
||||
if p.err != nil {
|
||||
return Resolved{}, p.err
|
||||
}
|
||||
return p.resolved.copied(), nil
|
||||
}
|
||||
|
||||
// planOf is planFor's answer for one node, kept.
|
||||
type planOf struct {
|
||||
resolved Resolved
|
||||
err error
|
||||
}
|
||||
|
||||
// copied is r with its own needs, words and settings lists: what a caller may change of it.
|
||||
func (r Resolved) copied() Resolved {
|
||||
out := r
|
||||
out.Plan.Needs = slices.Clone(r.Plan.Needs)
|
||||
out.Foreseen = slices.Clone(r.Foreseen)
|
||||
out.NotGiven = slices.Clone(r.NotGiven)
|
||||
if r.Settings != nil {
|
||||
out.Settings = make(catalogue.SettingsBy, len(r.Settings))
|
||||
for m, layers := range r.Settings {
|
||||
out.Settings[m] = slices.Clone(layers)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// planForNow is planFor worked out.
|
||||
func (c *composer) planForNow(nodeName string) (Resolved, error) {
|
||||
resolved, err := c.resolution(nodeName)
|
||||
if err != nil {
|
||||
return Resolved{}, err
|
||||
@@ -80,6 +118,10 @@ func (c *composer) planFor(nodeName string) (Resolved, error) {
|
||||
}
|
||||
continue
|
||||
}
|
||||
if credentialless(n) {
|
||||
// Its provider's offer presents no credential (form none): nothing is sealed, made or stood in for (issue 1126).
|
||||
continue
|
||||
}
|
||||
cred, held := c.credential(n, nodeName)
|
||||
_, outside := issuedOutsideBy(shelf, n)
|
||||
switch {
|
||||
|
||||
@@ -3,6 +3,7 @@ package compose
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
@@ -59,6 +60,22 @@ func TheRestOfTheMesh(in Input, exclude string) (catalogue.World, Passes, []Left
|
||||
}
|
||||
|
||||
func (c *composer) theRestOfTheMesh(exclude string) (catalogue.World, Passes, []LeftOut, error) {
|
||||
r, ok := c.rest[exclude]
|
||||
if !ok {
|
||||
r.world, r.passes, r.left, r.err = c.theRestOfTheMeshNow(exclude)
|
||||
r.unhostable = c.unhostable
|
||||
if c.rest == nil {
|
||||
c.rest = map[string]restOf{}
|
||||
}
|
||||
c.rest[exclude] = r
|
||||
}
|
||||
// Each caller has its own copy of what it may set or add to; the maps and the slices within are only read.
|
||||
c.unhostable = slices.Clone(r.unhostable)
|
||||
return r.world, r.passes, slices.Clone(r.left), r.err
|
||||
}
|
||||
|
||||
// theRestOfTheMeshNow is theRestOfTheMesh worked out: every other machine's set resolved, twice.
|
||||
func (c *composer) theRestOfTheMeshNow(exclude string) (catalogue.World, Passes, []LeftOut, error) {
|
||||
shelf := c.in.Catalogue
|
||||
holdings := append([]catalogue.Held(nil), c.in.Holdings...)
|
||||
nodes := c.in.sorted()
|
||||
|
||||
@@ -140,7 +140,7 @@ const (
|
||||
RuleStep = 4 // at most step other nodes are ahead (step 0: no limit, ADR 0528 §3)
|
||||
RuleProviders = 5 // every provider the module requires runs healthy on that node, or at its canary
|
||||
RuleGrant = 6 // the module's credential for F exists (grants.<module>.<fp>)
|
||||
RuleComposed = 7 // the module's composition for that node at F exists (mesh-controller_composed, ADR 0539 §6)
|
||||
RuleComposed = 7 // the module's composition for that node at F exists (ADR 0539 §6)
|
||||
// RuleProvisions: every provision the module requires on that node is provided (novox/hq ADR 0570 §4): a want's
|
||||
// words name the key and its state that hold it, as "the database of keycloak on novox is asked of postgres".
|
||||
RuleProvisions = 8
|
||||
|
||||
@@ -268,58 +268,39 @@ func TestCompositionVerdictRefused(t *testing.T) {
|
||||
|
||||
// 1394/B1b: a grant key round-trips; a want or held key is refused, naming it; a grant round-trips and one naming an
|
||||
// empty account is refused.
|
||||
func TestControllerGrant(t *testing.T) {
|
||||
func TestTheGrantsIssued(t *testing.T) {
|
||||
fp := fp2of('c')
|
||||
key, err := ControllerGrantKey("mesh-delivery-assigner", fp)
|
||||
key, err := PartGrantKey("mesh-delivery-assigner", fp)
|
||||
if err != nil || key != "mesh-delivery-assigner."+strings.Repeat("c", 64) {
|
||||
t.Fatalf("ControllerGrantKey = %q, %v", key, err)
|
||||
t.Fatalf("PartGrantKey = %q, %v", key, err)
|
||||
}
|
||||
module, back, err := ParseControllerGrantKey(key)
|
||||
module, back, err := ParsePartGrantKey(key)
|
||||
if err != nil || module != "mesh-delivery-assigner" || back != fp {
|
||||
t.Errorf("ParseControllerGrantKey = %q, %q, %v", module, back, err)
|
||||
t.Errorf("ParsePartGrantKey = %q, %q, %v", module, back, err)
|
||||
}
|
||||
held, _ := HeldKey("mesh-mail", fp)
|
||||
for _, bad := range []string{"want.ace.mesh-mail", held, "ace.mesh-mail", ""} {
|
||||
if _, _, err := ParseControllerGrantKey(bad); err == nil || !strings.Contains(err.Error(), "\""+bad+"\"") {
|
||||
t.Errorf("ParseControllerGrantKey(%q) = %v, want a refusal naming it", bad, err)
|
||||
if _, _, err := ParsePartGrantKey(bad); err == nil || !strings.Contains(err.Error(), "\""+bad+"\"") {
|
||||
t.Errorf("ParsePartGrantKey(%q) = %v, want a refusal naming it", bad, err)
|
||||
}
|
||||
}
|
||||
for _, g := range []ControllerGrant{{}, {Accounts: []string{"UABC", "UDEF"}}} {
|
||||
data, err := EncodeControllerGrant(g)
|
||||
for _, g := range []Grant{{}, {Accounts: []string{"UABC", "UDEF"}}} {
|
||||
data, err := EncodeGrant(g)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := DecodeControllerGrant(data)
|
||||
got, err := DecodeGrant(data)
|
||||
g.V = Version
|
||||
if err != nil || !SameControllerGrant(got, g) {
|
||||
if err != nil || !SameGrant(got, g) {
|
||||
t.Errorf("grant %+v came back %+v, %v", g, got, err)
|
||||
}
|
||||
}
|
||||
if _, err := EncodeControllerGrant(ControllerGrant{Accounts: []string{""}}); err == nil {
|
||||
if _, err := EncodeGrant(Grant{Accounts: []string{""}}); err == nil {
|
||||
t.Errorf("a grant naming an empty account was accepted")
|
||||
}
|
||||
// The grants part's keys are the controller's: the same key round-trips; the source is the part's bucket only for a
|
||||
// handed-away lease (issue 774).
|
||||
if pk, err := PartGrantKey("mesh-delivery-assigner", fp); err != nil || pk != key {
|
||||
t.Errorf("PartGrantKey = %q, %v, want the controller's %q", pk, err, key)
|
||||
}
|
||||
if m, f, err := ParsePartGrantKey(key); err != nil || m != "mesh-delivery-assigner" || f != fp {
|
||||
t.Errorf("ParsePartGrantKey = %q, %q, %v", m, f, err)
|
||||
}
|
||||
g := Grant{Accounts: []string{"ace.mesh-build-go", "shanks.mesh-mail.reader"}}
|
||||
for _, c := range []struct {
|
||||
away bool
|
||||
bucket, read string
|
||||
}{
|
||||
{false, "mesh-controller_grants", "mesh-controller.grants"},
|
||||
{true, "mesh-grants_grants", "mesh-grants.grants"},
|
||||
} {
|
||||
if b, r := GrantSource(c.away); b != c.bucket || r != c.read {
|
||||
t.Errorf("GrantSource(%v) = %q, %q, want %q, %q", c.away, b, r, c.bucket, c.read)
|
||||
}
|
||||
}
|
||||
g := ControllerGrant{Accounts: []string{"ace.mesh-build-go", "shanks.mesh-mail.reader"}}
|
||||
for _, c := range []struct {
|
||||
g ControllerGrant
|
||||
g Grant
|
||||
node, module string
|
||||
names bool
|
||||
}{
|
||||
@@ -327,7 +308,7 @@ func TestControllerGrant(t *testing.T) {
|
||||
{g, "shanks", "mesh-mail", true},
|
||||
{g, "g14", "mesh-build-go", false},
|
||||
{g, "ace", "mesh-build", false},
|
||||
{ControllerGrant{}, "novox", "nats", true},
|
||||
{Grant{}, "novox", "nats", true},
|
||||
} {
|
||||
if got := c.g.NamesNode(c.node, c.module); got != c.names {
|
||||
t.Errorf("%v names %s.%s: %v, want %v", c.g.Accounts, c.node, c.module, got, c.names)
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
// The builder's keys (novox/hq ADR 0429 clauses 5 and 16, ADR 0437 clause 5, ADR 0405 clauses 2 and 7, ADR 0453
|
||||
// clause 4): a build is `<module>.<fingerprint key part>` in the builder's bucket, one key per fingerprint, since equal
|
||||
// fingerprints are equal content and the same source is never built twice. They are written only by the builder part
|
||||
// from R4c and read by the checker, the assigner and the view, and from R4e by the controller. They are not the
|
||||
// controller's build records (buildrecord.go: BuildsBucket, BuildKey), which stay as they are.
|
||||
// from R4c and read by the checker, the assigner and the view. They are not the ended controller's build records
|
||||
// (buildrecord.go: BuildKey).
|
||||
//
|
||||
// **The rules its writer keeps.** A builder claims a fingerprint that has no build key by creating it queued or
|
||||
// building, a create that fails when the key exists, and moves it by compare-and-set; a builder reclaims only a build
|
||||
|
||||
@@ -166,10 +166,7 @@ func TestBuilderLayoutDoesNotCollideWithTheControllers(t *testing.T) {
|
||||
if BuilderBuildsBucket != "mesh-delivery-builder_builds" || BuilderBuildsHistory != 5 || BuilderModule != "mesh-delivery-builder" {
|
||||
t.Errorf("bucket %s history %d module %s", BuilderBuildsBucket, BuilderBuildsHistory, BuilderModule)
|
||||
}
|
||||
if BuilderBuildsBucket == BuildsBucket || BuilderModule == "mesh-controller" {
|
||||
t.Error("the builder's bucket is the controller's")
|
||||
}
|
||||
if BuildsBucket != "mesh-controller_builds" || BuildKey("m", "7") != "m.build.7" || NewestKey("m") != "m.newest" {
|
||||
if BuildKey("m", "7") != "m.build.7" || NewestKey("m") != "m.newest" {
|
||||
t.Error("the controller's layout changed")
|
||||
}
|
||||
// the two key forms are never read as each other
|
||||
|
||||
@@ -2,8 +2,8 @@ package delivery
|
||||
|
||||
import "time"
|
||||
|
||||
// BuildRecord is the layout mesh-controller internal/chain/records.go Build writes (line 91), the fields a reader outside
|
||||
// the controller reads (novox/hq ADR 0437 §4 §5, issue 1223). The controller's record carries more; a decode ignores
|
||||
// BuildRecord is the layout the ended controller's chain wrote (mesh-controller internal/chain/records.go Build, line 91),
|
||||
// the fields a reader outside it read (novox/hq ADR 0437 §4 §5, issue 1223). The controller's record carries more; a decode ignores
|
||||
// what is not here. Path is the module's directory in its repository, as the controller writes it (Build.Path, records.go:100,
|
||||
// set from the build request trimmed of slashes at internal/inventory/chain_requests.go:19); empty for a module whose
|
||||
// manifest is the repository's root. SourceInputs has no json keys of its own: it is written under its Go field names, as the controller
|
||||
@@ -32,11 +32,6 @@ type Artifact struct {
|
||||
Reference string `json:"reference"`
|
||||
}
|
||||
|
||||
// BuildsBucket is the bucket the builds are kept in: mesh-controller internal/broker/controller_buckets.go:101,
|
||||
// BucketName(ControllerSeat, ChainBuilds), with ControllerSeat "mesh-controller" (internal/broker/streams.go:359) and
|
||||
// ChainBuilds "builds" (controller_buckets.go:113).
|
||||
const BuildsBucket = "mesh-controller_builds"
|
||||
|
||||
// BuildKey is a build's record under its module, a build that knew none under the request's key: copied from
|
||||
// mesh-controller internal/chain/keys.go:35-40 (BuildKey, with RequestKey at line 42).
|
||||
func BuildKey(module, id string) string {
|
||||
|
||||
@@ -114,9 +114,6 @@ func TestBuildRecordWithoutInputs(t *testing.T) { // 1223/AC2
|
||||
func TestBuildKeys(t *testing.T) { // 1223/AC3
|
||||
// Literal values of mesh-controller internal/broker/controller_buckets.go:101 (BucketName(ControllerSeat, ChainBuilds)
|
||||
// is "mesh-controller_builds") and internal/chain/keys.go:35-44.
|
||||
if BuildsBucket != "mesh-controller_builds" {
|
||||
t.Errorf("BuildsBucket = %q", BuildsBucket)
|
||||
}
|
||||
if got := BuildKey("build-agent", "build-1"); got != "build-agent.build.build-1" {
|
||||
t.Errorf("BuildKey = %q", got)
|
||||
}
|
||||
|
||||
@@ -32,8 +32,8 @@ type AskWaiter interface {
|
||||
type Asker struct {
|
||||
Publisher AskPublisher
|
||||
Waiter AskWaiter
|
||||
// Bound is how long a step may run once a build node took the ask (its key written running) before the asker stops
|
||||
// waiting; zero waits as long as ctx. An ask waiting in its stream is not bounded: the stream's backlog is no fault
|
||||
// Bound is how long a running step (a build node took the ask, its key written running) may stand still, with no new phase,
|
||||
// before the asker stops waiting; a step that keeps moving is waited for up to 3 Bounds from its start (issue 1930); zero waits as long as ctx. An ask waiting in its stream is not bounded: the stream's backlog is no fault
|
||||
// (ADR 0525 §6, §9), and the bound starts again when another node takes the ask.
|
||||
Bound time.Duration
|
||||
}
|
||||
@@ -68,20 +68,33 @@ func (k Asker) Wait(ctx context.Context, a delivery.StepAsk) (delivery.Step, err
|
||||
if err != nil {
|
||||
return delivery.Step{}, err
|
||||
}
|
||||
var bound <-chan time.Time
|
||||
var timer *time.Timer
|
||||
var bound, outerCap <-chan time.Time
|
||||
var timer, capTimer *time.Timer
|
||||
defer func() {
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
if capTimer != nil {
|
||||
capTimer.Stop()
|
||||
}
|
||||
}()
|
||||
running, since := "", time.Time{}
|
||||
running, since, phase, phaseAt := "", time.Time{}, "", time.Time{}
|
||||
standStill := func() error {
|
||||
if phase == "" {
|
||||
return fmt.Errorf("the ask %s's step on %s did not end within %s of its start: it stood still, saying no phase", a.ID, running, k.Bound)
|
||||
}
|
||||
return fmt.Errorf("the ask %s's step on %s did not end within %s: it stood still in %q since %s",
|
||||
a.ID, running, k.Bound, phase, phaseAt.UTC().Format(time.RFC3339))
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return delivery.Step{}, ctx.Err()
|
||||
case <-bound:
|
||||
return delivery.Step{}, fmt.Errorf("the ask %s's step on %s did not end within %s of its start", a.ID, running, k.Bound)
|
||||
return delivery.Step{}, standStill()
|
||||
case <-outerCap:
|
||||
return delivery.Step{}, fmt.Errorf("the ask %s's step on %s did not end within %s of its start: it kept moving (last phase %q at %s) but never ended",
|
||||
a.ID, running, 3*k.Bound, phase, phaseAt.UTC().Format(time.RFC3339))
|
||||
case step, ok := <-steps:
|
||||
if !ok {
|
||||
return delivery.Step{}, errors.New("the watch of the ask's step ended")
|
||||
@@ -92,21 +105,42 @@ func (k Asker) Wait(ctx context.Context, a delivery.StepAsk) (delivery.Step, err
|
||||
if step.State != delivery.StepRunning {
|
||||
return step, nil
|
||||
}
|
||||
if k.Bound <= 0 || (step.Node == running && step.Started.Equal(since)) {
|
||||
if k.Bound <= 0 {
|
||||
continue
|
||||
}
|
||||
newRun := step.Node != running || !step.Started.Equal(since)
|
||||
if !newRun && step.Phase == phase && step.PhaseAt.Equal(phaseAt) {
|
||||
continue
|
||||
}
|
||||
// A node took the ask, or took it again after its run stopped (another node, or the same node's build module
|
||||
// replaced mid-run: a new start): its step is bounded from its own start.
|
||||
running, since = step.Node, step.Started
|
||||
// replaced mid-run: a new start), or its step entered a phase. The bound is on standing still: it runs from the
|
||||
// step's last movement (its phase's start, else its start), so a step that keeps moving is waited for, up to
|
||||
// three bounds from its start (issue 1930).
|
||||
running, since, phase, phaseAt = step.Node, step.Started, step.Phase, step.PhaseAt
|
||||
last := step.Started
|
||||
if step.PhaseAt.After(last) {
|
||||
last = step.PhaseAt
|
||||
}
|
||||
left := k.Bound
|
||||
if !step.Started.IsZero() {
|
||||
left = time.Until(step.Started.Add(k.Bound))
|
||||
if !last.IsZero() {
|
||||
left = time.Until(last.Add(k.Bound))
|
||||
}
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
timer = time.NewTimer(max(left, 0))
|
||||
bound = timer.C
|
||||
if newRun {
|
||||
if capTimer != nil {
|
||||
capTimer.Stop()
|
||||
}
|
||||
outer := 3 * k.Bound
|
||||
if !step.Started.IsZero() {
|
||||
outer = time.Until(step.Started.Add(3 * k.Bound))
|
||||
}
|
||||
capTimer = time.NewTimer(max(outer, 0))
|
||||
outerCap = capTimer.C
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,41 @@ func TestAnAskerPutsOnceAndReadsTheStep(t *testing.T) {
|
||||
if len(g.Asks()) != 0 {
|
||||
t.Fatal("a wait put an ask")
|
||||
}
|
||||
// Issue 1930: the bound is on a step that stands still, not on its age. A step that enters a new phase inside the bound is
|
||||
// waited for until it ends; one that stays in a phase the bound fails at once, naming the phase; one that never stops moving
|
||||
// ends at three bounds, said as such.
|
||||
k = Asker{Publisher: g, Waiter: g, Bound: 60 * time.Millisecond}
|
||||
started := time.Now()
|
||||
go func() {
|
||||
for i := range 8 {
|
||||
g.Write(delivery.Step{ID: "mvl0frv18d1980", Node: "ace", State: delivery.StepRunning, Started: started, Phase: "p" + string(rune('a'+i)), PhaseAt: time.Now()})
|
||||
time.Sleep(15 * time.Millisecond)
|
||||
}
|
||||
g.Write(delivery.Step{ID: "mvl0frv18d1980", Node: "ace", State: delivery.StepPassed})
|
||||
}()
|
||||
if step, err := k.Wait(context.Background(), goAsk("mvl0frv18d1980")); err != nil || step.State != delivery.StepPassed {
|
||||
t.Fatalf("a step that kept moving past its bound: %+v, %v", step, err)
|
||||
}
|
||||
g.Write(delivery.Step{ID: "mvl0frv18d1981", Node: "ace", State: delivery.StepRunning, Started: time.Now(), Phase: "integration: tests", PhaseAt: time.Now()})
|
||||
if _, err := k.Wait(context.Background(), goAsk("mvl0frv18d1981")); err == nil || !strings.Contains(err.Error(), `stood still in "integration: tests"`) {
|
||||
t.Fatalf("a step that stood still in a phase: %v", err)
|
||||
}
|
||||
loop := make(chan struct{})
|
||||
defer close(loop)
|
||||
started = time.Now()
|
||||
go func() {
|
||||
for i := 0; ; i++ {
|
||||
select {
|
||||
case <-loop:
|
||||
return
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
g.Write(delivery.Step{ID: "mvl0frv18d1982", Node: "ace", State: delivery.StepRunning, Started: started, Phase: "q" + string(rune('a'+i%26)), PhaseAt: time.Now()})
|
||||
}
|
||||
}
|
||||
}()
|
||||
if _, err := k.Wait(context.Background(), goAsk("mvl0frv18d1982")); err == nil || !strings.Contains(err.Error(), "kept moving") {
|
||||
t.Fatalf("a step that never stops moving: %v", err)
|
||||
}
|
||||
// Issue 734: an ask the build seat gave up on (DEAD_LETTERS keeps its copy with the ask's id) ends its wait as failed
|
||||
// with the words, though a node had taken it; under NotTakenBound one no node takes ends at the bound; a step written
|
||||
// first is answered as the node wrote it.
|
||||
@@ -157,7 +192,7 @@ func TestARedeliveryToTheSameNodeRestartsTheBound(t *testing.T) {
|
||||
first := time.Now()
|
||||
f.Write(delivery.Step{ID: id, Node: "ace", State: delivery.StepRunning, Started: first})
|
||||
go func() {
|
||||
time.Sleep(40 * time.Millisecond)
|
||||
time.Sleep(15 * time.Millisecond)
|
||||
f.Write(delivery.Step{ID: id, Node: "ace", State: delivery.StepRunning, Started: time.Now()})
|
||||
time.Sleep(50 * time.Millisecond) // past the first run's bound, inside the second's
|
||||
f.Write(delivery.Step{ID: id, Node: "ace", State: delivery.StepPassed, Started: time.Now()})
|
||||
|
||||
@@ -91,8 +91,8 @@ func TestToolchainGate(t *testing.T) {
|
||||
gate, set, said := start(t, true)
|
||||
key, value := gateReport(t, "ace", "v26.12.0", old)
|
||||
gate.Toolchain(NodeBuildModule, key, value, false)
|
||||
if _, none := agreedOf(set); !strings.HasPrefix(none, "builders disagree") {
|
||||
t.Fatalf("before the placement is settled every report counts; got %q", none)
|
||||
if a := delivery.ToolchainAgreement(set.Entries("typescript")); len(a.SittingOut) != 1 || a.SittingOut[0].Node != "ace" { // ADR 0584: the majority is agreed, ace's report counts as the one that sits out
|
||||
t.Fatalf("before the placement is settled every report counts; got %+v", a)
|
||||
}
|
||||
k, v := placing(t, "ace", GoBuildModule) // another build module on ace: no matter
|
||||
gate.Placement(k, v, false, old)
|
||||
@@ -111,8 +111,8 @@ func TestToolchainGate(t *testing.T) {
|
||||
// Placed on ace with a report written since: it counts, and the builders really disagree.
|
||||
k, v = placing(t, "ace", GoBuildModule, NodeBuildModule)
|
||||
gate.Placement(k, v, false, old.Add(-time.Minute))
|
||||
if _, none := agreedOf(set); !strings.HasPrefix(none, "builders disagree: v26.12.0 recipe f59571cf4be75adf on ace") {
|
||||
t.Fatalf("builders that run different versions still disagree; got %q", none)
|
||||
if a := delivery.ToolchainAgreement(set.Entries("typescript")); a.Toolchain.Version != "v26.12.1" || len(a.SittingOut) != 1 || a.SittingOut[0].Version != "v26.12.0" {
|
||||
t.Fatalf("builders that run different versions: the majority is agreed and ace sits out; got %+v", a)
|
||||
}
|
||||
// Taken off ace again (its key deleted): its report goes again.
|
||||
if !gate.Placement(k, nil, true, placed) {
|
||||
|
||||
@@ -143,7 +143,7 @@ type FailedFirst struct {
|
||||
Words string `json:"words,omitempty"`
|
||||
}
|
||||
|
||||
// MaxFailures is the most entries a verdict's Failures holds (issue 1921): fifty tests named one by one, and an entry
|
||||
// MaxFailures is the most entries a verdict's Failures holds (issue 1921): fifty failures named one by one, and an entry
|
||||
// after them saying how many more failed.
|
||||
const MaxFailures = 50
|
||||
|
||||
@@ -157,10 +157,11 @@ type Verdict struct {
|
||||
Trunk map[string]string `json:"trunk,omitempty"`
|
||||
State string `json:"state"`
|
||||
Failed FailedFirst `json:"failed,omitzero"`
|
||||
// Failures names every test that failed, in the order judged, each with the first line of its words (issue 1921):
|
||||
// Failed keeps the first one's words whole, and a check that ran every test says all it found here. At most
|
||||
// MaxFailures entries, the last of a longer list saying how many more failed. Absent in a value written before it,
|
||||
// and from a verdict whose failure is no test.
|
||||
// Failures names everything that failed, in the order judged, each with the first line of its words (issue 1921): every
|
||||
// failed test, and every member's refused step, conflict, manifest refusal and the composition's failure, since a check
|
||||
// runs every member, level and the composition to its end (the operator, 2026-11-02). Failed keeps the first one's
|
||||
// words whole. At most MaxFailures entries, the last of a longer list saying how many more failed. Absent in a value
|
||||
// written before it, and from a verdict with one failure that is no test.
|
||||
Failures []FailedFirst `json:"failures,omitempty"`
|
||||
Levels map[string]LevelResult `json:"levels,omitempty"`
|
||||
Took Took `json:"took,omitzero"`
|
||||
|
||||
+8
-20
@@ -7,29 +7,17 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The controller's compositions (novox/hq ADR 0539 clauses 1 to 4). A **composition** is one module's resources
|
||||
// composed for one node at one fingerprint, written by the controller, which keeps the inputs. It lives in a bucket of
|
||||
// each node, `composed-<node>`, which only that node's node-engine reads (a composition can carry a sealed value), under
|
||||
// `<node>.<module>.<fingerprint hex>`, and the node's own resources (adoption, firewall openings, accounts, the
|
||||
// resolver, identities and groups) under `<node>.node`. Beside it, under the same key in `mesh-controller_composed`,
|
||||
// which the assigner reads, the controller writes the composition's digest and its judgement of the move from the
|
||||
// composition the node runs now (ComposedDigest). The controller writes both by compare-and-set while it holds the
|
||||
// lease; the operator's credential is a second writer on the hand path alone (clause 10, mesh-hand-compose).
|
||||
//
|
||||
// A composition is derived state (clause 4): its key and its digest are deleted by the controller when its fingerprint
|
||||
// is no longer among the module's five newest and no want names it, and each bucket keeps five revisions of a key.
|
||||
// The compositions (novox/hq ADR 0539 clauses 1 to 4). A **composition** is one module's resources composed for one node
|
||||
// at one fingerprint: a node composes its own (ADR 0547 §2), and the ended controller wrote them into a bucket of each
|
||||
// node, `composed-<node>`, which nothing writes since it ended (ADR 0590), under `<node>.<module>.<fingerprint hex>`, and
|
||||
// the node's own resources (adoption, firewall openings, accounts, the resolver, identities and groups) under
|
||||
// `<node>.node`. A composition's digest and its judgement of the move from the composition the node runs now is
|
||||
// ComposedDigest.
|
||||
const (
|
||||
// ComposedBucketPrefix starts the name of a node's bucket of compositions, ComposedBucketFor(node).
|
||||
ComposedBucketPrefix = "composed-"
|
||||
// ComposedHistory is the revisions a node's composition bucket keeps per key (ADR 0539 clause 4).
|
||||
ComposedHistory = 5
|
||||
// ControllerModule is the module of the controller, the writer of the compositions.
|
||||
ControllerModule = "mesh-controller"
|
||||
// ControllerComposedBucket is the controller's bucket of each composition's digest and judgement, `<owner
|
||||
// module>_<name>`, read by the assigner (ADR 0539 clauses 2 and 6).
|
||||
ControllerComposedBucket = ControllerModule + "_composed"
|
||||
// ControllerComposedHistory is the revisions that bucket keeps per key.
|
||||
ControllerComposedHistory = 5
|
||||
// ComposedKeep is how many of a module's newest fingerprints keep their compositions though no want names them
|
||||
// (ADR 0539 clause 4).
|
||||
ComposedKeep = 5
|
||||
@@ -142,8 +130,8 @@ func checkDigest(digest, of string) error {
|
||||
//
|
||||
// Policy is the module's upgrade policy as the controller holds it (ADR 0236, ADR 0539 clause 14), with PolicyFrom
|
||||
// where it comes from and PolicyWhy why: the assigner holds a move of a module of policy record from it, the operator's
|
||||
// own choice (`upgrade` with policy) included. Rewritten when the policy changes. Empty on a composition made by hand
|
||||
// (mesh-hand-compose), whose want the operator writes.
|
||||
// own choice (`upgrade` with policy) included. Rewritten when the policy changes. Empty on a composition made by hand,
|
||||
// whose want the operator wrote.
|
||||
type ComposedDigest struct {
|
||||
V int `json:"v"`
|
||||
Digest string `json:"digest"`
|
||||
|
||||
@@ -6,9 +6,9 @@ import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// A composition's signature (novox/hq ADR 0539 clause 3): the controller signs each composition it writes with its
|
||||
// key, and the operator signs one made by hand with mesh-hand-compose with theirs. The node-engine trusts exactly these two
|
||||
// signers and applies nothing of a composition whose signature fails or whose signer is neither. The signature is an
|
||||
// A composition's signature (novox/hq ADR 0539 clause 3): the signer of a composition signs it with its key (the ended
|
||||
// controller signed those it wrote, the operator one made by hand). The node-engine trusts the signers it is given and
|
||||
// applies nothing of a composition whose signature fails or whose signer is none of them. The signature is an
|
||||
// ed25519 signature, as a declaration's is, of the composition's digest (ComposedDigestOf) after a context line, so a
|
||||
// signature made for anything else never verifies here.
|
||||
const composedSigningContext = "novox composition v1\n"
|
||||
@@ -84,7 +84,7 @@ func VerifyComposed(c Composed, trusted []ed25519.PublicKey) (string, error) {
|
||||
}
|
||||
}
|
||||
if key == nil {
|
||||
return "", fmt.Errorf("delivery: the composition of %q on %s is signed by %s, which is not a trusted signer (the controller's key or the operator's)",
|
||||
return "", fmt.Errorf("delivery: the composition of %q on %s is signed by %s, which is not a trusted signer (the vault's key or the operator's)",
|
||||
c.Module, c.Node, c.Signature.Signer)
|
||||
}
|
||||
digest, err := ComposedDigestOf(c)
|
||||
|
||||
@@ -108,7 +108,7 @@ func TestACompositionIsSignedByAKeptKey(t *testing.T) {
|
||||
// signers a node trusts and names which; an unknown key and another kind's context are refused.
|
||||
vaultPub, vault := aSigningKey(t)
|
||||
controllerPub, controller := aSigningKey(t)
|
||||
trusted := []Signer{{Name: SignerController, Key: controllerPub}, {Name: SignerVault, Key: vaultPub}}
|
||||
trusted := []Signer{{Name: "operator", Key: controllerPub}, {Name: SignerVault, Key: vaultPub}}
|
||||
enrol, _ := SignContext(KindEnrolment)
|
||||
certCtx, _ := SignContext(KindNodeCertificate)
|
||||
msg := []byte("the reply")
|
||||
@@ -120,7 +120,7 @@ func TestACompositionIsSignedByAKeptKey(t *testing.T) {
|
||||
want string
|
||||
}{
|
||||
{"the vault's key verifies", vault, enrol, enrol, SignerVault},
|
||||
{"the controller's key verifies", controller, enrol, enrol, SignerController},
|
||||
{"the controller's key verifies", controller, enrol, enrol, "operator"},
|
||||
{"an unknown key is refused", other, enrol, enrol, ""},
|
||||
{"the wrong context is refused", vault, certCtx, enrol, ""},
|
||||
} {
|
||||
@@ -148,7 +148,7 @@ func TestACompositionIsSignedByAKeptKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
if got := Trusts(trusted, nil); len(got) != 2 || !TrustsKey(got, SignerVault, SignerFingerprint(vaultPub)) ||
|
||||
!TrustsKey(got, SignerController, SignerFingerprint(controllerPub)) {
|
||||
!TrustsKey(got, "operator", SignerFingerprint(controllerPub)) {
|
||||
t.Errorf("trusts: %v", got)
|
||||
}
|
||||
// A certificate the vault's authority signs for a node's name and serving key checks against a bundle holding that
|
||||
|
||||
@@ -40,6 +40,10 @@ func TestTheControlRecordsReadTheirWritersValues(t *testing.T) {
|
||||
{"a node's health report, a part pointing at the hub", `{"v":1,"node":"anchor","network":{"state":"unhealthy",` +
|
||||
`"since":"2026-10-29T20:00:00Z","parts":[{"part":"tunnel","state":"unhealthy","reason":"the tunnel to the hub has not handshaken for over five minutes",` +
|
||||
`"toward":["hub"],"targets":["novox"],"since":"2026-10-29T20:00:00Z"}]},"at":"2026-10-29T20:00:00Z"}`, &NodeHealth{}},
|
||||
// A node fallen back to its old login (ADR 0583 §6), which the watchman raises as node-account-fallback.
|
||||
{"a node's health report, fallen back to its old login", `{"v":1,"node":"anchor","fallback":{"node":"anchor",` +
|
||||
`"from":"node.anchor--b","why":"the new login's test-dial was refused","at":"2026-11-02T01:00:00Z"},` +
|
||||
`"at":"2026-11-02T01:00:00Z"}`, &NodeHealth{}},
|
||||
{"the predefined list", `{"v":1,"commit":"abc","at":"2026-10-29T12:00:00Z","list":{"domain":{"type":"string"}}}`, &PredefinedKept{}},
|
||||
} {
|
||||
if err := json.Unmarshal([]byte(c.raw), c.into); err != nil {
|
||||
@@ -60,6 +64,10 @@ func TestTheControlRecordsReadTheirWritersValues(t *testing.T) {
|
||||
if _, err := EncodeNodeHealth(NodeHealth{}); err == nil {
|
||||
t.Error("a report naming no node was encoded")
|
||||
}
|
||||
if _, err := EncodeNodeHealth(NodeHealth{Node: "anchor", Fallback: &LoginFallback{Node: "ace", From: "node.anchor--b",
|
||||
Why: "refused", At: time.Now()}}); err == nil {
|
||||
t.Error("a fall back naming another node was encoded")
|
||||
}
|
||||
// ADR 0571 clause 4: who holds which seat, from placement; a node seat by its node.
|
||||
held := SeatHoldersOf(map[string]Placement{
|
||||
"ace": {Node: "ace", Holds: []Holding{{Claim: "mesh-dns-resolver", Scope: "mesh", Module: "dnsmasq"}, {Claim: "node-files", Scope: "node", Module: "mesh-files"}}},
|
||||
@@ -142,6 +150,40 @@ func TestTheControlRecordsReadTheirWritersValues(t *testing.T) {
|
||||
t.Errorf("%s keyed %q, %v", pull, got, err)
|
||||
}
|
||||
}
|
||||
// ADR 0565 §6: the engine works out its node's key of `packages` from the compositions it composed, the mirror's bytes
|
||||
// for the same resources (mesh-controller internal/inventory/declared_packages_test.go aNodesPackages at 0fbb5ef3):
|
||||
// a module's own composition, the node's own with a folded module's resource named by its id alone, and a file.
|
||||
res := func(s ...string) []json.RawMessage {
|
||||
var out []json.RawMessage
|
||||
for _, r := range s {
|
||||
out = append(out, json.RawMessage(r))
|
||||
}
|
||||
return out
|
||||
}
|
||||
comps := []Composed{
|
||||
{Node: "one", Module: "web", Resources: res(`{"id":"pkg-curl","type":"package","package":"curl"}`,
|
||||
`{"id":"pkg-nano","type":"package","package":"nano","absent":true}`, `{"id":"conf","type":"file","path":"/etc/web.conf"}`)},
|
||||
{Node: "one", Resources: res(`{"id":"backup.pkg-curl","type":"package","package":"curl"}`)},
|
||||
}
|
||||
want := `{"node":"one","packages":[{"package":"curl","modules":["backup","web"],"absent":false},` +
|
||||
`{"package":"nano","modules":["web"],"absent":true}]}`
|
||||
got, err := DeclaredPackagesOf("one", comps)
|
||||
if raw, _ := json.Marshal(got); err != nil || string(raw) != want {
|
||||
t.Errorf("the node's packages are %s (%v)", raw, err)
|
||||
}
|
||||
if none, _ := DeclaredPackagesOf("one", nil); string(jsonOf(none)) != `{"node":"one","packages":[]}` || EnginePackagesRead != "mesh-host.packages" {
|
||||
t.Errorf("a node declaring no package is %s", jsonOf(none))
|
||||
}
|
||||
// Their difference with the mirror: a package only one side holds, and one whose modules differ; none when they agree.
|
||||
mirror := DeclaredPackages{Node: "one", Packages: []PackageDeclared{{Package: "curl", Modules: []string{"web"}},
|
||||
{Package: "nano", Modules: []string{"web"}, Absent: true}, {Package: "vim", Modules: []string{"edit"}}}}
|
||||
if d := DiffDeclaredPackages(got, mirror); len(d) != 2 || d[0].Package != "curl" || strings.Join(d[0].Engine, ",") != "backup,web" ||
|
||||
d[1].Package != "vim" || len(d[1].Engine) != 0 || d[1].Mirror[0] != "edit" {
|
||||
t.Errorf("the difference is %+v", d)
|
||||
}
|
||||
if d := DiffDeclaredPackages(got, got); d == nil || len(d) != 0 {
|
||||
t.Errorf("one declaration differs from itself: %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func jsonOf(v any) []byte {
|
||||
|
||||
+14
-15
@@ -367,8 +367,7 @@ type Have struct {
|
||||
// holds now. The bus reloads the file itself when its digest changes (the module's entrypoint), so Digest is the list
|
||||
// the bus loads. Wanted differs from Digest only while a write failed, and Error says why.
|
||||
type UserListWritten struct {
|
||||
// Source is the key the list was taken from: the controller's composition of the bus module for this node
|
||||
// (`<node>.<module>.<fp>`), or the grants part's `user-list` once it took over.
|
||||
// Source is the key the list was taken from: the grants part's `user-list` (GrantsUserListSource).
|
||||
Source string `json:"source"`
|
||||
Revision uint64 `json:"revision"`
|
||||
// Wanted is the digest of the list the source holds, `sha256:<hex>`.
|
||||
@@ -378,8 +377,8 @@ type UserListWritten struct {
|
||||
Users int `json:"users"`
|
||||
At time.Time `json:"at"`
|
||||
Error string `json:"error,omitempty"`
|
||||
// Follows is which list the node follows now (FollowController or FollowGrants, novox/hq ADR 0547 clause 8), empty
|
||||
// from a node-engine before the switch.
|
||||
// Follows is which list the node follows now (FollowGrants, novox/hq ADR 0547 clause 8), empty from a node-engine
|
||||
// before the switch.
|
||||
Follows string `json:"follows,omitempty"`
|
||||
// Key is the bus node's sealing key, its public half as SPKI PEM: the key the grants part seals its list to
|
||||
// (clause 7). Public, as the vault holds it.
|
||||
@@ -417,19 +416,19 @@ type HaveNode struct {
|
||||
// its shadow composer whenever an answer changes, removed when the candidate key goes. The node applies nothing of a
|
||||
// candidate.
|
||||
Candidates map[string]CandidateAnswer `json:"candidates,omitempty"`
|
||||
// Composes is whose compositions the node applies: its own (ComposesOwn, placement places it) or the
|
||||
// controller's (ComposesController); ComposesWhy says why in words. Written by the node agent at each start, so a
|
||||
// node returning to the controller's compositions when its placement key is deleted says so (issue 1126). Empty
|
||||
// from an engine older than the field.
|
||||
// Composes is whose compositions the node applies: its own (ComposesOwn, placement places it), or none, empty, with
|
||||
// ComposesWhy saying why in words. Written by the node agent at each start (issue 1126). An engine from before the
|
||||
// controller ended may still say `controller` (composedByTheEndedController), which is read and never written.
|
||||
Composes string `json:"composes,omitempty"`
|
||||
ComposesWhy string `json:"composes-why,omitempty"`
|
||||
}
|
||||
|
||||
// Whose compositions a node applies (HaveNode.Composes).
|
||||
const (
|
||||
ComposesOwn = "own"
|
||||
ComposesController = "controller"
|
||||
)
|
||||
// ComposesOwn is a node applying its own compositions (HaveNode.Composes).
|
||||
const ComposesOwn = "own"
|
||||
|
||||
// composedByTheEndedController is what an engine from before the controller ended (ADR 0590) wrote in Composes while
|
||||
// it applied the controller's compositions: still read, so its have is not refused, and written by no engine now.
|
||||
const composedByTheEndedController = "controller"
|
||||
|
||||
// Presence is whether a node or a part's instance is online; the entry's own time says since when. The watchman
|
||||
// writes it.
|
||||
@@ -526,9 +525,9 @@ func DecodeHave(data []byte) (Have, error) {
|
||||
|
||||
func (n HaveNode) check() error {
|
||||
switch n.Composes {
|
||||
case "", ComposesOwn, ComposesController:
|
||||
case "", ComposesOwn, composedByTheEndedController:
|
||||
default:
|
||||
return fmt.Errorf("delivery: a node composes %q, neither %s nor %s", n.Composes, ComposesOwn, ComposesController)
|
||||
return fmt.Errorf("delivery: a node composes %q, not %s", n.Composes, ComposesOwn)
|
||||
}
|
||||
for group, a := range n.Candidates {
|
||||
if group == "" {
|
||||
|
||||
@@ -245,13 +245,18 @@ func TestRoundTrip(t *testing.T) {
|
||||
if err != nil || back.Digest != digest || back.LeftOut == nil || back.Follow != FollowGrants {
|
||||
t.Fatalf("decoded %+v, %v", back, err)
|
||||
}
|
||||
for _, bad := range []GrantsUserList{{Follow: "both", Digest: digest, Object: g.Object},
|
||||
// Issue 1126 B4a (ADR 0590): a key written with the lease's flag still reads; the controller's list is no source.
|
||||
if old, err := DecodeGrantsUserList([]byte(`{"v":1,"follow":"grants","digest":"","object":"","sealed_to":"",` +
|
||||
`"node":"novox","revision":3,"users":40,"left_out":[],"at":"2026-10-30T10:00:00Z","lease-handed":true}`)); err != nil || old.Revision != 3 {
|
||||
t.Errorf("a key with the lease's flag decodes %+v, %v", old, err)
|
||||
}
|
||||
for _, bad := range []GrantsUserList{{Follow: "both", Digest: digest, Object: g.Object}, {Follow: "controller"},
|
||||
{Follow: FollowGrants, Digest: digest, Object: "other"}, {Follow: FollowGrants, Digest: "md5:x", Object: "md5:x"}} {
|
||||
if _, err := EncodeGrantsUserList(bad); err == nil {
|
||||
t.Errorf("%+v was written", bad)
|
||||
}
|
||||
}
|
||||
if _, err := EncodeGrantsUserList(GrantsUserList{Follow: FollowController}); err != nil {
|
||||
if _, err := EncodeGrantsUserList(GrantsUserList{Follow: FollowGrants}); err != nil {
|
||||
t.Errorf("a switch with no list yet refused: %v", err)
|
||||
}
|
||||
object, err := SealUserList(list, node.PublicKey)
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
)
|
||||
|
||||
// **A node's declared packages, worked out by its engine** (novox/hq ADR 0565 §1, §6; issue 1953): the derivation and
|
||||
// the comparison of the node-engine's state `packages` (EnginePackagesState, layout DeclaredPackages), so the engine
|
||||
// that writes the key, the node seat's verb that compares it with the controller's mirror, and any reader work them out
|
||||
// one way. The controller's own copy of the derivation (mesh-controller internal/inventory DeclaredPackagesValue) is
|
||||
// not changed (ADR 0547 §1) and ends with its lease.
|
||||
|
||||
// EnginePackagesRead is the engine's state `packages` as a reader names it in `reads`: the node-engine's manifest is
|
||||
// mesh-host's, so its bucket is mesh-host_packages, read as disk-load reads mesh-host.data (EngineDataRead).
|
||||
const EnginePackagesRead = "mesh-host." + EnginePackagesState
|
||||
|
||||
// DeclaredPackagesOf is a node's key of `packages` from the compositions its engine composed for the node's wants:
|
||||
// each resource of type package, by its name and whether it is declared absent, with the modules that declare it so,
|
||||
// in the mirror's layout and order (names sorted, absent after present, modules sorted). A resource's module is the
|
||||
// one it names, else its composition's, else the guess from its id (declaration.ModuleGuess); a package of no module
|
||||
// lists none.
|
||||
func DeclaredPackagesOf(node string, compositions []Composed) (DeclaredPackages, error) {
|
||||
by := map[packageKey]map[string]bool{}
|
||||
for _, c := range compositions {
|
||||
for _, raw := range c.Resources {
|
||||
var r struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Package string `json:"package"`
|
||||
Absent bool `json:"absent"`
|
||||
Module string `json:"module"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &r); err != nil {
|
||||
return DeclaredPackages{}, fmt.Errorf("delivery: a resource of %s's composition %q is not readable: %w", node, c.Module, err)
|
||||
}
|
||||
if r.Type != "package" || r.Package == "" {
|
||||
continue
|
||||
}
|
||||
k := packageKey{r.Package, r.Absent}
|
||||
if by[k] == nil {
|
||||
by[k] = map[string]bool{}
|
||||
}
|
||||
module := r.Module
|
||||
if module == "" {
|
||||
module = c.Module
|
||||
}
|
||||
if module == "" {
|
||||
module, _ = declaration.ModuleGuess(r.ID)
|
||||
}
|
||||
if module != "" {
|
||||
by[k][module] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
v := DeclaredPackages{Node: node, Packages: []PackageDeclared{}}
|
||||
for _, k := range sortedPackageKeys(by) {
|
||||
modules := make([]string, 0, len(by[k]))
|
||||
for m := range by[k] {
|
||||
modules = append(modules, m)
|
||||
}
|
||||
sort.Strings(modules)
|
||||
v.Packages = append(v.Packages, PackageDeclared{Package: k.name, Modules: modules, Absent: k.absent})
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// PackageDifference is one package two declarations of a node hold differently: the modules each side declares it
|
||||
// with (present, or absent when Absent), none on a side that does not hold it.
|
||||
type PackageDifference struct {
|
||||
Package string `json:"package"`
|
||||
Absent bool `json:"absent"`
|
||||
Engine []string `json:"engine"`
|
||||
Mirror []string `json:"mirror"`
|
||||
}
|
||||
|
||||
// DiffDeclaredPackages is every package the engine's key and the controller's mirror hold differently for one node, in
|
||||
// the layout's order: what the node seat's verb packages answers until the lease write (ADR 0565 §6). Empty when they
|
||||
// agree.
|
||||
func DiffDeclaredPackages(engine, mirror DeclaredPackages) []PackageDifference {
|
||||
index := func(d DeclaredPackages) map[packageKey][]string {
|
||||
out := map[packageKey][]string{}
|
||||
for _, p := range d.Packages {
|
||||
out[packageKey{p.Package, p.Absent}] = append([]string{}, p.Modules...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
e, m := index(engine), index(mirror)
|
||||
union := map[packageKey]bool{}
|
||||
for k := range e {
|
||||
union[k] = true
|
||||
}
|
||||
for k := range m {
|
||||
union[k] = true
|
||||
}
|
||||
out := []PackageDifference{}
|
||||
for _, k := range sortedPackageKeys(union) {
|
||||
em, inE := e[k]
|
||||
mm, inM := m[k]
|
||||
if inE && inM && slices.Equal(em, mm) {
|
||||
continue
|
||||
}
|
||||
if em == nil {
|
||||
em = []string{}
|
||||
}
|
||||
if mm == nil {
|
||||
mm = []string{}
|
||||
}
|
||||
out = append(out, PackageDifference{Package: k.name, Absent: k.absent, Engine: em, Mirror: mm})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// packageKey is a package as the layout tells it apart: its name, and whether it is declared absent.
|
||||
type packageKey struct {
|
||||
name string
|
||||
absent bool
|
||||
}
|
||||
|
||||
// sortedPackageKeys is the keys of m by name, absent after present.
|
||||
func sortedPackageKeys[V any](m map[packageKey]V) []packageKey {
|
||||
keys := make([]packageKey, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
if keys[i].name != keys[j].name {
|
||||
return keys[i].name < keys[j].name
|
||||
}
|
||||
return !keys[i].absent && keys[j].absent
|
||||
})
|
||||
return keys
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// **The state of the seat mesh-enrolment** (novox/hq ADR 0587 §2, issue 774): the holder's bucket `enrolments`, holding
|
||||
// no secret, with two kinds of key. `node.<node>`, one per open enrolment, holds an Enrolment; `address.<address>`
|
||||
// reserves one private-network address for one open enrolment and holds the node's name and, from the ask, the ask and the issue's time. Every write is a
|
||||
// compare-and-set at the revision read. The bucket keeps per-key expiry (`key-ttl`): a key at stage live carries the
|
||||
// token's expiry, at asked and redeeming none (§5). It is read by the holder and, as a named reader, by the grants part,
|
||||
// which composes the user `enrol.<node>` from a key at live and the vault's hash (§4). These constants are the seat's
|
||||
// contract: every reader takes the names from here.
|
||||
const (
|
||||
// EnrolmentModule is the module holding the seat mesh-enrolment.
|
||||
EnrolmentModule = catalogue.EnrolmentSeat
|
||||
// EnrolmentsState is its state, EnrolmentsBucket the bucket, EnrolmentsRead the name a reader's manifest declares.
|
||||
EnrolmentsState = "enrolments"
|
||||
EnrolmentsBucket = EnrolmentModule + "_" + EnrolmentsState
|
||||
EnrolmentsRead = EnrolmentModule + "." + EnrolmentsState
|
||||
|
||||
// The stages of an open enrolment (§2, §4, §5).
|
||||
EnrolmentAsked = "asked"
|
||||
EnrolmentLive = "live"
|
||||
EnrolmentRedeeming = "redeeming"
|
||||
|
||||
// DefaultTokenLife is a token's life when issue names none; MostTokenLife the longest it may name (§4).
|
||||
DefaultTokenLife = time.Hour
|
||||
MostTokenLife = 24 * time.Hour
|
||||
|
||||
// EnrolmentUserPrefix is the bus user a joining machine dials as, `enrol.<node>` (§4).
|
||||
EnrolmentUserPrefix = "enrol."
|
||||
|
||||
enrolmentNodePrefix = "node."
|
||||
enrolmentAddressPrefix = "address."
|
||||
)
|
||||
|
||||
// Enrolment is one key `node.<node>` of `enrolments`: the node's name, the private-network address chosen at issue, the
|
||||
// why, the caller as the runtime proved it, the ask's id, the stage, when it was issued and when the token expires, and
|
||||
// from redeem the digest of the public keys presented (KeysDigest). No secret and nothing derived from the token.
|
||||
type Enrolment struct {
|
||||
V int `json:"v"`
|
||||
Node string `json:"node"`
|
||||
Address string `json:"address"`
|
||||
Why string `json:"why"`
|
||||
Caller string `json:"caller"`
|
||||
Ask string `json:"ask,omitempty"`
|
||||
Stage string `json:"stage"`
|
||||
Issued time.Time `json:"issued"`
|
||||
Expires time.Time `json:"expires"`
|
||||
KeysDigest string `json:"keys-digest,omitempty"`
|
||||
// OverlayKey is the machine's tunnel public key, given at issue (the operator's decision of 2026-11-02 01:05),
|
||||
// base64 of 32 bytes: at every stage, since the node enters placement with it on the operator's yes.
|
||||
OverlayKey string `json:"overlay-key"`
|
||||
}
|
||||
|
||||
// EnrolmentAddress is one key `address.<address>`: the node the address is reserved for and, from when the issue's ask
|
||||
// is raised, that ask's id and the issue's time (ADR 0589 §6), so the reservation names the enrolment it holds the
|
||||
// address for. The key is written when the address is reserved, before the ask: Ask and Issued are then both empty, and
|
||||
// from the ask both set; one without the other is refused.
|
||||
type EnrolmentAddress struct {
|
||||
V int `json:"v"`
|
||||
Node string `json:"node"`
|
||||
Ask string `json:"ask,omitempty"`
|
||||
Issued time.Time `json:"issued,omitzero"`
|
||||
}
|
||||
|
||||
func (a EnrolmentAddress) check() error {
|
||||
if err := checkName("node", a.Node); err != nil {
|
||||
return err
|
||||
}
|
||||
if (strings.TrimSpace(a.Ask) == "") != a.Issued.IsZero() {
|
||||
return fmt.Errorf("delivery: the address reserved for %s names its ask without the issue's time, or the reverse", a.Node)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnrolmentIssuedID is the id of the enrolment-issued event of node's enrolment issued at issued:
|
||||
// `enrolment-issued.<node>.<issue time in Unix nanoseconds>`, what enrolment-expired names (EnrolmentExpiry).
|
||||
func EnrolmentIssuedID(node string, issued time.Time) string {
|
||||
return catalogue.EnrolmentIssuedEvent + "." + node + "." + strconv.FormatInt(issued.UnixNano(), 10)
|
||||
}
|
||||
|
||||
// EnrolmentNodeKey is a node's key in `enrolments`.
|
||||
func EnrolmentNodeKey(node string) string { return enrolmentNodePrefix + node }
|
||||
|
||||
// EnrolmentAddressKey is an address's key in `enrolments`: the address as it is, its dots kept (a key's tokens).
|
||||
func EnrolmentAddressKey(address string) string { return enrolmentAddressPrefix + address }
|
||||
|
||||
// EnrolmentKeyNode is the node a key `node.<node>` names, and whether it is one.
|
||||
func EnrolmentKeyNode(key string) (string, bool) {
|
||||
node, ok := strings.CutPrefix(key, enrolmentNodePrefix)
|
||||
return node, ok && checkName("node", node) == nil
|
||||
}
|
||||
|
||||
// EnrolmentKeyAddress is the address a key `address.<address>` names, and whether it is one.
|
||||
func EnrolmentKeyAddress(key string) (string, bool) {
|
||||
address, ok := strings.CutPrefix(key, enrolmentAddressPrefix)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
_, err := netip.ParseAddr(address)
|
||||
return address, err == nil
|
||||
}
|
||||
|
||||
// EnrolmentUser is the bus user a node enrols as.
|
||||
func EnrolmentUser(node string) string { return EnrolmentUserPrefix + node }
|
||||
|
||||
func (e Enrolment) check() error {
|
||||
if err := checkName("node", e.Node); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := netip.ParseAddr(e.Address); err != nil {
|
||||
return fmt.Errorf("delivery: the enrolment of %s names the address %q, which is not an address", e.Node, e.Address)
|
||||
}
|
||||
if strings.TrimSpace(e.Why) == "" || strings.TrimSpace(e.Caller) == "" {
|
||||
return fmt.Errorf("delivery: the enrolment of %s names no why or no caller", e.Node)
|
||||
}
|
||||
if k, err := base64.StdEncoding.DecodeString(e.OverlayKey); err != nil || len(k) != 32 {
|
||||
return fmt.Errorf("delivery: the enrolment of %s names no tunnel key of 32 bytes in base64", e.Node)
|
||||
}
|
||||
switch e.Stage {
|
||||
case EnrolmentAsked, EnrolmentLive:
|
||||
if e.KeysDigest != "" {
|
||||
return fmt.Errorf("delivery: the enrolment of %s at %s names presented keys, which only a redeem gives", e.Node, e.Stage)
|
||||
}
|
||||
case EnrolmentRedeeming:
|
||||
if err := checkDigest(e.KeysDigest, "the enrolment's keys digest"); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("delivery: the enrolment of %s is at stage %q, not asked, live or redeeming", e.Node, e.Stage)
|
||||
}
|
||||
if e.Issued.IsZero() || !e.Expires.After(e.Issued) {
|
||||
return fmt.Errorf("delivery: the enrolment of %s names no issue or an expiry not after it", e.Node)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EncodeEnrolment is e as JSON at Version; it refuses what DecodeEnrolment refuses.
|
||||
func EncodeEnrolment(e Enrolment) ([]byte, error) {
|
||||
if err := e.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.V = Version
|
||||
return json.Marshal(e)
|
||||
}
|
||||
|
||||
// DecodeEnrolment reads a key `node.<node>`; it refuses an unknown version (ErrUnknownVersion), a node that is not a
|
||||
// name, an address that is not one, an unknown stage, presented keys before redeem or none at it, and an expiry not
|
||||
// after the issue.
|
||||
func DecodeEnrolment(data []byte) (Enrolment, error) {
|
||||
var e Enrolment
|
||||
if err := decode(data, &e); err != nil {
|
||||
return Enrolment{}, err
|
||||
}
|
||||
if err := e.check(); err != nil {
|
||||
return Enrolment{}, err
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// EncodeEnrolmentAddress is a key `address.<address>` reserving it for node: with no ask and a zero issued when it is
|
||||
// reserved, with the issue's ask and time once the ask is raised. It refuses what DecodeEnrolmentAddress refuses.
|
||||
func EncodeEnrolmentAddress(node, ask string, issued time.Time) ([]byte, error) {
|
||||
a := EnrolmentAddress{V: Version, Node: node, Ask: ask, Issued: issued}
|
||||
if err := a.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(a)
|
||||
}
|
||||
|
||||
// DecodeEnrolmentAddress reads a key `address.<address>`; it refuses no node, and an ask without the issue's time or
|
||||
// the reverse.
|
||||
func DecodeEnrolmentAddress(data []byte) (EnrolmentAddress, error) {
|
||||
var a EnrolmentAddress
|
||||
if err := decode(data, &a); err != nil {
|
||||
return EnrolmentAddress{}, err
|
||||
}
|
||||
if err := a.check(); err != nil {
|
||||
return EnrolmentAddress{}, err
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// TokenLife is the life issue's `expires` names: DefaultTokenLife when empty, refused when it is not a duration, not
|
||||
// positive or longer than MostTokenLife.
|
||||
func TokenLife(expires string) (time.Duration, error) {
|
||||
if strings.TrimSpace(expires) == "" {
|
||||
return DefaultTokenLife, nil
|
||||
}
|
||||
d, err := time.ParseDuration(strings.TrimSpace(expires))
|
||||
if err != nil || d <= 0 || d > MostTokenLife {
|
||||
return 0, fmt.Errorf("delivery: a token lasts a duration up to %s, such as 1h, not %q", MostTokenLife, expires)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// EnrolmentExpiry is what enrolment-expired carries (§2): the node, and the enrolment-issued event that carried the
|
||||
// key's content, named by its message id (the bundle's publish answers no stream sequence, and §2 asks only that the
|
||||
// event be named), since the bus deleted the key before the holder heard of it.
|
||||
type EnrolmentExpiry struct {
|
||||
V int `json:"v"`
|
||||
Node string `json:"node"`
|
||||
IssuedID string `json:"issued-event"`
|
||||
}
|
||||
|
||||
func (x EnrolmentExpiry) check() error {
|
||||
if err := checkName("node", x.Node); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(x.IssuedID) == "" {
|
||||
return fmt.Errorf("delivery: the expiry of %s's enrolment names no enrolment-issued event", x.Node)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EncodeEnrolmentExpiry is x as JSON at Version; it refuses what DecodeEnrolmentExpiry refuses.
|
||||
func EncodeEnrolmentExpiry(x EnrolmentExpiry) ([]byte, error) {
|
||||
if err := x.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
x.V = Version
|
||||
return json.Marshal(x)
|
||||
}
|
||||
|
||||
// DecodeEnrolmentExpiry reads enrolment-expired's body; it refuses an unknown version, a node that is not a name and
|
||||
// no issued event named.
|
||||
func DecodeEnrolmentExpiry(data []byte) (EnrolmentExpiry, error) {
|
||||
var x EnrolmentExpiry
|
||||
if err := decode(data, &x); err != nil {
|
||||
return EnrolmentExpiry{}, err
|
||||
}
|
||||
if err := x.check(); err != nil {
|
||||
return EnrolmentExpiry{}, err
|
||||
}
|
||||
return x, nil
|
||||
}
|
||||
|
||||
// **Redeem** (§6): what a joining node-engine sends, and what the holder answers. Shared by the holder and the engine,
|
||||
// which live in two repositories.
|
||||
|
||||
// Redeem is the redeem verb's call: the node, the public halves of its keys and its identity key's signature over
|
||||
// RedeemProof. No token: the bus's login check verified it.
|
||||
type Redeem struct {
|
||||
Node string `json:"node"`
|
||||
PublicKey string `json:"public_key"`
|
||||
OverlayKey string `json:"overlay_key,omitempty"`
|
||||
SealingKey string `json:"sealing_key"`
|
||||
ServingKey string `json:"serving_key,omitempty"`
|
||||
Profile map[string]any `json:"profile,omitempty"`
|
||||
Tunnel json.RawMessage `json:"tunnel,omitempty"`
|
||||
Proof string `json:"proof"`
|
||||
}
|
||||
|
||||
// RedeemProof is the bytes a node signs with its identity key when it redeems: its name and every public key it
|
||||
// presents, so a proof cannot be moved to another node or other keys. The holder builds the same bytes.
|
||||
func RedeemProof(node, public, overlay, sealing, serving string) []byte {
|
||||
return []byte("novox-mesh-redeem v1\x00" + node + "\x00" + public + "\x00" + overlay + "\x00" + sealing + "\x00" + serving)
|
||||
}
|
||||
|
||||
// KeysDigest is the digest of the public keys a redeem presents, `sha256:<hex>`: what the key at redeeming and the
|
||||
// events carry, the one hash an event carries (§1).
|
||||
func KeysDigest(public, overlay, sealing, serving string) string {
|
||||
sum := sha256.Sum256([]byte(strings.Join([]string{public, overlay, sealing, serving}, "\x00")))
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// KeysDigest is r's keys' digest.
|
||||
func (r Redeem) KeysDigest() string {
|
||||
return KeysDigest(r.PublicKey, r.OverlayKey, r.SealingKey, r.ServingKey)
|
||||
}
|
||||
|
||||
// ProofBytes is r's proof decoded from base64.
|
||||
func (r Redeem) ProofBytes() ([]byte, error) { return base64.StdEncoding.DecodeString(r.Proof) }
|
||||
|
||||
// RedeemAnswer is what redeem answers (§6): the node, the login it dials as next, its account sealed to the sealing key it
|
||||
// presented (base64), its certificate as PEM, the vault's public signing half (base64), and the vault's signature over
|
||||
// SignedBytes under the context of KindEnrolment. The engine refuses an answer not signed by the key its token carries.
|
||||
type RedeemAnswer struct {
|
||||
Node string `json:"node"`
|
||||
Login string `json:"login"`
|
||||
Account string `json:"account"`
|
||||
Certificate string `json:"certificate"`
|
||||
Signer string `json:"signer"`
|
||||
Signature string `json:"signature,omitempty"`
|
||||
}
|
||||
|
||||
// SignedBytes is what the vault signs of a: a without its signature, as JSON.
|
||||
func (a RedeemAnswer) SignedBytes() []byte {
|
||||
a.Signature = ""
|
||||
raw, _ := json.Marshal(a)
|
||||
return raw
|
||||
}
|
||||
|
||||
// **The vault's enrolment verbs** (ADR 0561 §5, ADR 0587 §3, §5), asked by mesh-enrolment alone.
|
||||
const (
|
||||
EnrolmentTokenVerb = "enrolment-token"
|
||||
NodeAccountVerb = "node-account"
|
||||
)
|
||||
|
||||
// EnrolmentTokenAnswer is what enrolment-token answers: what the joining machine must trust (the bus's address, its
|
||||
// certificate's fingerprint, the vault's public signing key in base64), never the token; Sealed holds the token sealed
|
||||
// to the operator key when asked with sealed, relayed by tokens as it is; Dropped says a drop left neither the token
|
||||
// nor its hash.
|
||||
type EnrolmentTokenAnswer struct {
|
||||
Node string `json:"node"`
|
||||
Broker string `json:"broker,omitempty"`
|
||||
Fingerprint string `json:"fingerprint,omitempty"`
|
||||
Signer string `json:"signer,omitempty"`
|
||||
Sealed string `json:"sealed,omitempty"`
|
||||
Dropped bool `json:"dropped,omitempty"`
|
||||
}
|
||||
|
||||
// NodeAccountAnswer is what node-account answers: the login minted, the account sealed to the sealing key the node
|
||||
// presented (base64), never in the clear; Retired says a retire left no account.
|
||||
type NodeAccountAnswer struct {
|
||||
Node string `json:"node"`
|
||||
Login string `json:"login,omitempty"`
|
||||
Account string `json:"account,omitempty"`
|
||||
Retired bool `json:"retired,omitempty"`
|
||||
}
|
||||
@@ -11,6 +11,10 @@ import (
|
||||
// SourcePrefix2 opens every source fingerprint of the new form.
|
||||
const SourcePrefix2 = "src2:"
|
||||
|
||||
// NoPackageVersion opens the reason a module has no fingerprint while a mesh package it uses has no published version
|
||||
// the indexer holds (novox/hq ADR 0573 §2); the packages follow, by module path.
|
||||
const NoPackageVersion = "no published version of the package "
|
||||
|
||||
// Toolchain2 is what an artifact's toolchain enters the new fingerprint as (novox/hq ADR 0405 clause 7): the
|
||||
// artifact's language, the installed toolchain's version and the build module's recipe version, in place of the
|
||||
// toolchain image's digest. A toolchain update so changes the fingerprints of that language's modules only.
|
||||
@@ -37,15 +41,19 @@ type SourceInputs2 struct {
|
||||
Contexts map[string]string
|
||||
Locks map[string]string
|
||||
Toolchains map[string]Toolchain2
|
||||
Unpinned string
|
||||
// Packages are the version of each mesh package the module uses, by package (novox/hq ADR 0573 §2): a package with
|
||||
// no version gives no fingerprint. None leaves the fingerprint as it was before packages entered it.
|
||||
Packages map[string]string
|
||||
Unpinned string
|
||||
}
|
||||
|
||||
// SourceFingerprint2 is the module's source fingerprint of the new form, `src2:` and the hex sha256 of its inputs'
|
||||
// lines, or none and the reason when the source does not pin the build: Unpinned when set, "no tree" when Tree is
|
||||
// empty, "no lock" when an artifact resolves packages and has no lock. It is a pure function of its inputs: the order
|
||||
// of maps and a base given twice change nothing. The lines are "module <m>", "tree <t>" and "builder <fp>", then
|
||||
// "base <fp>" (once each), "context <artifact> <tree>", "lock <artifact> <hash>" and "toolchain <artifact> <language>
|
||||
// <version> <recipe>", sorted, all joined by a newline.
|
||||
// "base <fp>" (once each), "context <artifact> <tree>", "lock <artifact> <hash>", "toolchain <artifact> <language>
|
||||
// <version> <recipe>" and "package <package> <version>", sorted, all joined by a newline. A module that uses no mesh
|
||||
// package has no package line, so its fingerprint is the one it had before packages entered (novox/hq ADR 0573 §2).
|
||||
func SourceFingerprint2(in SourceInputs2) (fp, none string) {
|
||||
if in.Unpinned != "" {
|
||||
return "", in.Unpinned
|
||||
@@ -58,6 +66,16 @@ func SourceFingerprint2(in SourceInputs2) (fp, none string) {
|
||||
return "", "no lock"
|
||||
}
|
||||
}
|
||||
var unpublished []string
|
||||
for p, v := range in.Packages {
|
||||
if v == "" {
|
||||
unpublished = append(unpublished, p)
|
||||
}
|
||||
}
|
||||
if len(unpublished) > 0 {
|
||||
sort.Strings(unpublished)
|
||||
return "", NoPackageVersion + strings.Join(unpublished, ", ")
|
||||
}
|
||||
lines := []string{"module " + in.Module, "tree " + in.Tree, "builder " + in.Builder}
|
||||
bases := map[string]bool{}
|
||||
for _, b := range in.Bases {
|
||||
@@ -75,6 +93,9 @@ func SourceFingerprint2(in SourceInputs2) (fp, none string) {
|
||||
for a, t := range in.Toolchains {
|
||||
lines = append(lines, "toolchain "+a+" "+t.Language+" "+t.Version+" "+t.Recipe)
|
||||
}
|
||||
for p, v := range in.Packages {
|
||||
lines = append(lines, "package "+p+" "+v)
|
||||
}
|
||||
sort.Strings(lines[3:])
|
||||
sum := sha256.Sum256([]byte(strings.Join(lines, "\n")))
|
||||
return SourcePrefix2 + hex.EncodeToString(sum[:]), ""
|
||||
|
||||
@@ -62,6 +62,9 @@ func TestFingerprint2Changes(t *testing.T) {
|
||||
"a context": func(in *SourceInputs2) { in.Contexts["mail"] = "t-changed" },
|
||||
"a context added": func(in *SourceInputs2) { in.Contexts["extra"] = "t-root" },
|
||||
"a toolchain added": func(in *SourceInputs2) { in.Toolchains["extra"] = Toolchain2{"go", "1.26.0", "3"} },
|
||||
// Issue 1585 (ADR 0573 §2): a package's new published version, and a package used at all.
|
||||
"a package added": func(in *SourceInputs2) { in.Packages = map[string]string{"git.novox.be/novox/mesh-sdk/go": "c1"} },
|
||||
"a package version": func(in *SourceInputs2) { in.Packages = map[string]string{"git.novox.be/novox/mesh-sdk/go": "c2"} },
|
||||
} {
|
||||
in := inputs2()
|
||||
f(&in)
|
||||
@@ -111,6 +114,17 @@ func TestFingerprint2None(t *testing.T) {
|
||||
if fp, none := SourceFingerprint2(in); fp != "" || none != "no tree" {
|
||||
t.Errorf("no tree: fp = %q, none = %q", fp, none)
|
||||
}
|
||||
// Issue 1585: a package with no published version gives none, naming it; an empty set of packages changes nothing.
|
||||
in = inputs2()
|
||||
in.Packages = map[string]string{"git.novox.be/novox/b/go": "", "git.novox.be/novox/a/go": "", "git.novox.be/novox/c/go": "c1"}
|
||||
if fp, none := SourceFingerprint2(in); fp != "" || none != NoPackageVersion+"git.novox.be/novox/a/go, git.novox.be/novox/b/go" {
|
||||
t.Errorf("no package version: fp = %q, none = %q", fp, none)
|
||||
}
|
||||
in = inputs2()
|
||||
in.Packages = map[string]string{}
|
||||
if fp, _ := SourceFingerprint2(in); fp != mustFP2(t, inputs2()) {
|
||||
t.Errorf("no package changes the fingerprint: %s", fp)
|
||||
}
|
||||
in = inputs2()
|
||||
in.Tree, in.Unpinned = "", "a context is a Go program's build source"
|
||||
if _, none := SourceFingerprint2(in); none != in.Unpinned {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -163,4 +164,35 @@ func TestModuleKeepsInputs2(t *testing.T) {
|
||||
if _, err := EncodeModule(m); err == nil {
|
||||
t.Error("Inputs2 without a manifest digest was encoded")
|
||||
}
|
||||
|
||||
// Issue 1585 (ADR 0573 §2): the mesh packages a go.sum names, those of other repositories, and the latest published
|
||||
// version of each: the trunk commit of the deepest module of its repository whose directory holds it.
|
||||
sum := "git.novox.be/novox/mesh-sdk/go v0.1.7 h1:a=\ngit.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:b=\n" +
|
||||
"git.novox.be/novox/mesh-controller/modules/route-proxy/contract v0.1.0 h1:c=\n" +
|
||||
"github.com/nats-io/nats.go v1.0.0 h1:d=\ngit.novox.be/novox/mesh-host v0.0.0-1 h1:e=\n"
|
||||
pkgs := MeshPackagesOf([]byte(sum))
|
||||
if want := []string{"git.novox.be/novox/mesh-controller/modules/route-proxy/contract", "git.novox.be/novox/mesh-host", "git.novox.be/novox/mesh-sdk/go"}; !reflect.DeepEqual(pkgs, want) {
|
||||
t.Errorf("the packages of a go.sum: %v, want %v", pkgs, want)
|
||||
}
|
||||
if got := OtherPackages(pkgs, "novox/mesh-controller"); len(got) != 2 || got[0] != "git.novox.be/novox/mesh-host" {
|
||||
t.Errorf("the packages of other repositories: %v", got)
|
||||
}
|
||||
held := map[string]Module{
|
||||
"mesh-sdk": {Repository: "novox/mesh-sdk", Path: RootPath, Commit: "c-sdk"},
|
||||
"route-proxy": {Repository: "novox/mesh-controller", Path: "modules/route-proxy", Commit: "c-rp"},
|
||||
"controller": {Repository: "novox/mesh-controller", Path: RootPath, Commit: "c-ctl"},
|
||||
"route-proxy2": {Repository: "novox/mesh-controller", Path: "modules/route", Commit: "c-wrong"},
|
||||
"no-path": {Repository: "novox/mesh-host", Commit: "c-host"},
|
||||
}
|
||||
versions, missing := PackageVersionsOf(pkgs, held)
|
||||
if want := (PackageVersions{"git.novox.be/novox/mesh-sdk/go": "c-sdk", "git.novox.be/novox/mesh-controller/modules/route-proxy/contract": "c-rp"}); !reflect.DeepEqual(versions, want) {
|
||||
t.Errorf("versions %v, want %v", versions, want)
|
||||
}
|
||||
if len(missing) != 1 || missing[0] != "git.novox.be/novox/mesh-host" {
|
||||
t.Errorf("missing %v: a module that names no directory holds no package", missing)
|
||||
}
|
||||
kept.Packages = []string{"git.novox.be/novox/mesh-sdk/go", "git.novox.be/novox/mesh-host"}
|
||||
if fp, none := SourceFingerprint2(kept.WithPackages("m", tc, versions)); fp != "" || !strings.HasPrefix(none, NoPackageVersion) {
|
||||
t.Errorf("a package with no version: %q %q", fp, none)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,17 +356,16 @@ func TestGateSame(t *testing.T) { // 1201/AC6
|
||||
}
|
||||
}
|
||||
|
||||
func TestGateBuckets(t *testing.T) {
|
||||
// today's keys: names of letters, digits, - / and _ key exactly as before the escape.
|
||||
func TestKeysOfPlainNamesAreUnchanged(t *testing.T) { // 1323/AC2
|
||||
// And the buckets and their history are the decided layout (once TestGateBuckets; merged to pay for issue 774's
|
||||
// token test).
|
||||
if MergesBucket != "mesh-delivery-gate_merges" || AsksBucket != "mesh-delivery-gate_asks" ||
|
||||
OwedBucket != "mesh-delivery-gate_owed" || AnswersBucket != "mesh-delivery-teller_answers" ||
|
||||
MergesHistory != 10 || AsksHistory != 5 || OwedHistory != 5 || AnswersHistory != 5 ||
|
||||
TurnsBucket != "mesh-delivery-gate_turns" || TurnsHistory != 5 {
|
||||
t.Error("a bucket or history differs from the decided layout")
|
||||
}
|
||||
}
|
||||
|
||||
// today's keys: names of letters, digits, - / and _ key exactly as before the escape.
|
||||
func TestKeysOfPlainNamesAreUnchanged(t *testing.T) { // 1323/AC2
|
||||
hh, _ := HeadsHash(gateHeads())
|
||||
for _, name := range []string{"feat/x", "fix/1323-escape", "feat/delivery_source-keys", "main", "A/B_c-9"} {
|
||||
if k, err := MergesKey(name); err != nil || k != name {
|
||||
|
||||
+30
-63
@@ -7,41 +7,44 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The controller's grants (novox/hq ADR 0528 clause 2, ADR 0429 §6 rule 6): when the controller has issued a module's
|
||||
// bus credential for a fingerprint, it writes `<module>.<fingerprint hex>` in its bucket grants. The assigner reads
|
||||
// it: a node's want moves to a fingerprint only once its grant exists. Only the controller writes the bucket.
|
||||
//
|
||||
// The names say Controller, since the catalogue's GrantKey (catalogue/streams.go) is a stream's grant pattern, unrelated.
|
||||
// **The grants issued** (novox/hq ADR 0528 clause 2, ADR 0429 §6 rule 6, ADR 0547 §4, §5, ADR 0590): when the grants
|
||||
// part has issued a module's bus credentials for a fingerprint, it writes `<module>.<fingerprint hex>` in its state
|
||||
// `grants` (PartGrantsBucket). The assigner reads it: a node's want moves to a fingerprint only once its grant exists.
|
||||
// Only the grants part writes the bucket.
|
||||
const (
|
||||
// ControllerGrantsBucket is the controller's bucket of issued credentials by fingerprint.
|
||||
ControllerGrantsBucket = "mesh-controller_grants"
|
||||
// ControllerGrantsHistory is the revisions the bucket keeps per key.
|
||||
ControllerGrantsHistory = 5
|
||||
// PartGrantsState is the grants part's state of issued credentials by fingerprint, as its manifest names it.
|
||||
PartGrantsState = "grants"
|
||||
// PartGrantsBucket is that state's bucket, `<owner module>_<name>`.
|
||||
PartGrantsBucket = "mesh-grants_" + PartGrantsState
|
||||
// PartGrantsRead is that state as a reader names it in `reads`.
|
||||
PartGrantsRead = "mesh-grants." + PartGrantsState
|
||||
// PartGrantsHistory is the revisions the bucket keeps per key.
|
||||
PartGrantsHistory = 5
|
||||
)
|
||||
|
||||
// ControllerGrantKey is the key, in ControllerGrantsBucket, of a module's credential issued for a fingerprint:
|
||||
// `<module>.<hex>`, the hex being the fingerprint's key part (FingerprintKeyPart).
|
||||
func ControllerGrantKey(module, fingerprint string) (string, error) {
|
||||
// PartGrantKey is the key, in PartGrantsBucket, of a module's credential issued for a fingerprint: `<module>.<hex>`,
|
||||
// the hex being the fingerprint's key part (FingerprintKeyPart).
|
||||
func PartGrantKey(module, fingerprint string) (string, error) {
|
||||
return BuilderBuildKey(module, fingerprint)
|
||||
}
|
||||
|
||||
// ParseControllerGrantKey is the inverse of ControllerGrantKey; it refuses a want or held key, naming it.
|
||||
func ParseControllerGrantKey(key string) (module, fingerprint string, err error) {
|
||||
// ParsePartGrantKey is the inverse of PartGrantKey; it refuses a want or held key, naming it.
|
||||
func ParsePartGrantKey(key string) (module, fingerprint string, err error) {
|
||||
if module, fingerprint, err = ParseBuilderBuildKey(key); err != nil {
|
||||
return "", "", fmt.Errorf("delivery: %q is not a grant key, <module>.<fingerprint hex>: %w", key, err)
|
||||
}
|
||||
return module, fingerprint, nil
|
||||
}
|
||||
|
||||
// ControllerGrant is the value of a grant key: the bus accounts (their public keys) the module's processes were issued
|
||||
// Grant is the value of a grant key: the bus accounts (their public keys) the module's processes were issued
|
||||
// for that fingerprint, none for a module whose grants the tool runner's account carries, so a reissue for another
|
||||
// account is a new revision. The entry's own time says when it was issued.
|
||||
type ControllerGrant struct {
|
||||
type Grant struct {
|
||||
V int `json:"v"`
|
||||
Accounts []string `json:"accounts,omitempty"`
|
||||
}
|
||||
|
||||
func (g ControllerGrant) check() error {
|
||||
func (g Grant) check() error {
|
||||
for _, a := range g.Accounts {
|
||||
if strings.TrimSpace(a) == "" || strings.ContainsAny(a, " \t\n") {
|
||||
return fmt.Errorf("delivery: a grant names an empty or spaced account %q", a)
|
||||
@@ -50,8 +53,8 @@ func (g ControllerGrant) check() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// EncodeControllerGrant is g as JSON at Version; it refuses an empty or spaced account.
|
||||
func EncodeControllerGrant(g ControllerGrant) ([]byte, error) {
|
||||
// EncodeGrant is g as JSON at Version; it refuses an empty or spaced account.
|
||||
func EncodeGrant(g Grant) ([]byte, error) {
|
||||
if err := g.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -59,26 +62,26 @@ func EncodeControllerGrant(g ControllerGrant) ([]byte, error) {
|
||||
return json.Marshal(g)
|
||||
}
|
||||
|
||||
// DecodeControllerGrant reads a grant; it refuses an unknown version (ErrUnknownVersion) and an empty or spaced account.
|
||||
func DecodeControllerGrant(data []byte) (ControllerGrant, error) {
|
||||
var g ControllerGrant
|
||||
// DecodeGrant reads a grant; it refuses an unknown version (ErrUnknownVersion) and an empty or spaced account.
|
||||
func DecodeGrant(data []byte) (Grant, error) {
|
||||
var g Grant
|
||||
if err := decode(data, &g); err != nil {
|
||||
return ControllerGrant{}, err
|
||||
return Grant{}, err
|
||||
}
|
||||
if err := g.check(); err != nil {
|
||||
return ControllerGrant{}, err
|
||||
return Grant{}, err
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
// SameControllerGrant says whether two grants are the same value.
|
||||
func SameControllerGrant(a, b ControllerGrant) bool { return reflect.DeepEqual(a, b) }
|
||||
// SameGrant says whether two grants are the same value.
|
||||
func SameGrant(a, b Grant) bool { return reflect.DeepEqual(a, b) }
|
||||
|
||||
// NamesNode says whether the grant names module's accounts on node: its own `<node>.<module>` or a process's
|
||||
// `<node>.<module>.<process>`. A grant that names no account (a module that does not speak on the bus) names every
|
||||
// node. The assigner wants a node only once the grant names its account (issue 1945), and a put waits for it (ADR
|
||||
// 0546 clause 4).
|
||||
func (g ControllerGrant) NamesNode(node, module string) bool {
|
||||
func (g Grant) NamesNode(node, module string) bool {
|
||||
if len(g.Accounts) == 0 {
|
||||
return true
|
||||
}
|
||||
@@ -90,39 +93,3 @@ func (g ControllerGrant) NamesNode(node, module string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// **The grants part's grants** (novox/hq ADR 0547 §4, §5, ADR 0561 §2, §6; issue 774). The grants part writes the same
|
||||
// keys, with the same value, into its own state `grants` (the bucket PartGrantsBucket), beside the controller's: first in
|
||||
// shadow, which nobody reads, so a tool can compare the two before anything switches. The assigner reads the part's keys
|
||||
// only once the controller's lease is handed away (GrantSource), and the controller's until then.
|
||||
const (
|
||||
// PartGrantsState is the grants part's state of issued credentials by fingerprint, as its manifest names it.
|
||||
PartGrantsState = "grants"
|
||||
// PartGrantsBucket is that state's bucket, `<owner module>_<name>`.
|
||||
PartGrantsBucket = "mesh-grants_" + PartGrantsState
|
||||
// PartGrantsRead is that state as a reader names it in `reads`.
|
||||
PartGrantsRead = "mesh-grants." + PartGrantsState
|
||||
// PartGrantsHistory is the revisions the bucket keeps per key.
|
||||
PartGrantsHistory = ControllerGrantsHistory
|
||||
)
|
||||
|
||||
// PartGrantKey is the key, in PartGrantsBucket, of a module's credential issued for a fingerprint: the layout of the
|
||||
// controller's key (ControllerGrantKey), so a key of one is the key of the other and a compare is by key.
|
||||
func PartGrantKey(module, fingerprint string) (string, error) {
|
||||
return ControllerGrantKey(module, fingerprint)
|
||||
}
|
||||
|
||||
// ParsePartGrantKey is the inverse of PartGrantKey.
|
||||
func ParsePartGrantKey(key string) (module, fingerprint string, err error) {
|
||||
return ParseControllerGrantKey(key)
|
||||
}
|
||||
|
||||
// GrantSource names the bucket the assigner reads grants from, and the `reads` name that grants it: the grants part's
|
||||
// once the controller's lease is handed away (buscompose.LeaseHandedAway, which refuses every other value of the lease,
|
||||
// an unreadable one included), the controller's in every other case.
|
||||
func GrantSource(leaseHandedAway bool) (bucket, read string) {
|
||||
if leaseHandedAway {
|
||||
return PartGrantsBucket, PartGrantsRead
|
||||
}
|
||||
return ControllerGrantsBucket, "mesh-controller.grants"
|
||||
}
|
||||
|
||||
+10
-10
@@ -8,19 +8,19 @@ import (
|
||||
)
|
||||
|
||||
// Whether the artifact store still holds what a done key names (novox/hq issue 1937). A build module pushes a bundle or
|
||||
// an archive as a bare blob (ADR 0498 §1), and the store's nightly collector deletes every blob no manifest names; only
|
||||
// the controller's take-in and sweep hold one by its manifest. A done key the controller has not taken in can therefore
|
||||
// name a blob that is gone, and reusing it sends a definition every machine fails on. So the builder asks before it
|
||||
// reuses a done key, and the controller says when a take-in found a gone blob.
|
||||
// an archive as a bare blob (ADR 0498 §1), and the store's nightly collector deletes every blob no manifest names. A done
|
||||
// key can therefore name a blob that is gone, and reusing it sends a definition every machine fails on. So the builder
|
||||
// asks before it reuses a done key, and the artifact sweep says when it found a gone blob. Both are the seat
|
||||
// mesh-artifact-sweep's (novox/hq ADR 0571 §2, ADR 0590): the controller that served them first has ended.
|
||||
const (
|
||||
// HeldVerb is the controller seat's verb the builder asks, as a module's invokes names it: references in, each
|
||||
// HeldVerb is the artifact sweep seat's verb the builder asks, as a module's invokes names it: references in, each
|
||||
// answered held or gone.
|
||||
HeldVerb = "seat:mesh-controller.held"
|
||||
// ArtifactsGoneEvent is the controller seat's event said when a take-in is refused because the store does not hold
|
||||
// an artifact the key names: as a consumer names it, `mesh-controller.artifacts-gone`.
|
||||
HeldVerb = "seat:mesh-artifact-sweep.held"
|
||||
// ArtifactsGoneEvent is the artifact sweep seat's event said when the store does not hold an artifact a key names:
|
||||
// as a consumer names it, `mesh-artifact-sweep.artifacts-gone`.
|
||||
ArtifactsGoneEvent = "artifacts-gone"
|
||||
// ArtifactsGoneSubject is where it is published (the seat's event subject).
|
||||
ArtifactsGoneSubject = "mesh.seat.mesh-controller.event." + ArtifactsGoneEvent
|
||||
ArtifactsGoneSubject = "mesh.seat.mesh-artifact-sweep.event." + ArtifactsGoneEvent
|
||||
)
|
||||
|
||||
// HeldAnswer is the answer of HeldVerb: the references the store holds, and those it answered 404 for. A reference the
|
||||
@@ -69,7 +69,7 @@ func DecodeHeldAnswer(raw []byte, asked []string) (HeldAnswer, error) {
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// ArtifactsGone is the body of ArtifactsGoneEvent: the module whose builder's key the controller would not take in,
|
||||
// ArtifactsGone is the body of ArtifactsGoneEvent: the module whose builder's key names a gone artifact,
|
||||
// the key and its fingerprint, and every reference of it the store does not hold.
|
||||
type ArtifactsGone struct {
|
||||
Event string `json:"event"`
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -55,30 +56,13 @@ func TestArtifactsGoneRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The verb and the event are on the controller seat's contract under the names the builder uses.
|
||||
func TestHeldOnTheControllerSeat(t *testing.T) {
|
||||
if HeldVerb != "seat:"+catalogue.ControllerSeatName+".held" {
|
||||
t.Fatalf("HeldVerb %q", HeldVerb)
|
||||
// The verb and the event are on the artifact sweep seat's contract under the names the builder uses (ADR 0571 §2).
|
||||
func TestHeldOnTheArtifactSweepSeat(t *testing.T) {
|
||||
seat, ok := catalogue.SeatNamed(catalogue.ArtifactSweepSeat)
|
||||
if !ok || HeldVerb != "seat:"+seat.Name+".held" || ArtifactsGoneSubject != "mesh.seat."+seat.Name+".event."+ArtifactsGoneEvent {
|
||||
t.Fatalf("HeldVerb %q, ArtifactsGoneSubject %q", HeldVerb, ArtifactsGoneSubject)
|
||||
}
|
||||
found := false
|
||||
for _, v := range catalogue.ControllerVerbs {
|
||||
if v.Name == "held" {
|
||||
found = true
|
||||
}
|
||||
if !slices.ContainsFunc(seat.Serves, func(v catalogue.Verb) bool { return v.Name == "held" }) || !slices.Contains(seat.Emits, ArtifactsGoneEvent) {
|
||||
t.Fatalf("the sweep seat serves %v and emits %v", seat.Serves, seat.Emits)
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("the controller seat declares no verb held")
|
||||
}
|
||||
for _, s := range catalogue.DefaultSeats() {
|
||||
if s.Name != catalogue.ControllerSeatName {
|
||||
continue
|
||||
}
|
||||
for _, e := range s.Emits {
|
||||
if e == ArtifactsGoneEvent {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("the controller seat emits %v, not %s", s.Emits, ArtifactsGoneEvent)
|
||||
}
|
||||
t.Fatal("no controller seat")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// **A node's two logins** (novox/hq ADR 0583, issue 774). A node's bus account rotates between the login `node.<n>` and
|
||||
// `node.<n>--b`, each with the node's permissions and its inbox `_INBOX.node.<n>` unchanged (§1). Which one a node uses
|
||||
// is the vault's state `node-logins`, one key per node (§1, insight 2026-11-01), written by the vault alone: the login
|
||||
// in use, the login rotating if any, and the rotation's stage. Each node-engine reads its own node's key alone; the
|
||||
// grants part reads them all and composes both logins while a node rotates (§2). The key lives while the node does:
|
||||
// its rotating part is cleared at rotation-done or abandon, and the key deleted when the node leaves placement.
|
||||
const (
|
||||
NodeLoginsState = "node-logins"
|
||||
NodeLoginsBucket = VaultModule + "_" + NodeLoginsState
|
||||
NodeLoginsRead = VaultModule + "." + NodeLoginsState
|
||||
|
||||
// The stages of a rotation (§2-§4): begun when the vault made the other login's value and wrote it rotating; ready
|
||||
// when the bus node's have shows a list holding both, said node-account-ready; confirmed when the engine called
|
||||
// node-account-confirm as the new login. Done (rotation-done) is no stage: the rotating part is cleared.
|
||||
RotationBegun = "begun"
|
||||
RotationReady = "ready"
|
||||
RotationConfirmed = "confirmed"
|
||||
|
||||
// The vault's rotation verbs on its seat (§2, §3, §5).
|
||||
RotateNodeVerb = "rotate-node"
|
||||
NodeAccountConfirmVerb = "node-account-confirm"
|
||||
AbandonVerb = "abandon"
|
||||
|
||||
// The vault's events of a rotation, said as its own (§2, §4), each carrying the node's NodeLogin.
|
||||
RotationBegunEvent = "rotation-begun"
|
||||
NodeAccountReadyEvent = "node-account-ready"
|
||||
RotationDoneEvent = "rotation-done"
|
||||
|
||||
// NodeAccountFallbackFinding is what a node-engine says when it fell back to its old login after repeated
|
||||
// authentication failures with the new one, before rotation-done; the watchman raises it (§6).
|
||||
NodeAccountFallbackFinding = "node-account-fallback"
|
||||
|
||||
nodeLoginPrefix = "node."
|
||||
// SecondLoginSuffix marks a node's second login, as a module's account rotates over `--b` (ADR 0114).
|
||||
SecondLoginSuffix = "--b"
|
||||
)
|
||||
|
||||
// RotationEventSubject is where the vault says event of a rotation.
|
||||
func RotationEventSubject(event string) string { return "mesh.mod." + VaultModule + ".event." + event }
|
||||
|
||||
// NodeLogin is one key of `node-logins`, the node's name: the login in use, the login rotating to (empty when none, and
|
||||
// then no stage), the rotation's stage, since when the key says what it says, and the user list's revision that held
|
||||
// both logins, from ready on.
|
||||
type NodeLogin struct {
|
||||
V int `json:"v"`
|
||||
Node string `json:"node"`
|
||||
Login string `json:"login"`
|
||||
Rotating string `json:"rotating,omitempty"`
|
||||
Stage string `json:"stage,omitempty"`
|
||||
Since time.Time `json:"since"`
|
||||
ListRevision uint64 `json:"list-revision,omitempty"`
|
||||
// Acts are the hand-act ids that warranted the node's rotations, oldest first (ADR 0583 §2): the vault records each.
|
||||
Acts []string `json:"acts,omitempty"`
|
||||
}
|
||||
|
||||
// FirstLogin is a node's first login, `node.<n>`, the one every node had before rotation; SecondLogin its other.
|
||||
func FirstLogin(node string) string { return nodeLoginPrefix + node }
|
||||
func SecondLogin(node string) string { return nodeLoginPrefix + node + SecondLoginSuffix }
|
||||
|
||||
// OtherLogin is the login a node rotates to from login: the second from the first, the first from the second.
|
||||
func OtherLogin(node, login string) string {
|
||||
if login == SecondLogin(node) {
|
||||
return FirstLogin(node)
|
||||
}
|
||||
return SecondLogin(node)
|
||||
}
|
||||
|
||||
// LoginNode is the node a node login names, either of its two, and whether login is one.
|
||||
func LoginNode(login string) (string, bool) {
|
||||
rest, ok := strings.CutPrefix(login, nodeLoginPrefix)
|
||||
rest = strings.TrimSuffix(rest, SecondLoginSuffix)
|
||||
return rest, ok && checkName("node", rest) == nil
|
||||
}
|
||||
|
||||
// Rotates says the node is rotating: a login rotating is named.
|
||||
func (l NodeLogin) Rotates() bool { return l.Rotating != "" }
|
||||
|
||||
func (l NodeLogin) check() error {
|
||||
if err := checkName("node", l.Node); err != nil {
|
||||
return err
|
||||
}
|
||||
if l.Login != FirstLogin(l.Node) && l.Login != SecondLogin(l.Node) {
|
||||
return fmt.Errorf("delivery: the node %s logs in as %q, neither of its two logins", l.Node, l.Login)
|
||||
}
|
||||
for _, act := range l.Acts {
|
||||
if strings.TrimSpace(act) == "" {
|
||||
return fmt.Errorf("delivery: the node %s names an empty hand act among its rotations' warrants", l.Node)
|
||||
}
|
||||
}
|
||||
if !l.Rotates() {
|
||||
if l.Stage != "" || l.ListRevision != 0 {
|
||||
return fmt.Errorf("delivery: the node %s rotates to nothing and names stage %q", l.Node, l.Stage)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if l.Rotating != OtherLogin(l.Node, l.Login) {
|
||||
return fmt.Errorf("delivery: the node %s rotates from %s to %q, not its other login", l.Node, l.Login, l.Rotating)
|
||||
}
|
||||
switch l.Stage {
|
||||
case RotationBegun, RotationReady, RotationConfirmed:
|
||||
default:
|
||||
return fmt.Errorf("delivery: the node %s's rotation is at stage %q, not begun, ready or confirmed", l.Node, l.Stage)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EncodeNodeLogin is l as JSON at Version; it refuses what DecodeNodeLogin refuses.
|
||||
func EncodeNodeLogin(l NodeLogin) ([]byte, error) {
|
||||
if err := l.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
l.V = Version
|
||||
return json.Marshal(l)
|
||||
}
|
||||
|
||||
// DecodeNodeLogin reads a key of `node-logins`; it refuses an unknown version (ErrUnknownVersion), a login that is not
|
||||
// one of the node's two, a rotation to anything but the other, and a stage without a rotation or an unknown one. An
|
||||
// empty rotating reads as none.
|
||||
func DecodeNodeLogin(data []byte) (NodeLogin, error) {
|
||||
var l NodeLogin
|
||||
if err := decode(data, &l); err != nil {
|
||||
return NodeLogin{}, err
|
||||
}
|
||||
if err := l.check(); err != nil {
|
||||
return NodeLogin{}, err
|
||||
}
|
||||
return l, nil
|
||||
}
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
// what it says changed. The watchman (seat mesh-watchdogs) reads it for six alerts the controller raised from the
|
||||
// passing health message: machine-network (Network), network-rewritten (the resolver file's part, Writer and Owner),
|
||||
// network-unreachable (Peers, read across every node's report), machine-units (Units), relogin-needed (Relogin) and
|
||||
// directory-used-as-found (Found). Moved here from mesh-host internal/link (65fbc1e6) unchanged; the engine builds a
|
||||
// report from its own health statement there (NodeHealthOf).
|
||||
// directory-used-as-found (Found); and for node-account-fallback (Fallback, ADR 0583 §6). Moved here from mesh-host
|
||||
// internal/link (65fbc1e6); the engine builds a report from its own health statement there (NodeHealthOf).
|
||||
|
||||
// NodeHealthState and NodeHealthBucket are the node-engine's state health, as a reader names it and as the bus does.
|
||||
const (
|
||||
@@ -40,6 +40,10 @@ type NodeHealth struct {
|
||||
Relogin []NodeAccount `json:"relogin,omitempty"`
|
||||
// Found are the directories used as found.
|
||||
Found []NodeDirectory `json:"found,omitempty"`
|
||||
// Fallback is set when the engine fell back to its node's old login after the new one failed its test-dial or its
|
||||
// confirm (novox/hq ADR 0583 §6): the watchman raises node-account-fallback from it. Nil while the node logs in as
|
||||
// its login in force.
|
||||
Fallback *LoginFallback `json:"fallback,omitempty"`
|
||||
// Root are the engine's verdicts on each account a module declares never to become root without a person (novox/hq
|
||||
// ADR 0266), sorted: the agent account's among them where a module declares it. What the self-check's DA and D-root
|
||||
// judge an agent's confinement by, on the machine's own word.
|
||||
@@ -51,6 +55,15 @@ type NodeHealth struct {
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// LoginFallback is a node's fall back to its old login (ADR 0583 §6): the node, the login it fell back from (the one the
|
||||
// rotation moved to), why, in words with no credential, and when.
|
||||
type LoginFallback struct {
|
||||
Node string `json:"node"`
|
||||
From string `json:"from"`
|
||||
Why string `json:"why"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// NetworkHealth is the machine's networking (ADR 0241): the worst of its parts, since when, and each part.
|
||||
type NetworkHealth struct {
|
||||
State string `json:"state"`
|
||||
@@ -150,6 +163,9 @@ func EncodeNodeHealth(h NodeHealth) ([]byte, error) {
|
||||
if h.Node == "" {
|
||||
return nil, errors.New("a node's health report names its node")
|
||||
}
|
||||
if f := h.Fallback; f != nil && (f.Node != h.Node || f.From == "" || f.Why == "" || f.At.IsZero()) {
|
||||
return nil, fmt.Errorf("a node's fall back to its old login names its node %s, the login fallen back from, why and when", h.Node)
|
||||
}
|
||||
return json.Marshal(h)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
@@ -13,7 +16,7 @@ import (
|
||||
// check (ADR 0201 §13) refuses such a field in a state every node reads.
|
||||
var secretNamed = regexp.MustCompile(`(?i)(secret|passw|token|api[-_]?key|private[-_]?key|credential|bearer|cookie|salt|signing)`)
|
||||
|
||||
// Issue 1126 (ADR 0555): a node's record reads back, rides in its have key, refuses a node that is not a name and an empty capability, and
|
||||
// Issue 1126 (ADR 0555), and issue 774 for the enrolment's keys (ADR 0587 §2): a node's record reads back, rides in its have key, refuses a node that is not a name and an empty capability, and
|
||||
// neither it nor the placement file names a field like a secret.
|
||||
func TestNodeRecord(t *testing.T) {
|
||||
hub := NodeRecord{Node: "novox", TunnelKey: "k", Capabilities: []string{"docker"}, SealingKey: "age1x", ServingKey: "pub",
|
||||
@@ -68,6 +71,143 @@ func TestNodeRecord(t *testing.T) {
|
||||
}
|
||||
}
|
||||
walk(reflect.TypeOf(NodeRecord{}), "NodeRecord")
|
||||
// 774/S35/AC1: an enrolment key and an address key read back equal, and neither names a field like a secret: the
|
||||
// grants part reads them (ADR 0587 §2).
|
||||
issued := time.Date(2026, 11, 2, 1, 0, 0, 0, time.UTC)
|
||||
// The tunnel key given at issue, at every stage.
|
||||
wg := base64.StdEncoding.EncodeToString(make([]byte, 32))
|
||||
for _, e := range []Enrolment{
|
||||
{Node: "lab", Address: "10.0.0.7", Why: "a new machine", Caller: "mesh-enrolment", Ask: "a1", Stage: EnrolmentLive,
|
||||
OverlayKey: wg, Issued: issued, Expires: issued.Add(DefaultTokenLife)},
|
||||
{Node: "lab", Address: "10.0.0.7", Why: "a new machine", Caller: "mesh-enrolment", Ask: "a1", Stage: EnrolmentRedeeming,
|
||||
OverlayKey: wg, Issued: issued, Expires: issued.Add(MostTokenLife), KeysDigest: KeysDigest("pub", "overlay", "sealing", "serving")},
|
||||
} {
|
||||
raw, err := EncodeEnrolment(e)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
back, err := DecodeEnrolment(raw)
|
||||
e.V = Version
|
||||
if err != nil || !reflect.DeepEqual(back, e) {
|
||||
t.Errorf("enrolment round trip: %+v, %v", back, err)
|
||||
}
|
||||
}
|
||||
// ADR 0589 §6: reserved before the ask with neither ask nor issue time, and from the ask with both.
|
||||
if raw, err := EncodeEnrolmentAddress("lab", "", time.Time{}); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if back, err := DecodeEnrolmentAddress(raw); err != nil || back.Node != "lab" || back.Ask != "" || strings.Contains(string(raw), "issued") {
|
||||
t.Errorf("address round trip: %+v, %v (%s)", back, err, raw)
|
||||
}
|
||||
if raw, err := EncodeEnrolmentAddress("lab", "a1", issued); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if back, err := DecodeEnrolmentAddress(raw); err != nil || back.Ask != "a1" || !back.Issued.Equal(issued) {
|
||||
t.Errorf("address at the ask round trip: %+v, %v", back, err)
|
||||
}
|
||||
for name, raw := range map[string]string{
|
||||
"no node": `{"v":1}`,
|
||||
"an ask with no issue time": `{"v":1,"node":"lab","ask":"a1"}`,
|
||||
"an issue time with no ask": `{"v":1,"node":"lab","issued":"2026-11-02T01:00:00Z"}`,
|
||||
} {
|
||||
if _, err := DecodeEnrolmentAddress([]byte(raw)); err == nil {
|
||||
t.Errorf("an address with %s: taken", name)
|
||||
}
|
||||
}
|
||||
if id := EnrolmentIssuedID("lab", issued); id != "enrolment-issued.lab."+strconv.FormatInt(issued.UnixNano(), 10) {
|
||||
t.Errorf("the issued event's id: %q", id)
|
||||
}
|
||||
for name, e := range map[string]Enrolment{
|
||||
"keys before redeem": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: EnrolmentLive,
|
||||
OverlayKey: wg, Issued: issued, Expires: issued.Add(time.Hour), KeysDigest: KeysDigest("a", "b", "c", "d")},
|
||||
"no keys at redeem": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: EnrolmentRedeeming,
|
||||
OverlayKey: wg, Issued: issued, Expires: issued.Add(time.Hour)},
|
||||
"no address": {Node: "lab", Why: "w", Caller: "c", Stage: EnrolmentAsked, OverlayKey: wg, Issued: issued, Expires: issued.Add(time.Hour)},
|
||||
"no tunnel key": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: EnrolmentAsked, Issued: issued,
|
||||
Expires: issued.Add(time.Hour)},
|
||||
"a tunnel key not of 32 bytes": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: EnrolmentAsked,
|
||||
OverlayKey: base64.StdEncoding.EncodeToString(make([]byte, 31)), Issued: issued, Expires: issued.Add(time.Hour)},
|
||||
"other stage": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: "spent", OverlayKey: wg, Issued: issued,
|
||||
Expires: issued.Add(time.Hour)},
|
||||
} {
|
||||
if _, err := EncodeEnrolment(e); err == nil {
|
||||
t.Errorf("enrolment with %s: taken", name)
|
||||
}
|
||||
}
|
||||
if key, ok := EnrolmentKeyAddress(EnrolmentAddressKey("10.0.0.7")); !ok || key != "10.0.0.7" {
|
||||
t.Errorf("address key: %q %v", key, ok)
|
||||
}
|
||||
if node, ok := EnrolmentKeyNode(EnrolmentNodeKey("lab")); !ok || node != "lab" {
|
||||
t.Errorf("node key: %q %v", node, ok)
|
||||
}
|
||||
for _, life := range []string{"", "1h", "24h"} {
|
||||
if _, err := TokenLife(life); err != nil {
|
||||
t.Errorf("a life of %q: %v", life, err)
|
||||
}
|
||||
}
|
||||
for _, life := range []string{"25h", "-1h", "a day"} {
|
||||
if _, err := TokenLife(life); err == nil {
|
||||
t.Errorf("a life of %q: taken", life)
|
||||
}
|
||||
}
|
||||
// 774/S38/AC1: a node's key of node-logins reads back equal, an empty rotating reads as none, and a stage without a
|
||||
// rotation, a rotation to anything but the other login, or a login not the node's is refused (ADR 0583 §1).
|
||||
for _, l := range []NodeLogin{
|
||||
{Node: "g14", Login: "node.g14", Since: issued},
|
||||
{Node: "g14", Login: "node.g14", Rotating: "node.g14--b", Stage: RotationReady, Since: issued, ListRevision: 41},
|
||||
{Node: "g14", Login: "node.g14--b", Rotating: "node.g14", Stage: RotationBegun, Since: issued},
|
||||
} {
|
||||
raw, err := EncodeNodeLogin(l)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
back, err := DecodeNodeLogin(raw)
|
||||
l.V = Version
|
||||
if err != nil || !reflect.DeepEqual(back, l) {
|
||||
t.Errorf("node login round trip: %+v, %v", back, err)
|
||||
}
|
||||
}
|
||||
// The hand acts that warranted its rotations, as the vault records them.
|
||||
if raw, err := EncodeNodeLogin(NodeLogin{Node: "g14", Login: "node.g14--b", Acts: []string{"act-1"}}); err != nil ||
|
||||
!strings.Contains(string(raw), `"acts":["act-1"]`) {
|
||||
t.Errorf("a login with its acts: %s, %v", raw, err)
|
||||
}
|
||||
if l, err := DecodeNodeLogin([]byte(`{"v":1,"node":"g14","login":"node.g14","rotating":"","since":"2026-11-02T01:00:00Z"}`)); err != nil || l.Rotates() {
|
||||
t.Errorf("an empty rotating: %+v, %v", l, err)
|
||||
}
|
||||
for name, l := range map[string]NodeLogin{
|
||||
"a stage, no rotation": {Node: "g14", Login: "node.g14", Stage: RotationBegun},
|
||||
"another node's login": {Node: "g14", Login: "node.ace"},
|
||||
"a rotation to itself": {Node: "g14", Login: "node.g14", Rotating: "node.g14", Stage: RotationBegun},
|
||||
"a rotation, no stage": {Node: "g14", Login: "node.g14", Rotating: "node.g14--b"},
|
||||
"a rotation, done stage": {Node: "g14", Login: "node.g14", Rotating: "node.g14--b", Stage: "done"},
|
||||
"an empty act": {Node: "g14", Login: "node.g14", Acts: []string{""}},
|
||||
} {
|
||||
if _, err := EncodeNodeLogin(l); err == nil {
|
||||
t.Errorf("node login with %s: taken", name)
|
||||
}
|
||||
}
|
||||
if node, ok := LoginNode("node.g14--b"); !ok || node != "g14" || OtherLogin("g14", "node.g14--b") != "node.g14" {
|
||||
t.Errorf("the second login's node: %q %v", node, ok)
|
||||
}
|
||||
walk(reflect.TypeOf(NodeLogin{}), "NodeLogin")
|
||||
walk(reflect.TypeOf(Enrolment{}), "Enrolment")
|
||||
walk(reflect.TypeOf(EnrolmentAddress{}), "EnrolmentAddress")
|
||||
walk(reflect.TypeOf(EnrolmentExpiry{}), "EnrolmentExpiry")
|
||||
// enrolment-expired names the issued event by its message id (§2), never a stream sequence.
|
||||
if raw, err := EncodeEnrolmentExpiry(EnrolmentExpiry{Node: "lab", IssuedID: "enrolment-issued.lab.1"}); err != nil {
|
||||
t.Errorf("an expiry: %v", err)
|
||||
} else if x, err := DecodeEnrolmentExpiry(raw); err != nil || x.IssuedID != "enrolment-issued.lab.1" ||
|
||||
!strings.Contains(string(raw), `"issued-event":"enrolment-issued.lab.1"`) {
|
||||
t.Errorf("an expiry reads back as %+v, %v (%s)", x, err, raw)
|
||||
}
|
||||
for name, raw := range map[string]string{
|
||||
"no issued event": `{"v":1,"node":"lab"}`,
|
||||
"a sequence for it": `{"v":1,"node":"lab","issued-event":7}`,
|
||||
"a node not a name": `{"v":1,"node":"Lab Pi","issued-event":"e"}`,
|
||||
} {
|
||||
if _, err := DecodeEnrolmentExpiry([]byte(raw)); err == nil {
|
||||
t.Errorf("an expiry with %s: taken", name)
|
||||
}
|
||||
}
|
||||
// And the placement file, which holds what a person decides of a node (K2): accounts by name, never a credential.
|
||||
walk(reflect.TypeOf(Placement{}), "Placement")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// **Every module builds against the latest published version of each mesh package it uses** (novox/hq ADR 0573 §2;
|
||||
// issue 1585). A mesh package is a Go module of a repository of the mesh's forge (ForgeModulePrefix): mesh-sdk's Go
|
||||
// module, and a seat's contract its holder's repository publishes (ADR 0573 §1). Its version is the trunk merge the
|
||||
// indexer holds for the module whose directory holds the package (PackageVersionsOf): a merge that changes it changes
|
||||
// the fingerprint of every module that uses it, and the build writes that version into its own go.mod and go.sum
|
||||
// (StepAsk.Packages). Which packages a module uses is read from its lock file by MeshPackagesOf, the same way by the
|
||||
// forge bridge and by the build modules (buildstep.ModuleTrees), so nobody names a version by hand.
|
||||
|
||||
// ForgeModulePrefix is the module path prefix of the Go modules the mesh's forge serves: a Go module whose path starts
|
||||
// with it is a mesh package, git.novox.be/novox/<repository>[/<directory of the module in it>].
|
||||
const ForgeModulePrefix = "git.novox.be/novox/"
|
||||
|
||||
// MeshPackagesOf are the mesh packages a Go module's go.sum names (the module path of each line that starts with
|
||||
// ForgeModulePrefix), sorted and each once. go.sum names every module of the build's graph, so a package used only
|
||||
// through another is named too. Nil for none. A package of the module's own repository comes with its own tree, and
|
||||
// its reader leaves it out (OtherPackages).
|
||||
func MeshPackagesOf(goSum []byte) []string {
|
||||
seen := map[string]bool{}
|
||||
for _, line := range strings.Split(string(goSum), "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) < 2 || !strings.HasPrefix(f[0], ForgeModulePrefix) {
|
||||
continue
|
||||
}
|
||||
if _, _, ok := PackageRepository(f[0]); !ok {
|
||||
continue
|
||||
}
|
||||
seen[f[0]] = true
|
||||
}
|
||||
if len(seen) == 0 {
|
||||
return nil
|
||||
}
|
||||
return slices.Sorted(maps.Keys(seen))
|
||||
}
|
||||
|
||||
// OtherPackages are the packages not published from repository, the module's own, in their order; nil for none.
|
||||
func OtherPackages(packages []string, repository string) []string {
|
||||
var out []string
|
||||
for _, p := range packages {
|
||||
if repo, _, ok := PackageRepository(p); ok && !SameRepository(repo, repository) {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// PackageRepository is the repository (<owner>/<name>) a mesh package is published from and the package's directory in
|
||||
// it ("" for its root); false for a path that is not a forge module's.
|
||||
func PackageRepository(pkg string) (repository, dir string, ok bool) {
|
||||
rest, found := strings.CutPrefix(pkg, ForgeModulePrefix)
|
||||
if !found || rest == "" {
|
||||
return "", "", false
|
||||
}
|
||||
name, dir, _ := strings.Cut(rest, "/")
|
||||
if name == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return "novox/" + name, dir, true
|
||||
}
|
||||
|
||||
// PackageVersionsOf is the latest published version of each package from the modules the indexer holds (by key): the
|
||||
// trunk merge commit of the module of the package's repository whose directory holds the package's directory (the
|
||||
// deepest, a module at the repository's root holding every directory). A package no held module holds, or whose
|
||||
// module names no commit or no directory, is in missing, sorted; versions is nil when packages is empty.
|
||||
func PackageVersionsOf(packages []string, modules map[string]Module) (versions PackageVersions, missing []string) {
|
||||
for _, pkg := range packages {
|
||||
repository, dir, ok := PackageRepository(pkg)
|
||||
commit, depth := "", -1
|
||||
if ok {
|
||||
for _, key := range slices.Sorted(maps.Keys(modules)) {
|
||||
m := modules[key]
|
||||
if !SameRepository(m.Repository, repository) || m.Commit == "" {
|
||||
continue
|
||||
}
|
||||
at := strings.Trim(m.Path, "/")
|
||||
switch {
|
||||
case at == "":
|
||||
continue // a value that names no directory says nothing of where the package lies
|
||||
case at == RootPath:
|
||||
at = ""
|
||||
case dir != at && !strings.HasPrefix(dir, at+"/"):
|
||||
continue
|
||||
}
|
||||
if d := len(at); d > depth {
|
||||
commit, depth = m.Commit, d
|
||||
}
|
||||
}
|
||||
}
|
||||
if commit == "" {
|
||||
missing = append(missing, pkg)
|
||||
continue
|
||||
}
|
||||
if versions == nil {
|
||||
versions = PackageVersions{}
|
||||
}
|
||||
versions[pkg] = commit
|
||||
}
|
||||
slices.Sort(missing)
|
||||
return versions, missing
|
||||
}
|
||||
@@ -127,10 +127,9 @@ type CandidateAnswer struct {
|
||||
Held []ModuleRefused `json:"held,omitempty"`
|
||||
// Refused is why the node could not compose with the candidate at all; Refusals is then empty.
|
||||
Refused string `json:"refused,omitempty"`
|
||||
// Returns is, for a proposal that takes the node's placement key away, what the node does then: it returns to the
|
||||
// controller's compositions (ReturnsToController), as a node with no placement key composes none of its own. Same, Differs, Added and Removed then compare the controller's compositions at
|
||||
// the node's wants with what it runs, and the verdict is theirs; a want the controller holds no composition for is
|
||||
// refused (Refused, without Returns).
|
||||
// Returns is, for a proposal that takes the node's placement key away, what the node does then (ReturnsToNothing): a
|
||||
// node with no placement key composes nothing (ADR 0590), so Removed names every module it runs and the verdict is
|
||||
// theirs.
|
||||
Returns string `json:"returns,omitempty"`
|
||||
// Note is what the node says of how it answered, in words, beside the verdict: a pass that ran over a minute, inputs
|
||||
// it waited for (issue 1126: the shadow shows its work). It changes no verdict.
|
||||
@@ -141,8 +140,8 @@ type CandidateAnswer struct {
|
||||
ValuesDiffer int `json:"values_differ,omitempty"`
|
||||
}
|
||||
|
||||
// ReturnsToController is what a node answers a proposal that takes its placement key away.
|
||||
const ReturnsToController = "the proposal takes this node's placement away: it returns to the controller's compositions"
|
||||
// ReturnsToNothing is what a node answers a proposal that takes its placement key away.
|
||||
const ReturnsToNothing = "the proposal takes this node's placement away: it composes nothing then"
|
||||
|
||||
// ModuleDiffers is one module the node runs and the candidate still places whose composition is not the one it runs:
|
||||
// both digests (Composed with the candidate, Running what runs), the first resource that differs by its id, or empty
|
||||
@@ -227,8 +226,7 @@ func (a CandidateAnswer) check() error {
|
||||
return fmt.Errorf("delivery: a candidate the node could not compose names modules")
|
||||
}
|
||||
if a.Returns != "" && (a.Refused != "" || len(a.Refusals) > 0) {
|
||||
return fmt.Errorf("delivery: a candidate the node returns to the controller's compositions on is compared, never refused: " +
|
||||
"a composition the controller holds none of is refused without returning")
|
||||
return fmt.Errorf("delivery: a candidate that takes the node's placement away is compared, never refused")
|
||||
}
|
||||
for _, d := range a.Differs {
|
||||
if d.Module == "" || d.Why == "" {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user