Compare commits
205
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 | ||
|
|
4dfc11b424 | ||
|
|
68aaf235d4 | ||
|
|
25a2cecd98 | ||
|
|
7cab079e2e | ||
|
|
9904a03b51 | ||
|
|
7b09de1e55 | ||
|
|
7631a7d5de | ||
|
|
a343557554 | ||
|
|
ccb367e68c | ||
|
|
ff5e00f1f8 | ||
|
|
c28c645ca4 | ||
|
|
5ae69df8cc | ||
|
|
cbbfbf3d2d | ||
|
|
fea48f19ce | ||
|
|
bb01a656d0 | ||
|
|
b611022457 | ||
|
|
fbbfae7629 | ||
|
|
acbf80ad91 | ||
|
|
de8e82c06f | ||
|
|
085f17e935 | ||
|
|
2d1d8def75 | ||
|
|
98d5306d6b | ||
|
|
a4e7629742 | ||
|
|
a320f8e2cd | ||
|
|
7a9c8570cb | ||
|
|
4c54aec972 | ||
|
|
d352fe51b4 | ||
|
|
712ec9481b | ||
|
|
50ea6f748e | ||
|
|
748543c35f | ||
|
|
dfd5d8295e | ||
|
|
dec3bfe7a8 | ||
|
|
8ed3b872e0 | ||
|
|
6bce1fb0d2 | ||
|
|
409c56685d | ||
|
|
b9ef1e4fcb | ||
|
|
17fc9b9b81 | ||
|
|
125bb91771 | ||
|
|
6c5a8ef893 | ||
|
|
91aa7b78fc | ||
|
|
b74e23f973 | ||
|
|
eff81f1220 | ||
|
|
6a71d29b33 | ||
|
|
4e9621edd6 | ||
|
|
0cdbdca139 | ||
|
|
14de8dadb4 | ||
|
|
9685990a15 | ||
|
|
d539d7e4eb | ||
|
|
c1bc21c35f | ||
|
|
16932c93d6 | ||
|
|
754edfae69 | ||
|
|
4c1384faef | ||
|
|
a37181261c | ||
|
|
206f794eb3 | ||
|
|
ef1ec1c935 | ||
|
|
942f906222 | ||
|
|
61b67d61e2 | ||
|
|
44dbb22ea7 | ||
|
|
1dc5d8cb3d | ||
|
|
7115b7bf64 | ||
|
|
e703e9b0e2 | ||
|
|
7b02f63daf | ||
|
|
59311407e2 | ||
|
|
1d9de5dbb6 | ||
|
|
af66ccb833 | ||
|
|
42988a734b | ||
|
|
6e78203959 | ||
|
|
d0625eea3d | ||
|
|
9379e9a96a | ||
|
|
0c99e847a2 | ||
|
|
83cf97b690 | ||
|
|
46d4b4da5e | ||
|
|
2b1e148bf5 | ||
|
|
2da0de75c3 | ||
|
|
044ead7db8 | ||
|
|
78c9ec8817 | ||
|
|
6e4a3014cb | ||
|
|
007b6fd45f | ||
|
|
8fe3467753 | ||
|
|
fa5a6aeff5 | ||
|
|
31e90aaa0f | ||
|
|
7bd0f6c2bd | ||
|
|
f3677eb6f3 | ||
|
|
992ea0ab66 | ||
|
|
85f5fd0b22 | ||
|
|
9f0bb1cfa4 | ||
|
|
08fc70783a | ||
|
|
bb993dd606 | ||
|
|
196c5b6af2 | ||
|
|
dc78524e38 | ||
|
|
f9a496d3e1 | ||
|
|
7fdbadc655 | ||
|
|
6eae0765f4 | ||
|
|
94d0de5c19 | ||
|
|
58a83249c8 | ||
|
|
6b8c694381 | ||
|
|
dedd988fc9 | ||
|
|
f701db0516 | ||
|
|
161fba56ae | ||
|
|
692dbf9a17 | ||
|
|
66ff33dbd3 | ||
|
|
6e39ed1852 | ||
|
|
56514aa4a2 | ||
|
|
4a967933b3 | ||
|
|
233b6be5dd | ||
|
|
4204afd240 | ||
|
|
2d2aa7ba9b | ||
|
|
2e5cc4f166 | ||
|
|
bc6c8a6d24 | ||
|
|
20cac82ab7 | ||
|
|
fbd00c8943 | ||
|
|
7df90445ad | ||
|
|
32f5ce3712 | ||
|
|
8e3d7a4c3c | ||
|
|
b6ef01d6c6 | ||
|
|
7000bd5049 | ||
|
|
69fcb6c1c1 | ||
|
|
2a04bf0ac5 | ||
|
|
0b12b62a01 | ||
|
|
5a47192b57 | ||
|
|
83d928fe50 | ||
|
|
544720b00a | ||
|
|
575a05e49e | ||
|
|
4fff214eeb | ||
|
|
187fb63439 | ||
|
|
22c9ddd32b | ||
|
|
94cee5d199 | ||
|
|
52056010fe | ||
|
|
307b6234e6 | ||
|
|
dbcaa29a1a | ||
|
|
394fec8a63 | ||
|
|
c2999a4e13 | ||
|
|
d4c61ee8ff | ||
|
|
76f7c4d223 | ||
|
|
f609be7864 | ||
|
|
d747c1128e | ||
|
|
f9afe32888 | ||
|
|
77b48a4f1a | ||
|
|
e84a121b8a | ||
|
|
06b13ec1cc | ||
|
|
0830fcb25e | ||
|
|
b03bf4c118 | ||
|
|
53f2649369 | ||
|
|
9e5666b4d6 | ||
|
|
c006aa8242 |
@@ -24,16 +24,13 @@ 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
|
||||
// its buckets' names (catalogue.StateProblems).
|
||||
// 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) {
|
||||
return "", fmt.Errorf("announce: %q is not a module that keeps state: lower-case letters, digits and hyphens", 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
-20
@@ -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,8 +260,9 @@ func (s AskSteps) level(ctx context.Context, job delivery.CheckJob, level string
|
||||
_ = json.Unmarshal(r, &t)
|
||||
return t.Passed, nil
|
||||
}
|
||||
// Every test step of the level runs, 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 level as a whole 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.
|
||||
@@ -279,7 +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 // the packages left still run (issue 1921)
|
||||
all = all && passed // the others still run after a failure (issue 1921)
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
+71
-40
@@ -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, then the rest also after a failure (issue 1921), 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"}},
|
||||
@@ -130,13 +143,16 @@ func TestRunAskChecksTheLevelsOfOneCheck(t *testing.T) {
|
||||
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/a"],"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) != 2 || u.Digest == nil {
|
||||
@@ -254,41 +270,56 @@ func TestRunAskChecksNodeModulesAndConflicts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1921: a failure in the packages whose tests changed does not stop the level: the other packages still run, so two
|
||||
// failing packages, one changed and one not, are two test results of the one level, and the level does not pass. A Node
|
||||
// check runs every module directory after one fails too.
|
||||
func TestRunAskRunsEveryPackageAfterAFailure(t *testing.T) {
|
||||
r := &recorder{
|
||||
fetch: map[string]any{"workspace": "/w", "changed": []string{"pkg/b/b_test.go"}},
|
||||
digests: map[string]string{"x/pkg/a": "d-a", "x/pkg/b": "d-b"},
|
||||
tests: map[string]bool{"x/pkg/a": false, "x/pkg/b": false},
|
||||
}
|
||||
out, err := RunAsk(context.Background(), r.steps(), ask(delivery.CheckJob{Levels: []string{delivery.LevelIntegration, delivery.LevelUnit},
|
||||
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := out.(delivery.CheckAnswer)
|
||||
if len(a.Levels) != 1 || len(a.Levels[0].Tests) != 2 {
|
||||
t.Fatalf("levels %+v, want the one level with both packages' results", a.Levels)
|
||||
}
|
||||
for i, raw := range a.Levels[0].Tests {
|
||||
var res struct{ Passed bool }
|
||||
if err := json.Unmarshal(raw, &res); err != nil || res.Passed {
|
||||
t.Errorf("result %d %s, want a failure", i, raw)
|
||||
// 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 at each level, in order
|
||||
}{
|
||||
{"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"}},
|
||||
} {
|
||||
r := &recorder{fetch: map[string]any{"workspace": "/w", "changed": row.changed}, tests: row.tests,
|
||||
digests: map[string]string{"x/pkg/a": "d-a", "x/pkg/b": "d-b"}}
|
||||
if _, ok := row.tests["x/pkg/c"]; ok {
|
||||
r.digests["x/pkg/c"] = "d-c"
|
||||
}
|
||||
out, err := RunAsk(context.Background(), r.steps(), ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit, delivery.LevelIntegration},
|
||||
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got []string
|
||||
for _, c := range r.calls[1:] {
|
||||
if strings.HasPrefix(c, "test ") {
|
||||
var a struct{ Packages []string }
|
||||
_ = json.Unmarshal([]byte(strings.TrimPrefix(c, "test ")), &a)
|
||||
got = append(got, strings.Join(a.Packages, " "))
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
r = &recorder{fetch: map[string]any{"workspace": "/w", "modules": []string{"a", "b"}, "changed": []string{"b/test/x.test.ts"}},
|
||||
r := &recorder{fetch: map[string]any{"workspace": "/w", "modules": []string{"a", "b"}, "changed": []string{"b/test/x.test.ts"}},
|
||||
tests: map[string]bool{"a": true, "b": false}}
|
||||
s := r.steps()
|
||||
s.Digest = nil
|
||||
out, err = RunAsk(context.Background(), s, ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit, delivery.LevelIntegration},
|
||||
out, err := RunAsk(context.Background(), s, ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit, delivery.LevelIntegration},
|
||||
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a := out.(delivery.CheckAnswer); len(r.calls) != 3 || len(a.Levels) != 1 || len(a.Levels[0].Tests) != 2 {
|
||||
t.Fatalf("calls %v, levels %+v: want both directories of the unit level 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)
|
||||
}
|
||||
}
|
||||
|
||||
+17
-14
@@ -22,20 +22,7 @@ func NodeCapacity(path, node string, logf func(string, ...any)) func() int {
|
||||
}
|
||||
}
|
||||
return func() int {
|
||||
if path == "" {
|
||||
return 0
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
say("the build capacity cannot be read, so %s pulls nothing: %v", node, err)
|
||||
return 0
|
||||
}
|
||||
var s map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
say("the settings file %s is not JSON, so %s pulls nothing: %v", path, node, err)
|
||||
return 0
|
||||
}
|
||||
n, err := capacityOf(s[CapacitySetting])
|
||||
n, err := ReadCapacity(path)
|
||||
if err != nil {
|
||||
say("%v, so %s pulls nothing", err, node)
|
||||
return 0
|
||||
@@ -44,6 +31,22 @@ func NodeCapacity(path, node string, logf func(string, ...any)) func() int {
|
||||
}
|
||||
}
|
||||
|
||||
// ReadCapacity is the capacity the settings file at path holds, and why it cannot be read when it cannot (then 0).
|
||||
func ReadCapacity(path string) (int, error) {
|
||||
if path == "" {
|
||||
return 0, nil
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("the build capacity cannot be read: %w", err)
|
||||
}
|
||||
var s map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
return 0, fmt.Errorf("the settings file %s is not JSON: %w", path, err)
|
||||
}
|
||||
return capacityOf(s[CapacitySetting])
|
||||
}
|
||||
|
||||
// capacityOf is the capacity a config value holds: absent or null is 0; anything but a whole number of at least 0 is
|
||||
// refused, naming it.
|
||||
func capacityOf(value json.RawMessage) (int, error) {
|
||||
|
||||
+109
-8
@@ -98,6 +98,12 @@ type Puller struct {
|
||||
// again, and the worker it came from is not looked at again until the next pull's wait, so a node lacking the need
|
||||
// does not take the asker's next copy at once. Nil has nothing.
|
||||
Has func(need string) bool
|
||||
// SettingsFile is the module's settings file. With it set the puller wakes whenever the file is written (a file event,
|
||||
// WatchSettings), and reads Capacity from it itself, keeping why it cannot be read (CapacityNote); a parked puller at
|
||||
// capacity 0 therefore wakes when the node-engine refills the file, and a failed config follow is tried again then.
|
||||
SettingsFile string
|
||||
// watching, when set, is called each time the settings file's watch is (re)armed: a test waits on it, not on a sleep.
|
||||
watching func()
|
||||
|
||||
mu sync.Mutex
|
||||
inHand int
|
||||
@@ -108,6 +114,14 @@ type Puller struct {
|
||||
// watched is the capacity the config watch handed last, used in place of Capacity once hasWatched.
|
||||
watched int
|
||||
hasWatched bool
|
||||
// capFollow and hoursFollow are the state of the two config follows: followed once WatchConfig took them, trying
|
||||
// while a start is in flight; followFault is the last failure said, so each failure is said once.
|
||||
capFollowed, capTrying, hoursFollowed, hoursTrying bool
|
||||
followFault string
|
||||
// fileFault is why the settings file's capacity cannot be read, "" when it can.
|
||||
fileFault string
|
||||
// capWhy is why the config watch's last value could not be read, "" when it could.
|
||||
capWhy string
|
||||
// hours is the build window the config watch handed last, and hoursFault why its value cannot be read; used in place
|
||||
// of BuildHours once hasHours.
|
||||
hours *BuildWindow
|
||||
@@ -248,6 +262,21 @@ func (p *Puller) roomNow() (ok bool, capacity, inHand int) {
|
||||
return !p.paused && p.inHand < capacity, capacity, p.inHand
|
||||
}
|
||||
|
||||
// CapacityNote says when the puller takes nothing for want of a capacity: why, and whether it is a fault (a setting that
|
||||
// cannot be read or is refused, so a person must look) and not a choice (a capacity set to 0). "" when it pulls.
|
||||
func (p *Puller) CapacityNote() (why string, fault bool) {
|
||||
c := p.CapacityDetail()
|
||||
if c.Effective > 0 {
|
||||
return "", false
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.fileFault != "" && !p.hasWatched {
|
||||
return "the " + p.Language + " build capacity of " + p.Node + " is 0 because it cannot be read: " + p.fileFault, true
|
||||
}
|
||||
return "the " + p.Language + " build capacity of " + p.Node + " is " + c.Why, p.hasWatched && p.capWhy != ""
|
||||
}
|
||||
|
||||
// CapacityNow is the capacity the puller judges by (CapacityDetail's Effective).
|
||||
func (p *Puller) CapacityNow() int {
|
||||
return p.CapacityDetail().Effective
|
||||
@@ -265,6 +294,15 @@ func (p *Puller) CapacityDetail() delivery.Capacity {
|
||||
switch {
|
||||
case has:
|
||||
configured, from = watched, "mesh-config, as the config watch handed it"
|
||||
case p.SettingsFile != "":
|
||||
n, err := ReadCapacity(p.SettingsFile)
|
||||
configured = n
|
||||
p.mu.Lock()
|
||||
p.fileFault = ""
|
||||
if err != nil {
|
||||
p.fileFault = err.Error()
|
||||
}
|
||||
p.mu.Unlock()
|
||||
case p.Capacity != nil:
|
||||
configured = p.Capacity()
|
||||
}
|
||||
@@ -386,8 +424,8 @@ func (p *Puller) Pull(ctx context.Context) error {
|
||||
// node kept from building is held by its capacity (0), not by a pause.
|
||||
p.logf("the pause of %s cannot be read: %v; pulling as not paused", p.Node, err)
|
||||
}
|
||||
go p.watchCapacity() // WatchConfigServed waits for Serve, which a module starts after its pull
|
||||
go p.watchHours()
|
||||
p.follow() // WatchConfigServed waits for Serve, which a module starts after its pull
|
||||
go p.watchSettings(ctx)
|
||||
wait := p.PullWait
|
||||
if wait <= 0 {
|
||||
wait = DefaultPullWait
|
||||
@@ -414,6 +452,7 @@ func (p *Puller) Pull(ctx context.Context) error {
|
||||
case <-edge:
|
||||
}
|
||||
stop()
|
||||
p.follow() // a follow that failed is tried again at the next event
|
||||
_, capacity, inHand = p.roomNow()
|
||||
p.logf("the %s puller on %s wakes: %d of capacity %d in hand, paused %v", p.Language, p.Node, inHand, capacity, p.Paused())
|
||||
continue
|
||||
@@ -432,6 +471,7 @@ func (p *Puller) Pull(ctx context.Context) error {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-p.wake:
|
||||
p.follow()
|
||||
case <-time.After(wait):
|
||||
}
|
||||
continue
|
||||
@@ -498,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
|
||||
@@ -585,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
|
||||
@@ -620,11 +687,18 @@ func (p *Puller) watchCapacity() {
|
||||
if p.WatchConfig == nil {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
if p.capFollowed || p.capTrying {
|
||||
p.mu.Unlock()
|
||||
return
|
||||
}
|
||||
p.capTrying = true
|
||||
p.mu.Unlock()
|
||||
err := p.WatchConfig(CapacitySetting, func(c stdio.ProvisionChange) error {
|
||||
n, why := p.capacityFrom(c)
|
||||
p.mu.Lock()
|
||||
before, had := p.watched, p.hasWatched
|
||||
p.watched, p.hasWatched = n, true
|
||||
p.watched, p.hasWatched, p.capWhy = n, true, why
|
||||
p.mu.Unlock()
|
||||
switch {
|
||||
case why != "":
|
||||
@@ -635,9 +709,31 @@ func (p *Puller) watchCapacity() {
|
||||
p.Wake()
|
||||
return nil
|
||||
})
|
||||
p.followed(&p.capFollowed, &p.capTrying, err, "build capacity")
|
||||
}
|
||||
|
||||
// follow starts each config follow that is not running: at the start, and at every wake after (a file event, a resume, a
|
||||
// step's end), so a follow that failed is tried again by the next event and not given up for good.
|
||||
func (p *Puller) follow() {
|
||||
go p.watchCapacity()
|
||||
go p.watchHours()
|
||||
}
|
||||
|
||||
// followed records the end of one follow's start. A failure is said once per distinct error, with that it is tried again.
|
||||
func (p *Puller) followed(done, trying *bool, err error, what string) {
|
||||
p.mu.Lock()
|
||||
*trying = false
|
||||
*done = err == nil
|
||||
said := err != nil && err.Error() == p.followFault
|
||||
if err != nil {
|
||||
p.logf("the %s build capacity of %s cannot be followed on the bus, so it is read from the settings file when the "+
|
||||
"puller wakes: %v", p.Language, p.Node, err)
|
||||
p.followFault = err.Error()
|
||||
} else {
|
||||
p.followFault = ""
|
||||
}
|
||||
p.mu.Unlock()
|
||||
if err != nil && !said {
|
||||
p.logf("the %s %s of %s cannot be followed on the bus, so it is read from the settings file; the follow is tried "+
|
||||
"again at the next event: %v", p.Language, what, p.Node, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -648,6 +744,13 @@ func (p *Puller) watchHours() {
|
||||
if p.WatchConfig == nil || p.BuildHours == nil {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
if p.hoursFollowed || p.hoursTrying {
|
||||
p.mu.Unlock()
|
||||
return
|
||||
}
|
||||
p.hoursTrying = true
|
||||
p.mu.Unlock()
|
||||
err := p.WatchConfig(BuildHoursSetting, func(c stdio.ProvisionChange) error {
|
||||
setting := ""
|
||||
var w *BuildWindow
|
||||
@@ -672,9 +775,7 @@ func (p *Puller) watchHours() {
|
||||
p.Wake()
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
p.logf("the %s build hours of %s cannot be followed on the bus, so they are read from the settings file: %v", p.Language, p.Node, err)
|
||||
}
|
||||
p.followed(&p.hoursFollowed, &p.hoursTrying, err, "build hours")
|
||||
}
|
||||
|
||||
// WatchConfigServed is stdio.WatchConfig once Serve has the runtime's channel (stdio.Served): a build module starts its
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -228,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.
|
||||
@@ -301,6 +318,71 @@ func TestPullAtCapacityZeroTakesNothing(t *testing.T) {
|
||||
w.set(t, "2")
|
||||
waitFor(t, "the ask taken once the capacity moved to 2", func() bool { a, _, _ := src.Fates(); return len(a) == 1 })
|
||||
})
|
||||
t.Run("settings refill wakes a parked puller and retries a failed follow", func(t *testing.T) {
|
||||
// 2026-10-28: a replaced builder held the old "shanks=2,..." in its settings file, its config follow timed out, and
|
||||
// the puller parked at 0 with nothing to wake it when the node-engine refilled the file.
|
||||
src, st := NewFakeAsks(), newSteps()
|
||||
_ = src.PublishAsk(testAsk(delivery.AskDelivery))
|
||||
file := filepath.Join(t.TempDir(), "settings.json")
|
||||
if err := os.WriteFile(file, []byte(`{"capacity":"shanks=2,ace=2"}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var mu sync.Mutex
|
||||
attempts := 0
|
||||
watch := func(key string, handle func(stdio.ProvisionChange) error) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
attempts++
|
||||
if attempts == 1 {
|
||||
return errors.New("nats: timeout")
|
||||
}
|
||||
return nil // followed; no value handed yet
|
||||
}
|
||||
armed := make(chan struct{}, 8)
|
||||
var parked atomic.Int32
|
||||
parks := func() int32 { return parked.Load() }
|
||||
logf := func(format string, args ...any) {
|
||||
if strings.Contains(format, "parks") {
|
||||
parked.Add(1)
|
||||
}
|
||||
}
|
||||
p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: st.put, PullWait: 10 * time.Millisecond,
|
||||
SettingsFile: file, WatchConfig: watch, Logf: logf, watching: func() { armed <- struct{}{} },
|
||||
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { return nil, nil }}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go p.Pull(ctx)
|
||||
waitFor(t, "the parked puller to say why", func() bool { why, fault := p.CapacityNote(); return fault && why != "" })
|
||||
if src.Waiting(delivery.AskDelivery) != 1 {
|
||||
t.Fatal("a node whose capacity cannot be read took an ask")
|
||||
}
|
||||
<-armed // the file watch is in place
|
||||
tmp := file + ".new"
|
||||
if err := os.WriteFile(tmp, []byte(`{"capacity":2}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Rename(tmp, file); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitFor(t, "the ask taken once the file was refilled", func() bool { a, _, _ := src.Fates(); return len(a) == 1 })
|
||||
// and the directory itself replaced (IN_IGNORED): the watch is armed again and a later refill still wakes the puller
|
||||
dir := filepath.Dir(file)
|
||||
before := parks()
|
||||
if err := os.RemoveAll(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
<-armed // armed again
|
||||
waitFor(t, "the puller parked with no settings file", func() bool { return parks() > before })
|
||||
_ = src.PublishAsk(testAsk(delivery.AskDelivery))
|
||||
if err := os.WriteFile(file, []byte(`{"capacity":2}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitFor(t, "the second ask taken after the directory was replaced", func() bool { a, _, _ := src.Fates(); return len(a) == 2 })
|
||||
waitFor(t, "the failed follow tried again", func() bool { mu.Lock(); defer mu.Unlock(); return attempts >= 2 })
|
||||
})
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
watch func(string, func(stdio.ProvisionChange) error) error
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+10
-4
@@ -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
|
||||
@@ -75,11 +80,12 @@ func ModuleTrees(ctx context.Context, run forgekey.Runner, env []string, dir, co
|
||||
}
|
||||
|
||||
// narrow sets mt's Paths and its named Tree when the module's manifest names paths on every artifact (novox/hq ADR 0499
|
||||
// clause 2): one `git ls-tree -z` gives each named path's object at commit. The merge preview reads the manifest here, as
|
||||
// the indexer does, with catalogue.ParseManifestForInputs (issue 1751), and no other build step reads paths from one (ADR 0495 clause 1). A manifest that does not parse
|
||||
// is not narrowed: the indexer gives it none. A named path absent at commit fails, naming the module and the path.
|
||||
// clause 2): one `git ls-tree -z` gives each named path's object at commit. The merge preview decodes the manifest here
|
||||
// (catalogue.Decode: a build step judges no manifest, novox/hq ADR 0578 §3), and no other build step reads paths from one
|
||||
// (ADR 0495 clause 1). A manifest that does not decode is not narrowed: the indexer gives it none. A named path absent at
|
||||
// commit fails, naming the module and the path.
|
||||
func narrow(ctx context.Context, run forgekey.Runner, env []string, dir, commit, mdir string, raw []byte, mt *ModuleTree) error {
|
||||
m, err := catalogue.ParseManifestForInputs(raw)
|
||||
m, err := catalogue.Decode(raw)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -75,20 +75,17 @@ func TestModuleTreesFailsNamingANamedPathAbsentAtTheCommit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A manifest whose config value falls back to a predefined key is narrowed here as the indexer narrows it: both read it
|
||||
// with catalogue.ParseManifestForInputs (issue 1751), so a build module holding no published list computes the same
|
||||
// tree (ADR 0499 clause 2).
|
||||
// A manifest whose config value falls back to a predefined key is narrowed here: the merge preview decodes it and judges
|
||||
// nothing (novox/hq ADR 0578 §3), so a build module holding no published list computes the tree the indexer does (ADR
|
||||
// 0499 clause 2; issue 1751).
|
||||
func TestModuleTreesNarrowsAManifestFallingBackToAPredefinedKey(t *testing.T) {
|
||||
was := catalogue.ReplacePublished(nil) // the build modules' case: no published list
|
||||
defer catalogue.ReplacePublished(was)
|
||||
root := t.TempDir()
|
||||
raw := `{"module":"xterm","version":"1","config":{"size":{"type":"number","description":"The text size.","predefined":"font-size","default":12,"runtime":true}},` +
|
||||
`"build":{"artifacts":[{"name":"lib","kind":"package","language":"typescript","paths":["src"]}]}}`
|
||||
if _, err := catalogue.ParseManifest([]byte(raw)); err == nil {
|
||||
t.Fatal("ParseManifest reads the manifest with no list; the test proves nothing")
|
||||
}
|
||||
if _, err := catalogue.ParseManifestForInputs([]byte(raw)); err != nil {
|
||||
t.Fatalf("the indexer's read refuses the manifest: %v", err)
|
||||
if _, err := catalogue.Decode([]byte(raw)); err != nil {
|
||||
t.Fatalf("the preview's read refuses the manifest: %v", err)
|
||||
}
|
||||
writeTreeFile(t, root, "module.json", raw)
|
||||
run := func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
//go:build linux
|
||||
|
||||
package buildstep
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
const settingsMask = syscall.IN_CLOSE_WRITE | syscall.IN_MOVED_TO | syscall.IN_CREATE
|
||||
|
||||
// watchSettings wakes the puller each time its settings file is written or replaced (inotify on the file's directory, so
|
||||
// a rename over it counts): the event the node-engine's refill of the file is, which a parked puller at capacity 0
|
||||
// otherwise never sees. No timer: it blocks on the kernel's event. When the kernel drops the watch (IN_IGNORED: the
|
||||
// directory was replaced or removed) it is added again, said once; while the directory is missing the nearest existing
|
||||
// ancestor is watched for the directory's return. A watch that cannot start is said once.
|
||||
func (p *Puller) watchSettings(ctx context.Context) {
|
||||
if p.SettingsFile == "" {
|
||||
return
|
||||
}
|
||||
fd, err := syscall.InotifyInit1(syscall.IN_CLOEXEC | syscall.IN_NONBLOCK)
|
||||
if err != nil {
|
||||
p.logf("the settings file %s cannot be watched, so a refill of it wakes the puller only at another event: %v", p.SettingsFile, err)
|
||||
return
|
||||
}
|
||||
f := os.NewFile(uintptr(fd), "inotify")
|
||||
defer f.Close()
|
||||
dir, base := filepath.Split(p.SettingsFile)
|
||||
dir = filepath.Clean(dir)
|
||||
// arm watches dir, or when it is missing its nearest existing ancestor; it returns the watch descriptor and whether it
|
||||
// is the directory's own.
|
||||
arm := func() (int, bool, error) {
|
||||
wd, err := syscall.InotifyAddWatch(fd, dir, settingsMask)
|
||||
if err == nil {
|
||||
return wd, true, nil
|
||||
}
|
||||
for up := filepath.Dir(dir); ; up = filepath.Dir(up) {
|
||||
wd, aerr := syscall.InotifyAddWatch(fd, up, settingsMask)
|
||||
if aerr == nil {
|
||||
// the directory may have come back between the two calls: look once more, now that the ancestor is watched
|
||||
if own, oerr := syscall.InotifyAddWatch(fd, dir, settingsMask); oerr == nil {
|
||||
return own, true, nil
|
||||
}
|
||||
return wd, false, nil
|
||||
}
|
||||
if up == filepath.Dir(up) {
|
||||
return 0, false, err
|
||||
}
|
||||
}
|
||||
}
|
||||
wd, own, err := arm()
|
||||
if err != nil {
|
||||
p.logf("the settings file %s cannot be watched, so a refill of it wakes the puller only at another event: %v", p.SettingsFile, err)
|
||||
return
|
||||
}
|
||||
if p.watching != nil {
|
||||
p.watching()
|
||||
}
|
||||
go func() { <-ctx.Done(); f.Close() }()
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := f.Read(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
hit, rearm := false, false
|
||||
for off := 0; off+16 <= n; {
|
||||
ewd := int(int32(binary.LittleEndian.Uint32(buf[off:])))
|
||||
mask := binary.LittleEndian.Uint32(buf[off+4:])
|
||||
l := int(binary.LittleEndian.Uint32(buf[off+12:]))
|
||||
name := buf[off+16 : off+16+l]
|
||||
for i, c := range name {
|
||||
if c == 0 {
|
||||
name = name[:i]
|
||||
break
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case mask&syscall.IN_IGNORED != 0 && ewd == wd:
|
||||
rearm = true
|
||||
case ewd == wd && own && string(name) == base:
|
||||
hit = true
|
||||
case ewd == wd && !own:
|
||||
rearm = true // something appeared where the directory was: look again
|
||||
}
|
||||
off += 16 + l
|
||||
}
|
||||
if rearm {
|
||||
nwd, nown, err := arm()
|
||||
if err != nil {
|
||||
p.logf("the settings file %s is no longer watched, so a refill of it wakes the puller only at another event: %v", p.SettingsFile, err)
|
||||
return
|
||||
}
|
||||
if own || nown { // said once per loss of the watch on the directory itself
|
||||
if own {
|
||||
p.logf("the watch of the settings file %s was dropped (its directory was replaced); watching again", p.SettingsFile)
|
||||
}
|
||||
}
|
||||
wd, own = nwd, nown
|
||||
if p.watching != nil {
|
||||
p.watching()
|
||||
}
|
||||
hit = true // the file may have been refilled meanwhile
|
||||
}
|
||||
if hit {
|
||||
p.Wake()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//go:build !linux
|
||||
|
||||
package buildstep
|
||||
|
||||
import "context"
|
||||
|
||||
// watchSettings watches nothing off Linux: the puller wakes at its other events.
|
||||
func (p *Puller) watchSettings(ctx context.Context) {}
|
||||
@@ -186,59 +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})
|
||||
}
|
||||
|
||||
// **One issuer at every moment: minting turns on at 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 vault mints and the grants part creates the durable
|
||||
// consumers an account needs. A controller's value, a key deleted or expired, or one that cannot be read or parsed, turn
|
||||
// both off: when in doubt nothing is minted, so the controller and the vault never both mint one account. No clock and
|
||||
// no setting turns it.
|
||||
|
||||
// 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 nothing is minted"
|
||||
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 nothing is minted"
|
||||
}
|
||||
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 nothing is minted"
|
||||
}
|
||||
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 + "): it mints, so the vault mints nothing (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 nothing is minted"
|
||||
}
|
||||
return true, "the lease is handed to " + h.HandedTo + ": the vault mints every new account (ADR 0561 §1, §3)"
|
||||
}
|
||||
|
||||
@@ -9,49 +9,17 @@ 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
|
||||
}
|
||||
|
||||
// ReadsAnnounced splits a reader's `reads` (`<owner>.<name>`) by what each owner announced: granted when the owner
|
||||
// announced the state readable, or announced nothing yet (the transition); refused when the owner announced and did
|
||||
// not announce that state readable.
|
||||
func ReadsAnnounced(reads []string, announced map[string]announce.States) (granted, refused []string) {
|
||||
return ReadsAnnouncedBy("", reads, announced)
|
||||
}
|
||||
|
||||
// ReadsAnnouncedBy is ReadsAnnounced for one reader module, which also honours the readers an owner named for a state
|
||||
// (announce.State.Readers) and the composer's named readers (NamedReaders, ADR 0547 clause 9): a read named for other
|
||||
// modules is refused. An empty reader is judged by the readable mark alone.
|
||||
// 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
|
||||
// nothing yet (the transition); refused otherwise, and a read named for other modules is refused. There is no judgement
|
||||
// without the reader (issue 774): one made without it refused every named reader, and Users judges again for the
|
||||
// reader whatever split it is given (withAnnouncedReads). An empty reader is judged by the readable mark alone.
|
||||
func ReadsAnnouncedBy(reader string, reads []string, announced map[string]announce.States) (granted, refused []string) {
|
||||
for _, r := range reads {
|
||||
owner, name, ok := strings.Cut(r, ".")
|
||||
@@ -60,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) {
|
||||
|
||||
+539
-216
@@ -2,145 +2,43 @@ package buscompose
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/announce"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"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.
|
||||
func beyondTheControllers(user string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
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 {
|
||||
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+".")
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if !now[g] {
|
||||
t.Errorf("%s: the copy does not grant %s, which the controller's does", user, g)
|
||||
}
|
||||
}
|
||||
for g := range now {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,62 +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)
|
||||
}
|
||||
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) {
|
||||
@@ -290,6 +159,21 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
|
||||
!MaySubscribe(perms, "$SRV.INFO."+EngineSeat) || MaySubscribe(perms, "mesh.seat."+EngineSeat+".tool.hand-over.two") {
|
||||
t.Errorf("node one hears %v", perms.Subscribe)
|
||||
}
|
||||
// A verb or an event the seat's contract gains later is its engine's on its own node without a release of
|
||||
// the grants part (2026-10-31: mesh-host #184's optional verb composition was refused on every node, the
|
||||
// grants part's mesh-sdk pin predating it): the seat's subjects on its node, any verb, any event.
|
||||
for _, verb := range []string{"composition", "compose", "a-verb-added-later"} {
|
||||
if !MaySubscribe(perms, "mesh.seat."+EngineSeat+".tool."+verb+".one") ||
|
||||
!MaySubscribe(perms, "mesh.seat."+EngineSeat+".call."+verb+".one.messenger~one") ||
|
||||
MaySubscribe(perms, "mesh.seat."+EngineSeat+".tool."+verb+".two") ||
|
||||
MaySubscribe(perms, "mesh.seat."+EngineSeat+".call."+verb+".two.messenger~one") {
|
||||
t.Errorf("node one does not answer %s on its own node alone: %v", verb, perms.Subscribe)
|
||||
}
|
||||
}
|
||||
if !MayPublish(perms, "mesh.seat."+EngineSeat+".event.an-event-added-later.one") ||
|
||||
MayPublish(perms, "mesh.seat."+EngineSeat+".event.an-event-added-later.two") {
|
||||
t.Errorf("node one does not say a later event on its own node alone: %v", perms.Publish)
|
||||
}
|
||||
case u.Kind == KindNodeTools || u.Module == "messenger":
|
||||
if !MayPublish(perms, "mesh.seat."+EngineSeat+".tool.root-free.one") {
|
||||
t.Errorf("%s cannot call one's root-free", u.Username())
|
||||
@@ -316,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)
|
||||
}
|
||||
@@ -356,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"},
|
||||
@@ -378,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
|
||||
@@ -418,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 {
|
||||
@@ -488,39 +512,36 @@ 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)
|
||||
// 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{GrantsAccounts}},
|
||||
{Module: "restic", ReadsRefused: []string{"disk-load.readings"}},
|
||||
{Module: "messenger", Reads: []string{GrantsAccounts}}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reads := map[string]string{}
|
||||
for _, u := range judged {
|
||||
if u.Kind == KindModule {
|
||||
reads[u.Module] = strings.Join(u.Reads, ",")
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
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.
|
||||
filled, _ := WithPasswords(judged, map[string]string{judged[0].Username(): "$2a$11$h"})
|
||||
list, err := ComposeAccounts(filled[:1])
|
||||
perms, perr := PermissionsFor(filled[0])
|
||||
if err != nil || perr != nil || !strings.Contains(list, " users = [\n"+AccountsEntry(filled[0].Username(), "$2a$11$h", perms)+" ]\n") {
|
||||
t.Errorf("the accounts entry is not ComposeAccounts' own: %v %v\n%s", err, perr, list)
|
||||
}
|
||||
c, _ := ConsumerFor(Principal{Kind: KindModule, Node: "a", Module: "m", Consumes: []string{"x.happened"}})
|
||||
cfg := ConsumerConfig(c)
|
||||
@@ -548,6 +569,8 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
|
||||
t.Errorf("the grants part creates consumers as %v", u.CreatesConsumers)
|
||||
}
|
||||
}
|
||||
// 1952 S5 (novox/hq ADR 0564 clauses 2, 5, 8; Limits 2, 4, 8): the health holders' credentials.
|
||||
healthChecks(t)
|
||||
q := ConsumerConfig(QueueConsumer(Principal{Kind: KindProcess, Node: "a", Module: "m", Process: "p"},
|
||||
QueueOwner{Process: "p", Stream: "M_WORK", MaxDeliver: 3}))
|
||||
if q.Durable != "a_m_p" || q.FilterSubject != "" || q.MaxDeliver != 3 || q.DeliverPolicy != jetstream.DeliverAllPolicy {
|
||||
@@ -555,3 +578,303 @@ 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{
|
||||
"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", "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},
|
||||
} {
|
||||
granted, _ := ReadsAnnouncedBy(c.reader, []string{c.read}, announced)
|
||||
if (len(granted) == 1) != c.may {
|
||||
t.Errorf("%s reads %s: %v, want %v", c.reader, c.read, granted, c.may)
|
||||
}
|
||||
}
|
||||
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"}},
|
||||
{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)}},
|
||||
{Module: "mesh-healers", State: []Bucket{bucket("mesh-healers", "findings"), bucket("mesh-healers", "attempts"), bucket("mesh-healers", "would")}},
|
||||
{Module: WatchmanModule, State: []Bucket{bucket(WatchmanModule, "findings")}, Reads: []string{delivery.NodeHealthRead}},
|
||||
{Module: "mesh-hand-acts", State: []Bucket{bucket("mesh-hand-acts", "warrants")}, Logs: []Log{{Module: "mesh-hand-acts", Name: "acts"}}},
|
||||
// ADR 0570 clauses 1, 8 (issue 1954 L6-P5): mesh-provisioner keeps provisions and plans; the vault and a provider
|
||||
// read them.
|
||||
{Module: "mesh-provisioner", State: []Bucket{bucket("mesh-provisioner", "provisions"), bucket("mesh-provisioner", "plans"),
|
||||
bucket("mesh-provisioner", "registry")}},
|
||||
{Module: "mesh-vault", Reads: []string{"mesh-provisioner.provisions", "mesh-provisioner.plans"}},
|
||||
{Module: "postgres", Reads: []string{"mesh-provisioner.provisions", "mesh-provisioner.plans"}},
|
||||
// The node's provisioner client reads its machine's plans and records no needs (ADR 0570 clause 2; its V3).
|
||||
{Module: "mesh-provisioner-client", ProvisionsNode: true},
|
||||
// ADR 0571 clauses 2, 4 (issue 1955): the seat mesh-artifact-sweep says artifacts-gone; the assigner keeps seat-holders.
|
||||
{Module: "artifact-store-tools", Holds: []Seat{{Name: "mesh-artifact-sweep", Emits: []string{"artifacts-gone"}}},
|
||||
Reads: []string{delivery.SeatHoldersRead}},
|
||||
{Module: delivery.AssignerModule, State: []Bucket{bucket(delivery.AssignerModule, delivery.SeatHoldersState)}},
|
||||
// ADR 0571 clause 3: disk-load keeps the readings; the backup holder reads them.
|
||||
// ADR 0577 clause 1: and it reads its own node's keys of the node-engine's data items, which restic may not.
|
||||
{Module: "disk-load", State: []Bucket{bucket("disk-load", "readings")}, Reads: []string{delivery.EngineDataRead}},
|
||||
{Module: "restic", Reads: []string{"disk-load.readings", delivery.EngineDataRead}, State: []Bucket{bucket("restic", "good")}},
|
||||
// ADR 0571 clauses 1, 9: the indexer reads the builder's log through its own consumer; the catalogue module asks
|
||||
// the builder's tool.
|
||||
{Module: delivery.BuilderModule, Logs: []Log{{Module: delivery.BuilderModule, Name: delivery.BuilderLog, MaxAgeDays: 30}}},
|
||||
{Module: delivery.IndexerModule, Reads: []string{delivery.BuildLogRead}},
|
||||
{Module: "mesh-catalog", Invokes: []string{delivery.BuilderModule + "." + delivery.BuildsSinceTool}},
|
||||
{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.RemovesEngineActs, catalogue.RemovesControllerLeftovers}}, {Process: "other"}}},
|
||||
{Module: "shop", Processes: []ProcessPrincipal{{Process: "nats-tools", Removes: []string{catalogue.RemovesEngineActs}}}},
|
||||
}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
may := map[string][]string{
|
||||
"a.mesh-conditions": {"$KV.mesh-conditions_conditions.machine.g14.silent"},
|
||||
"a.mesh-provisioner": {"$KV.mesh-provisioner_provisions.a.keycloak.postgres-database", "$KV.mesh-provisioner_plans.a.keycloak"},
|
||||
"a.mesh-vault": {"$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.a.keycloak.postgres-database",
|
||||
"$JS.API.DIRECT.GET.KV_mesh-provisioner_plans.$KV.mesh-provisioner_plans.a.keycloak"},
|
||||
"a.mesh-hand-acts": {"$KV.mesh-hand-acts_warrants.mesh-config.ask-1", "mesh.log.mesh-hand-acts.acts.act-1"},
|
||||
"a.disk-load": {"$KV.disk-load_readings.a.pg.data", "$JS.API.DIRECT.GET.KV_mesh-host_data.$KV.mesh-host_data.a.pg",
|
||||
"$JS.API.CONSUMER.CREATE.KV_mesh-host_data.c1.$KV.mesh-host_data.a.*"},
|
||||
"provisioner.a": {"$JS.API.DIRECT.GET.KV_mesh-provisioner_plans.$KV.mesh-provisioner_plans.a.keycloak",
|
||||
"$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_plans.c1.$KV.mesh-provisioner_plans.a.keycloak",
|
||||
// Every machine's plans, as the client V3 watches them (issue 774, 2026-10-30).
|
||||
"$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_plans.c1.$KV.mesh-provisioner_plans.>",
|
||||
"$JS.API.DIRECT.GET.KV_mesh-provisioner_plans.$KV.mesh-provisioner_plans.b.keycloak",
|
||||
// The needs, for the client before V3, until the lease write (review round 3).
|
||||
"$KV." + ProvisionerNeedsBucket + ".a.keycloak.db.x"},
|
||||
// ADR 0571 clause 3: restic's bundle writes each item's last good night as its own state; the self-check reads it.
|
||||
"a.mesh-self-check": {"$JS.API.DIRECT.GET.KV_restic_good.$KV.restic_good.a.pg.data"},
|
||||
"a.mesh-grants": {"$KV.mesh-grants_withheld.reader.mail.a"},
|
||||
"a.restic": {"$JS.API.DIRECT.GET.KV_disk-load_readings.$KV.disk-load_readings.a.pg.data", "$KV.restic_good.a.pg.data",
|
||||
// ADR 0571 clause 7: its own membership from the grants part's state.
|
||||
"$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.a.restic"},
|
||||
"a.mesh-delivery-indexer": {"$JS.API.CONSUMER.CREATE.LOG_mesh-delivery-builder_build-log.r1.mesh.log.mesh-delivery-builder.build-log.letta",
|
||||
"$JS.API.CONSUMER.MSG.NEXT.LOG_mesh-delivery-builder_build-log.r1", "$JS.API.CONSUMER.DELETE.LOG_mesh-delivery-builder_build-log.r1",
|
||||
"$JS.API.DIRECT.GET.LOG_mesh-delivery-builder_build-log.mesh.log.mesh-delivery-builder.build-log.letta"},
|
||||
"a.mesh-delivery-builder": {"mesh.log.mesh-delivery-builder.build-log.letta"},
|
||||
"a.mesh-catalog": {"mesh.mod.mesh-delivery-builder.tool.builder_builds_since"},
|
||||
WatchmanModule: {"$JS.API.CONSUMER.CREATE.DEAD_LETTER_NOTICES." + WatchmanModule, "$JS.API.CONSUMER.MSG.NEXT.DEAD_LETTER_NOTICES." + WatchmanModule,
|
||||
"$JS.API.CONSUMER.INFO.DEAD_LETTER_NOTICES.controller", "$JS.API.STREAM.MSG.GET.DEAD_LETTERS"},
|
||||
WatchmanModule + " sources": {"$JS.API.STREAM.MSG.GET.EVENTS", "$JS.API.STREAM.INFO.SEAT_TELEGRAM_SENDER",
|
||||
"$JS.API.STREAM.MSG.GET.M_WORK", "$JS.API.STREAM.INFO.EVENTS", "mesh.events.dead.EVENTS.a_m"},
|
||||
"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",
|
||||
// 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).
|
||||
"a.mesh-conditions": {"$KV.mesh-self-check_findings.x", "$KV.mesh-healers_attempts.x", "$KV.mesh-controller_conditions.x"},
|
||||
"a.mesh-self-check": {"$KV.mesh-conditions_conditions.x", "$KV.mesh-conditions_takeover.switch", "$KV.restic_good.a.pg.data",
|
||||
"$KV.mesh-grants_withheld.reader.mail.a", "$SRV.INFO", "$SRV.PING.node-tools",
|
||||
"$JS.API.DIRECT.GET.KV_mesh-grants_withheld.$KV.mesh-grants_withheld.reader.mail.a"},
|
||||
// Only mesh-provisioner writes provisions and plans, and it writes none of the grants part's states (ADR 0570
|
||||
// clauses 1, 8).
|
||||
"a.mesh-provisioner": {"$KV.mesh-grants_accounts.a.m", "$KV.mesh-grants_user-list.sealed"},
|
||||
"a.mesh-vault": {"$KV.mesh-provisioner_provisions.a.keycloak.postgres-database", "$KV.mesh-provisioner_plans.a.keycloak",
|
||||
"mesh.seat.mesh-artifact-sweep.event.artifacts-gone"},
|
||||
// Each writes its own state alone, and these users are composed.
|
||||
"a.disk-load": {"$KV.restic_good.a.pg.data", "$KV.mesh-host_data.a.pg",
|
||||
"$JS.API.DIRECT.GET.KV_mesh-host_data.$KV.mesh-host_data.b.pg", "$JS.API.CONSUMER.CREATE.KV_mesh-host_data.c1.$KV.mesh-host_data.b.*"},
|
||||
"a.mesh-delivery-builder": {"$KV.disk-load_readings.a.pg.data"},
|
||||
// It reads every machine's plans (a provider's consumers are bound there, ADR 0373) but writes none, nor another
|
||||
// machine's needs, which it keeps for the client before V3 until the lease write.
|
||||
"provisioner.a": {"$KV.mesh-provisioner_plans.a.keycloak", "$KV." + ProvisionerNeedsBucket + ".b.keycloak.db.x"},
|
||||
// Only the assigner writes seat-holders.
|
||||
"a.artifact-store-tools": {"$KV.mesh-delivery-assigner_seat-holders.mesh-broker"},
|
||||
// Only disk-load writes its readings.
|
||||
"a.restic": {"$KV.disk-load_readings.a.pg.data", "$JS.API.DIRECT.GET.KV_mesh-host_data.$KV.mesh-host_data.a.pg"},
|
||||
"a.postgres": {"$KV.mesh-provisioner_provisions.a.keycloak.postgres-database", "$KV.mesh-provisioner_plans.a.keycloak"},
|
||||
// Nor mesh-hand-acts' log and warrants, which it alone writes (ADR 0565 clause 10).
|
||||
"a.mesh-healers": {"$KV.mesh-conditions_conditions.x", "$KV.mesh-conditions_takeover.switch",
|
||||
"$KV.mesh-hand-acts_warrants.x", "mesh.log.mesh-hand-acts.acts.x"},
|
||||
// The watchman never deletes or changes the controller's consumer (clause 5).
|
||||
// Its reads and deletes of a message are on the streams a dead letter comes from, named, never a bucket's
|
||||
// (the lead, 2026-10-29).
|
||||
WatchmanModule + " sources": {"$JS.API.STREAM.MSG.GET.KV_mesh-vault_bus-accounts", "$JS.API.STREAM.MSG.GET.KV_x",
|
||||
"$JS.API.STREAM.MSG.DELETE.KV_mesh-host_acts", "$JS.API.STREAM.MSG.GET.OBJ_mesh-grants-user-list", "$JS.API.STREAM.MSG.GET.OTHER",
|
||||
// In L4 it neither delivers again nor drops (clause 5, the lead 2026-10-29).
|
||||
"$JS.API.STREAM.MSG.DELETE.EVENTS", "$JS.API.STREAM.MSG.DELETE.DEAD_LETTERS", "$JS.API.CONSUMER.CREATE.DEAD_LETTERS.x",
|
||||
"mesh.again.a_m.x"},
|
||||
WatchmanModule: {"$KV.mesh-conditions_conditions.x", "$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller",
|
||||
"$JS.API.CONSUMER.CREATE.DEAD_LETTER_NOTICES.controller", "$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES." + WatchmanModule},
|
||||
// The bus module's tools remove the one consumer and the acts' keys, nothing else (Limits 4); its other accounts
|
||||
// remove nothing; an account is granted the removals its bus declares and no other (the catalogue refuses them
|
||||
// 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",
|
||||
// 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"},
|
||||
}
|
||||
// ADR 0571 clause 1: a read naming another module's log is issued to the reader as a log it reads, never writes.
|
||||
if m := MembershipFor("a", Declared{Module: delivery.IndexerModule, Reads: []string{delivery.BuildLogRead, "x.y"}},
|
||||
Placements{Logs: map[string]bool{delivery.BuildLogRead: true}}); len(m.Logs) != 1 || m.Logs[0].Writes ||
|
||||
m.Logs[0].Stream != "LOG_mesh-delivery-builder_build-log" || m.Logs[0].Name != delivery.BuildLogRead {
|
||||
t.Errorf("the indexer's membership issues the logs %+v", m.Logs)
|
||||
}
|
||||
// ADR 0565 clauses 6, 7 (issue 1953 L5-S4): a node's engine writes its own packages key and no other node's; only the
|
||||
// assigner writes its policies.
|
||||
for _, u := range users {
|
||||
perms, err := PermissionsFor(u)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// ADR 0570 clause 6: a node's engine reads its own node's provision keys, and no other node's, and writes none.
|
||||
if u.Kind == KindNode && (!MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.a.keycloak.db") ||
|
||||
MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.b.keycloak.db") ||
|
||||
MayPublish(perms, "$KV.mesh-provisioner_provisions.a.keycloak.db")) {
|
||||
t.Errorf("node %s reads the provisions %v", u.Node, perms.Publish)
|
||||
}
|
||||
// Issue 774: a node's engine writes its own health report's key and no other node's.
|
||||
if u.Kind == KindNode && (!MayPublish(perms, "$KV.mesh-host_health.a") || MayPublish(perms, "$KV.mesh-host_health.b")) {
|
||||
t.Errorf("node %s writes the health reports %v", u.Node, perms.Publish)
|
||||
}
|
||||
if u.Kind == KindModule && u.Module == WatchmanModule && (!MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.b") ||
|
||||
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)
|
||||
}
|
||||
// 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).
|
||||
if aged, kept := (Log{Module: "mesh-hand-acts", Name: "acts", MaxAgeDays: 400}).Config(), (Log{Module: "m", Name: "l"}).Config(); aged.MaxAge != 400*24*time.Hour || kept.MaxAge != 0 || !aged.DenyDelete {
|
||||
t.Errorf("an aged log's stream keeps %s, an unaged one %s", aged.MaxAge, kept.MaxAge)
|
||||
}
|
||||
// The tools account's secret is the module's own broker-<binary>, which its manifest's own-secrets names and the
|
||||
// vault keeps by that name (AccountSecret).
|
||||
if w := (WantedAccount{User: "a.nats.nats-tools", Kind: KindProcess, Node: "a", Module: "nats", Process: "nats-tools",
|
||||
URL: "nats://a:4222"}); w.Valid() != nil || w.OwnSecret() != "broker-nats-tools" {
|
||||
t.Errorf("the tools account is held in %q (%v)", w.OwnSecret(), w.Valid())
|
||||
}
|
||||
// Issue 1952: a module that holds a seat says its own seat's events, also when the tool runner carries it, so the
|
||||
// self-check's `checked`, the keeper's condition events and the healers' `healer-acted` reach their hearers. Its
|
||||
// membership lists them, the runtime is granted them; a user of the seat is not, nor a machine's event on a node seat
|
||||
// (granted per machine, to the holder's own account only).
|
||||
selfCheck := Declared{Module: "mesh-self-check", Holds: []Seat{{Name: "mesh-self-check", Scope: "mesh", Emits: []string{"checked"}}}}
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
d Declared
|
||||
subject string
|
||||
may bool
|
||||
}{
|
||||
{"the self-check says checked", selfCheck, "mesh.seat.mesh-self-check.event.checked", true},
|
||||
{"the keeper says a condition raised", Declared{Module: "mesh-conditions", Holds: []Seat{{Name: "mesh-conditions", Scope: "mesh",
|
||||
Emits: []string{"condition-raised", "condition-cleared"}}}}, "mesh.seat.mesh-conditions.event.condition-cleared", true},
|
||||
{"a user does not say the seat's events", Declared{Module: "mesh-delivery-view", Uses: []Seat{{Name: "mesh-self-check",
|
||||
Scope: "mesh", Emits: []string{"checked"}}}}, "mesh.seat.mesh-self-check.event.checked", false},
|
||||
{"the holder says no other seat's", selfCheck, "mesh.seat.mesh-conditions.event.condition-raised", false},
|
||||
{"a machine's event on a node seat is not composed here", Declared{Module: "pause", Holds: []Seat{{Name: "node-power",
|
||||
Scope: "node", Emits: []string{"paused.*"}}}}, "mesh.seat.node-power.event.paused.b", false},
|
||||
} {
|
||||
m := MembershipFor("a", c.d, Placements{})
|
||||
listed := m.SeatTraffic != nil && slices.ContainsFunc(m.SeatTraffic.Publish, func(p string) bool { return p == c.subject })
|
||||
perms, err := PermissionsFor(Principal{Kind: KindNodeTools, Node: "a", Carries: []Declared{c.d}})
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", c.name, err)
|
||||
}
|
||||
if listed != c.may || MayPublish(perms, c.subject) != c.may {
|
||||
t.Errorf("%s: the membership lists %s %v and the runtime may publish it %v, want %v", c.name, c.subject, listed,
|
||||
MayPublish(perms, c.subject), c.may)
|
||||
}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, u := range users {
|
||||
name := u.Username()
|
||||
if u.Module == WatchmanModule && u.Kind == KindModule {
|
||||
name = WatchmanModule
|
||||
}
|
||||
perms, err := PermissionsFor(u)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
if name == WatchmanModule {
|
||||
// It hears its watchdogs' words on core subjects (Limits 3, 11).
|
||||
for _, subject := range []string{"mesh.mod.power.event.sleeping", "mesh.control.g14.tools-alive",
|
||||
"mesh.seat.mesh-self-check.event.checked", "mesh.mod.keycloak.event.provisioner.failing",
|
||||
"mesh.mod.nats.event.bus.slow-consumer", "prov.applied.g14.keycloak"} {
|
||||
if !MaySubscribe(perms, subject) {
|
||||
t.Errorf("the watchman may not hear %s", subject)
|
||||
}
|
||||
}
|
||||
// Never the bus's API audit, which carries every API answer in the account (the lead, 2026-10-29).
|
||||
if MaySubscribe(perms, "$JS.EVENT.ADVISORY.API") || MaySubscribe(perms, "$JS.EVENT.ADVISORY.API.x") {
|
||||
t.Error("the watchman hears the bus's API audit")
|
||||
}
|
||||
if !MayPublish(perms, "$JS.API.DIRECT.GET.PROV_APPLIED.prov.applied.g14.keycloak") {
|
||||
t.Error("the watchman may not read the newest provisioner report")
|
||||
}
|
||||
}
|
||||
subjects := append(append([]string(nil), may[name]...), may[name+" sources"]...)
|
||||
denied := append(append([]string(nil), mayNot[name]...), mayNot[name+" sources"]...)
|
||||
if len(may[name+" sources"]) > 0 {
|
||||
seen[name+" sources"] = true
|
||||
}
|
||||
for _, subject := range subjects {
|
||||
seen[name] = true
|
||||
if !MayPublish(perms, subject) {
|
||||
t.Errorf("%s may not publish %s", name, subject)
|
||||
}
|
||||
}
|
||||
for _, subject := range denied {
|
||||
seen[name] = true
|
||||
if MayPublish(perms, subject) {
|
||||
t.Errorf("%s may publish %s", name, subject)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Every row names a user that was composed, in either table: a row for a user absent would pass for nothing.
|
||||
for _, table := range []map[string][]string{may, mayNot} {
|
||||
for name := range table {
|
||||
if !seen[name] {
|
||||
t.Errorf("no user %s was composed", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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,
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,10 @@ func TestTheGrantsPartCreatesOnlyDeclaredStreams(t *testing.T) {
|
||||
Streams: []ModuleStream{{Module: "mesh-delivery-builder", Name: "BUILDASKS", Events: []string{"ask"}}}},
|
||||
{Module: GrantsModule, State: []Bucket{{Module: GrantsModule, Name: "assertions"}}, Reads: []string{VaultBusAccounts}},
|
||||
{Module: RuntimeModule},
|
||||
// The node-engine's manifest as a placement names it (issue 774, met twice on 2026-10-30): no account of its
|
||||
// own, so the runner would carry it, and its state is the node's to write alone.
|
||||
{Module: EngineModule, NoAccount: true, Consumes: []string{"restic.backed-up"},
|
||||
State: []Bucket{{Module: EngineModule, Name: "health"}, {Module: EngineModule, Name: EngineActsState}}},
|
||||
}},
|
||||
// The node-engine's own manifest, placed on every node though no node is assigned it (novox/hq ADR 0563 §2, §3).
|
||||
Engine: &Declared{Module: EngineModule, State: []Bucket{{Module: EngineModule, Name: EngineActsState}}},
|
||||
@@ -32,6 +36,9 @@ func TestTheGrantsPartCreatesOnlyDeclaredStreams(t *testing.T) {
|
||||
}
|
||||
perms := map[string]Permissions{}
|
||||
for _, u := range users {
|
||||
if u.Module == EngineModule {
|
||||
t.Errorf("the node-engine's manifest composed a user of its own: %s", u.Username())
|
||||
}
|
||||
if u.Kind == KindNode || u.Kind == KindNodeTools {
|
||||
p, err := PermissionsFor(u)
|
||||
if err != nil {
|
||||
@@ -64,6 +71,8 @@ func TestTheGrantsPartCreatesOnlyDeclaredStreams(t *testing.T) {
|
||||
{GrantsModule, "$JS.API.STREAM.CREATE.KV_mesh-delivery-builder_undeclared", false},
|
||||
{GrantsModule, "$JS.API.STREAM.CREATE.EVENTS", false},
|
||||
{GrantsModule, "$JS.API.STREAM.CREATE.ASSIGNMENTS", false},
|
||||
// Research 072 row 33: a runtime reads its membership from the grants part's state, never the assignments stream.
|
||||
{"mesh-delivery-builder", "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.novox.mesh-delivery-builder", false},
|
||||
{GrantsModule, "$JS.API.STREAM.INFO.EVENTS", false},
|
||||
{"mesh-delivery-builder", "$JS.API.STREAM.CREATE.KV_mesh-delivery-builder_steps", false},
|
||||
// The node-engine's acts: created by the grants part, written by each node's engine under its own keys alone.
|
||||
@@ -75,6 +84,11 @@ func TestTheGrantsPartCreatesOnlyDeclaredStreams(t *testing.T) {
|
||||
{"node.ace", "$JS.API.DIRECT.GET.KV_mesh-host_acts.$KV.mesh-host_acts.ace.ask.hand-over-1", true},
|
||||
{"node.ace", "$JS.API.DIRECT.GET.KV_mesh-host_acts.$KV.mesh-host_acts.novox.ask.hand-over-1", false},
|
||||
{"novox.node-tools", "$KV.mesh-host_acts.novox.act.hand-over-1", false},
|
||||
// The runner never carries the engine (issue 774): its health report is the node's own key alone.
|
||||
{"novox.node-tools", "$KV.mesh-host_health.novox", false},
|
||||
{"novox.node-tools", "$KV.mesh-host_acts.novox.ask.x", false},
|
||||
{"node.novox", "$KV.mesh-host_health.novox", true},
|
||||
{"node.novox", "$KV.mesh-host_health.ace", false},
|
||||
// The sealed user list (ADR 0547 clause 7): the grants part writes its own bucket and no other.
|
||||
{GrantsModule, "$JS.API.STREAM.CREATE.OBJ_mesh-grants-user-list", true},
|
||||
{GrantsModule, "$O.mesh-grants-user-list.C.abc", true},
|
||||
|
||||
+43
-6
@@ -3,6 +3,8 @@ package buscompose
|
||||
import (
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/provisions"
|
||||
)
|
||||
|
||||
// The node seat node-engine (novox/hq ADR 0563 §1): every node's engine holds it on its own bus account, and no module
|
||||
@@ -22,18 +24,34 @@ const (
|
||||
// EngineActsBucket is that state's bucket.
|
||||
var EngineActsBucket = BucketName(EngineModule, EngineActsState)
|
||||
|
||||
// EnginePackagesBucket is the engine's state of its node's declared packages (novox/hq ADR 0565 clause 6): one key, the
|
||||
// node's name, written by that node's engine alone.
|
||||
var EnginePackagesBucket = BucketName(EngineModule, delivery.EnginePackagesState)
|
||||
|
||||
// EngineDataBucket is the engine's state of its node's placed modules' data items (novox/hq ADR 0577 clause 1): one
|
||||
// key `<node>.<module>` per module, written by that node's engine alone.
|
||||
var EngineDataBucket = delivery.EngineDataBucket
|
||||
|
||||
// EnginePackagesGrants is what a node's engine publishes to keep its node's key of the packages bucket: the key alone,
|
||||
// and its read.
|
||||
func EnginePackagesGrants(node string) []string {
|
||||
return append([]string{"$KV." + EnginePackagesBucket + "." + node}, readGrants(EnginePackagesBucket, node)...)
|
||||
}
|
||||
|
||||
// engineGrants is what a node's engine is granted to hold its node seat and to ask the operator (ADR 0563 §1, §3): it
|
||||
// answers its own node's verbs (the caller-named subjects are added beside them with every principal's), says the
|
||||
// seat's events about its node, answers discovery for the seat, asks who answers what (the login shell's execute, ADR
|
||||
// 0563 §6), and asks the operator under asks.EngineAsker(node) on the router's seat as its holder declares it, as any
|
||||
// user of that seat is granted.
|
||||
//
|
||||
// Its verbs and events are granted as any verb and any event of the seat on its own node, not as the contract's list:
|
||||
// the list is compiled into whichever mesh-sdk the grants part pins, so a verb the engine gained before the grants part
|
||||
// was released was refused on every node and the engine reopened its link every few minutes (2026-10-31, the optional
|
||||
// verb composition of mesh-host #184). As a module answers `mesh.mod.<module>.tool.>`, nothing is given away: no other
|
||||
// principal may subscribe this seat's subjects of this node, and a caller is still granted each verb by name.
|
||||
func engineGrants(p Principal) (pub, sub []string) {
|
||||
for _, v := range catalogue.EngineVerbs() {
|
||||
sub = append(sub, "mesh.seat."+EngineSeat+".tool."+v.Name+"."+p.Node)
|
||||
}
|
||||
for _, e := range catalogue.EngineEvents {
|
||||
pub = append(pub, "mesh.seat."+EngineSeat+".event."+e+"."+p.Node)
|
||||
}
|
||||
sub = append(sub, "mesh.seat."+EngineSeat+".tool.*."+p.Node)
|
||||
pub = append(pub, "mesh.seat."+EngineSeat+".event.*."+p.Node)
|
||||
sub = append(sub, announcing(EngineSeat)...)
|
||||
pub = append(pub, discovering()...)
|
||||
tp, ts := seatTrafficGrants(SeatTrafficOf(asks.EngineAsker(p.Node), nil, p.Uses, nil))
|
||||
@@ -41,5 +59,24 @@ func engineGrants(p Principal) (pub, sub []string) {
|
||||
if own, err := NodeKeysGrants(EngineActsBucket, p.Node); err == nil {
|
||||
pub = append(pub, own...)
|
||||
}
|
||||
// And the provisions its node's modules consume, its own node's keys alone, read (novox/hq ADR 0570 clause 6: the
|
||||
// consuming engine checks the vault's name and generation of what it is handed).
|
||||
if safeSubject.MatchString(p.Node) {
|
||||
pub = append(pub, readGrants(provisions.ProvisionsBucket, p.Node+".>")...)
|
||||
}
|
||||
// And its node's own health report, its key `<node>` alone (issue 774, the operator's alert table): what the watchman
|
||||
// reads for the machine's alerts.
|
||||
if safeSubject.MatchString(p.Node) {
|
||||
pub = append(pub, append([]string{"$KV." + delivery.NodeHealthBucket + "." + p.Node},
|
||||
readGrants(delivery.NodeHealthBucket, p.Node)...)...)
|
||||
}
|
||||
// And its node's placed modules' data items, its own node's keys alone (ADR 0577 clause 1).
|
||||
if own, err := NodeKeysGrants(EngineDataBucket, p.Node); err == nil {
|
||||
pub = append(pub, own...)
|
||||
}
|
||||
// And its node's declared packages, its own key alone (ADR 0565 clause 6).
|
||||
if safeSubject.MatchString(p.Node) {
|
||||
pub = append(pub, EnginePackagesGrants(p.Node)...)
|
||||
}
|
||||
return append(pub, tp...), append(sub, ts...)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
package buscompose
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"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; ADR 0590).
|
||||
//
|
||||
// - 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 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).
|
||||
|
||||
// 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
|
||||
|
||||
// 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.RemovesEngineActs:
|
||||
out = append(out, "$JS.API.STREAM.INFO.KV_"+EngineActsBucket, "$KV."+EngineActsBucket+".>")
|
||||
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, ADR 0590)", r,
|
||||
strings.Join([]string{catalogue.RemovesEngineActs, catalogue.RemovesControllerLeftovers}, ", "))
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
// exemption from a node's engine being the only writer of its keys, for removal (ADR 0564 Limits 4).
|
||||
func removesEngineActs(p Principal, pattern string) bool {
|
||||
return p.Kind == KindProcess && slices.Contains(p.Removes, catalogue.RemovesEngineActs) && pattern == "$KV."+EngineActsBucket+".>"
|
||||
}
|
||||
|
||||
// WatchmanDeadLetterConsumer is the watchman's own consumer on DEAD_LETTER_NOTICES, made only once the lease names no
|
||||
// controller and `controller` is gone (ADR 0564 clause 5).
|
||||
const WatchmanDeadLetterConsumer = WatchmanModule
|
||||
|
||||
// WatchmanDeadLetterGrants are what the watchman may publish for the dead letters in L4 (ADR 0564 clause 5), at least
|
||||
// privilege: its own pulled consumer on DEAD_LETTER_NOTICES (made, read, pulled, acknowledged); whether `controller`
|
||||
// still stands (read only); a read of DEAD_LETTERS (its information and one kept message); a copy kept
|
||||
// (`mesh.events.dead.>`); and on each stream a notice can name (sources: EVENTS, the seats' work queues and the
|
||||
// modules' own), its information, for a source's age (ADR 0376), and the message given up on, each stream by its name.
|
||||
// Never a wildcard over the streams and never a bucket's or an object store's stream: a read of any message would read
|
||||
// every bucket (the lead, 2026-10-29, on issue 1952 S5). Delivering again and dropping stay refused in L4, so it may
|
||||
// neither delete a message nor publish `mesh.again.>`.
|
||||
func WatchmanDeadLetterGrants(sources []string) []string {
|
||||
own := DeadLetterNoticesStream + "." + WatchmanDeadLetterConsumer
|
||||
out := []string{
|
||||
"$JS.API.STREAM.INFO." + DeadLetterNoticesStream,
|
||||
"$JS.API.CONSUMER.INFO." + DeadLetterNoticesStream + "." + ControllerName,
|
||||
"$JS.API.CONSUMER.CREATE." + own,
|
||||
"$JS.API.CONSUMER.INFO." + own,
|
||||
"$JS.API.CONSUMER.MSG.NEXT." + own,
|
||||
"$JS.ACK." + own + ".>",
|
||||
"$JS.API.STREAM.INFO." + DeadLettersStream,
|
||||
"$JS.API.STREAM.MSG.GET." + DeadLettersStream,
|
||||
deadLetterPrefix + ">",
|
||||
// And the provisioner clients' reports, the newest per subject read at its start (S21, ADR 0362 §3).
|
||||
"$JS.API.STREAM.INFO." + ProvAppliedStream,
|
||||
"$JS.API.STREAM.MSG.GET." + ProvAppliedStream,
|
||||
"$JS.API.DIRECT.GET." + ProvAppliedStream + ".>",
|
||||
}
|
||||
for _, s := range DeadLetterSources(sources) {
|
||||
out = append(out, "$JS.API.STREAM.INFO."+s, "$JS.API.STREAM.MSG.GET."+s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 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), 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 {
|
||||
return []string{
|
||||
"mesh.mod.power.event.*",
|
||||
"mesh.mod.*.event.provisioner.failing",
|
||||
"mesh.mod.*.event.provisioner.recovered",
|
||||
"mesh.control.*.tools-alive",
|
||||
"mesh.seat.mesh-self-check.event." + health.EventChecked,
|
||||
"$JS.EVENT.ADVISORY.CONSUMER.DELETED.>",
|
||||
"mesh.mod.nats.event.bus.slow-consumer",
|
||||
"mesh.mod.nats.event.bus.connection-closed",
|
||||
ProvAppliedSubjects,
|
||||
deadLetterPrefix + ">",
|
||||
}
|
||||
}
|
||||
|
||||
// DeadLetterSources are the streams of names a dead letter may come from, sorted, none twice: each a plain name, never a
|
||||
// bucket's (KV_) or an object store's (OBJ_) stream, the dead letters' own two streams, or a pattern.
|
||||
func DeadLetterSources(names []string) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, n := range names {
|
||||
switch {
|
||||
case seen[n], !safeSubject.MatchString(n), strings.HasPrefix(n, "KV_"), strings.HasPrefix(n, "OBJ_"),
|
||||
n == DeadLettersStream, n == DeadLetterNoticesStream:
|
||||
continue
|
||||
}
|
||||
seen[n] = true
|
||||
out = append(out, n)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// deadLetterSourcesOf are the streams the records' consumers give up on: EVENTS, the work queue of every seat that
|
||||
// accepts work, and every module's and process's own work queue.
|
||||
func deadLetterSourcesOf(r Records) []string {
|
||||
out := []string{EventsStream}
|
||||
for _, mods := range r.Assigned {
|
||||
for _, d := range mods {
|
||||
for _, s := range append(append([]Seat(nil), d.Holds...), d.Uses...) {
|
||||
if len(s.Accepts) > 0 {
|
||||
out = append(out, seatStreamName(s.Name))
|
||||
}
|
||||
}
|
||||
for _, q := range d.Queues {
|
||||
out = append(out, q.Stream)
|
||||
}
|
||||
for _, pr := range d.Processes {
|
||||
for _, q := range pr.Queues {
|
||||
out = append(out, q.Stream)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return DeadLetterSources(out)
|
||||
}
|
||||
|
||||
// keepsDeadLetters is the watchman keeping the dead letters: a second writer of the copies beside the controller, which
|
||||
// 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 p.Kind == KindModule && p.Module == WatchmanModule && pattern == deadLetterPrefix+">"
|
||||
}
|
||||
+62
-4
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
@@ -14,8 +15,10 @@ import (
|
||||
// A log follows a bucket in every respect (ADR 0201): the module names it locally, the mesh derives
|
||||
// its stream and subjects, the controller creates it from the catalogue on every raise and from
|
||||
// registration, never a module, and **no path of the mesh removes one** — a log whose declaration
|
||||
// is gone is reported, as a bucket is. Unlike a bucket, a log is its owner's alone: no other module
|
||||
// reads it, so there is no read of a log to grant or issue.
|
||||
// is gone is reported, as a bucket is. Only its owner appends to it. Another module reads it when its manifest's
|
||||
// `reads` names it, `<module>.<log>` (novox/hq ADR 0571 clause 1; the lead, 2026-10-29): by direct gets, and through an
|
||||
// ordered consumer of its own it creates and lets go of to read from a time or the newest per key and then follow new
|
||||
// entries; never a durable consumer, never a write.
|
||||
//
|
||||
// Pure, but for the stream's configuration, which is the server's own type so that what is tested
|
||||
// is exactly what is sent; jetstream.go is the part that asks a server.
|
||||
@@ -37,6 +40,8 @@ type Log struct {
|
||||
Name string
|
||||
// MaxMiB is its cap in MiB; zero is LogDefaultMiB.
|
||||
MaxMiB int
|
||||
// MaxAgeDays is how long an entry is kept; zero is as long as the log (catalogue.LogDeclaration).
|
||||
MaxAgeDays int
|
||||
}
|
||||
|
||||
// LogStreamPrefix starts every log's stream name, which no other stream of the mesh's starts with.
|
||||
@@ -74,7 +79,7 @@ func (l Log) Why() string {
|
||||
}
|
||||
|
||||
// Config is the stream a log is, field by field as novox/hq ADR 0297 fixes it: a file stream kept by
|
||||
// limits, with no maximum age and no cap per key, whose message is an entry's value and 4 KiB of
|
||||
// limits, with no maximum age unless the log says one (ADR 0565 §10) and no cap per key, whose message is an entry's value and 4 KiB of
|
||||
// headers at most, that refuses a new entry when full rather than
|
||||
// drop an old one, and that refuses deleting an entry or purging it. Direct gets are allowed, which
|
||||
// is how the runtime reads it.
|
||||
@@ -85,7 +90,7 @@ func (l Log) Config() jetstream.StreamConfig {
|
||||
Subjects: []string{l.Subject() + ".>"},
|
||||
Storage: jetstream.FileStorage,
|
||||
Retention: jetstream.LimitsPolicy,
|
||||
MaxAge: 0,
|
||||
MaxAge: time.Duration(l.MaxAgeDays) * 24 * time.Hour,
|
||||
MaxMsgs: -1,
|
||||
MaxMsgsPerSubject: -1,
|
||||
MaxBytes: l.MaxBytes(),
|
||||
@@ -135,6 +140,59 @@ func logGrants(module string, names []string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// DeclaredLogs is every log a module placed anywhere declares, as a read names it: `<module>.<log>`.
|
||||
func DeclaredLogs(r Records) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, ds := range r.Assigned {
|
||||
for _, d := range ds {
|
||||
for _, l := range d.Logs {
|
||||
out[d.Module+"."+l.Name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// withReadLogs is d with ReadLogs set: of its reads, those naming a log of logs.
|
||||
func withReadLogs(d Declared, logs map[string]bool) Declared {
|
||||
d.ReadLogs = nil
|
||||
for _, r := range d.Reads {
|
||||
if logs[strings.TrimSpace(r)] {
|
||||
d.ReadLogs = append(d.ReadLogs, strings.TrimSpace(r))
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// readLogGrants is what a reader of other modules' logs publishes, for each `<module>.<log>` read: the stream's info,
|
||||
// direct gets (an entry by sequence, the last of a key), and an ordered consumer of its own (created with any name and
|
||||
// filter, pulled, read and deleted). No append, no purge, no delete of an entry: the log's stream refuses those anyway.
|
||||
func readLogGrants(reads []string) []string {
|
||||
var out []string
|
||||
for _, r := range reads {
|
||||
module, name, ok := strings.Cut(r, ".")
|
||||
if !ok || !safeSubject.MatchString(module) || !safeSubject.MatchString(name) {
|
||||
continue
|
||||
}
|
||||
stream := LogStreamName(module, name)
|
||||
out = append(out, "$JS.API.STREAM.INFO."+stream, "$JS.API.DIRECT.GET."+stream, "$JS.API.DIRECT.GET."+stream+".>",
|
||||
"$JS.API.CONSUMER.CREATE."+stream+".>", "$JS.API.CONSUMER.MSG.NEXT."+stream+".*",
|
||||
"$JS.API.CONSUMER.INFO."+stream+".*", "$JS.API.CONSUMER.DELETE."+stream+".*")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// readLogsIssuedFor is every other module's log a module's code reads, as its membership lists them: by the read's
|
||||
// name, never written.
|
||||
func readLogsIssuedFor(d Declared) []LogIssued {
|
||||
var out []LogIssued
|
||||
for _, r := range d.ReadLogs {
|
||||
module, name, _ := strings.Cut(r, ".")
|
||||
out = append(out, LogIssued{Name: r, Stream: LogStreamName(module, name), Subject: LogSubject(module, name)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// logNames is the local names of a module's logs.
|
||||
func logNames(logs []Log) []string {
|
||||
out := make([]string, 0, len(logs))
|
||||
|
||||
@@ -24,6 +24,9 @@ type Placements struct {
|
||||
Interchangeable map[string]bool
|
||||
// Kinds is every kind held of a kinded bench, by whom and with what capabilities (ADR 0259 §5).
|
||||
Kinds []KindHeld
|
||||
// Logs is every log a module placed anywhere declares, `<module>.<log>` (DeclaredLogs): a read naming one is issued
|
||||
// as a log read (novox/hq ADR 0571 clause 1).
|
||||
Logs map[string]bool
|
||||
}
|
||||
|
||||
// AnswersForTheModule says whether an instance of a module on one machine is issued the module's
|
||||
@@ -50,15 +53,12 @@ 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)})
|
||||
}
|
||||
}
|
||||
m.State = stateIssuedFor(d, node)
|
||||
m.Logs = logsIssuedFor(d)
|
||||
m.Logs = append(logsIssuedFor(d), readLogsIssuedFor(withReadLogs(d, where.Logs))...)
|
||||
m.Streams = streamsIssuedFor(d)
|
||||
m.Hears = HeardIssuedFor(node, d)
|
||||
m.Waits = waitsIssuedFor(d)
|
||||
@@ -103,7 +103,7 @@ func MembershipFor(node string, d Declared, where Placements) Membership {
|
||||
|
||||
// PlacementsOf reads where everything runs from the records the bus's accounts are composed from.
|
||||
func PlacementsOf(r Records, interchangeable map[string]bool) Placements {
|
||||
p := Placements{Nodes: map[string][]string{}, Interchangeable: interchangeable}
|
||||
p := Placements{Nodes: map[string][]string{}, Interchangeable: interchangeable, Logs: DeclaredLogs(r)}
|
||||
for node, declared := range r.Assigned {
|
||||
for _, d := range declared {
|
||||
p.Nodes[d.Module] = append(p.Nodes[d.Module], node)
|
||||
|
||||
@@ -108,7 +108,7 @@ func (s ModuleStream) Stream() Stream {
|
||||
|
||||
// QueueOwner is the one process of a module that is the durable consumer of one of its own work-queue
|
||||
// streams (novox/hq ADR 0436 §1, §2, §6): named in that process's bus.queues, and checked at registration
|
||||
// to be the only one (catalogue.WorkQueueStreamProblems).
|
||||
// to be the only one (the indexer's judge, novox/hq ADR 0578 §1).
|
||||
type QueueOwner struct {
|
||||
// Process is the process resource's id — its bus account's name, with the node and module (novox/hq ADR
|
||||
// 0436 §2).
|
||||
|
||||
+213
-476
@@ -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
|
||||
@@ -181,6 +166,8 @@ type Principal struct {
|
||||
KeyedReads []KeyedRead
|
||||
// Logs is the local names of the logs this principal's module keeps (novox/hq ADR 0297).
|
||||
Logs []string
|
||||
// ReadLogs are, of Reads, those naming another module's log (novox/hq ADR 0571 clause 1).
|
||||
ReadLogs []string
|
||||
// Streams are the streams this principal's module owns, and Hears the module streams it hears with the
|
||||
// subjects it was granted (novox/hq ADR 0363).
|
||||
Streams []ModuleStream
|
||||
@@ -196,12 +183,13 @@ type Principal struct {
|
||||
// whole authority is BusSnapshotGrants: it copies the streams for the night's backup and nothing
|
||||
// else — not its declarations, which the node's tool runtime serves for it.
|
||||
SnapshotsTheBus bool
|
||||
// Removes are, for a process or tools bundle account whose bus declares them, what it may remove from the bus
|
||||
// (novox/hq ADR 0564 Limits 4): its whole authority is then BusRemovalGrants of them.
|
||||
Removes []string
|
||||
// DeadLetterSources are, for the watchman, the streams a dead letter it keeps may come from, by name
|
||||
// (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.
|
||||
@@ -242,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
|
||||
@@ -270,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+".*")
|
||||
}
|
||||
@@ -292,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 {
|
||||
@@ -317,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"}
|
||||
@@ -369,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
|
||||
@@ -517,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.
|
||||
@@ -576,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
|
||||
@@ -655,159 +561,21 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
return Permissions{Publish: pub, Subscribe: []string{p.inbox()}}, nil
|
||||
}
|
||||
|
||||
if p.Kind == KindProcess && len(p.Removes) > 0 {
|
||||
pub, err := BusRemovalGrants(p.Removes, p.FilesFor)
|
||||
if err != nil {
|
||||
return Permissions{}, err
|
||||
}
|
||||
sort.Strings(pub)
|
||||
if err := CheckWriters(p, pub); err != nil {
|
||||
return Permissions{}, err
|
||||
}
|
||||
// Its own inbox for the answers, and nothing else (Limits 4).
|
||||
return Permissions{Publish: pub, Subscribe: []string{p.inbox()}}, nil
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -831,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
|
||||
@@ -859,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:
|
||||
@@ -965,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)
|
||||
@@ -988,6 +758,10 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
if p.Module == WatchmanModule {
|
||||
sub = append(sub, BusConnectSubject, BusDisconnectSubject)
|
||||
pub = append(pub, BusConnzSubject)
|
||||
// And the dead letters, from its own consumer, never the controller's (novox/hq ADR 0564 clause 5).
|
||||
pub = append(pub, WatchmanDeadLetterGrants(p.DeadLetterSources)...)
|
||||
// And the words its watchdogs hear (ADR 0564 clause 4, Limits 3, 11).
|
||||
sub = append(sub, WatchmanHears()...)
|
||||
}
|
||||
// The grants part alone publishes the modules' memberships into the assignments stream (novox/hq ADR 0547 §5;
|
||||
// the operator, 2026-10-25 22:31): one per subject, `mesh.assignment.<node>.<module>`, reading the last one of each
|
||||
@@ -1036,10 +810,11 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
}
|
||||
}
|
||||
sub = append(sub, announcing(announced...)...)
|
||||
// Its own membership (ADR 0160): the one subject a runtime derives for itself, read
|
||||
// directly from the stream and followed live. Nothing else's.
|
||||
sub = append(sub, MembershipSubject(p.Node, p.Module))
|
||||
pub = append(pub, "$JS.API.DIRECT.GET."+AssignmentsStream+"."+MembershipSubject(p.Node, p.Module))
|
||||
// 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
|
||||
// grant a person gets and derived the same way, so "what may this module ask" is
|
||||
@@ -1093,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 {
|
||||
@@ -1157,6 +928,7 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
sub = append(sub, stateCreatedHeard(kept)...)
|
||||
// And its logs (novox/hq ADR 0297): appended to and read directly, its own alone.
|
||||
pub = append(pub, logGrants(p.Module, p.Logs)...)
|
||||
pub = append(pub, readLogGrants(p.ReadLogs)...)
|
||||
|
||||
// 6. Its traffic on seats that name their caller or their kind, ask proofs or keep records
|
||||
// (novox/hq ADR 0259 §3).
|
||||
@@ -1191,19 +963,16 @@ 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, read
|
||||
// directly from the stream and followed live. 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+".*")
|
||||
pub = append(pub, "$JS.API.DIRECT.GET."+AssignmentsStream+".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.
|
||||
pub = append(pub, "mesh.control."+p.Node+".tools-alive")
|
||||
@@ -1260,6 +1029,7 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
// runtime's to keep, from the logs each membership lists.
|
||||
for _, d := range p.Carries {
|
||||
pub = append(pub, logGrants(d.Module, logNames(d.Logs))...)
|
||||
pub = append(pub, readLogGrants(d.ReadLogs)...)
|
||||
}
|
||||
// **And the event families a session may wait on** (novox/hq ADR 0367 §2): the tool runner's module's own,
|
||||
// named one by one in its manifest, as a plain subscription — no consumer is made for them, and no
|
||||
@@ -1310,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())
|
||||
|
||||
@@ -1323,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)
|
||||
@@ -1378,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1398,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
|
||||
}
|
||||
@@ -1420,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)
|
||||
}
|
||||
}
|
||||
@@ -1453,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 != "" {
|
||||
@@ -1533,8 +1264,6 @@ func consumerStream(p Principal) string {
|
||||
return "EVENTS"
|
||||
case KindNode:
|
||||
return "NODES"
|
||||
case KindController:
|
||||
return "CONTROL"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1545,8 +1274,6 @@ func consumerDurable(p Principal) string {
|
||||
return p.Node + "_" + p.Module
|
||||
case KindNode:
|
||||
return p.Node
|
||||
case KindController:
|
||||
return "controller"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1574,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")
|
||||
|
||||
@@ -1628,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")
|
||||
|
||||
@@ -1649,17 +1376,7 @@ func ComposeAccounts(principals []Principal) (string, error) {
|
||||
if p.PasswordHash == "" {
|
||||
return "", fmt.Errorf("%s has no password hash: a user without one is a user anybody is", p.Username())
|
||||
}
|
||||
fmt.Fprintf(&b, " { user: %q, password: %q, permissions: {\n", p.Username(), p.PasswordHash)
|
||||
if len(perms.PublishDeny) > 0 {
|
||||
fmt.Fprintf(&b, " publish: { allow: [%s], deny: [%s] }\n", quoted(perms.Publish), quoted(perms.PublishDeny))
|
||||
} else {
|
||||
fmt.Fprintf(&b, " publish: { allow: [%s] }\n", quoted(perms.Publish))
|
||||
}
|
||||
fmt.Fprintf(&b, " subscribe: { allow: [%s] }\n", quoted(perms.Subscribe))
|
||||
if perms.AllowResponses {
|
||||
fmt.Fprintf(&b, " allow_responses: { max: 1, ttl: \"%dm\" }\n", int(ResponseTTL/time.Minute))
|
||||
}
|
||||
b.WriteString(" } }\n")
|
||||
b.WriteString(AccountsEntry(p.Username(), p.PasswordHash, perms))
|
||||
}
|
||||
b.WriteString(" ]\n }\n")
|
||||
b.WriteString(systemAccount)
|
||||
@@ -1667,6 +1384,25 @@ func ComposeAccounts(principals []Principal) (string, error) {
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// AccountsEntry is one user of the accounts block, as ComposeAccounts writes each: its name, its password hash and its
|
||||
// permissions, in the lines the bus reads. Exported so a writer that keeps a user from grants it holds rather than
|
||||
// composes (mesh-grants' refused users) writes the same lines, never a copy of them (issue 774).
|
||||
func AccountsEntry(user, passwordHash string, perms Permissions) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, " { user: %q, password: %q, permissions: {\n", user, passwordHash)
|
||||
if len(perms.PublishDeny) > 0 {
|
||||
fmt.Fprintf(&b, " publish: { allow: [%s], deny: [%s] }\n", quoted(perms.Publish), quoted(perms.PublishDeny))
|
||||
} else {
|
||||
fmt.Fprintf(&b, " publish: { allow: [%s] }\n", quoted(perms.Publish))
|
||||
}
|
||||
fmt.Fprintf(&b, " subscribe: { allow: [%s] }\n", quoted(perms.Subscribe))
|
||||
if perms.AllowResponses {
|
||||
fmt.Fprintf(&b, " allow_responses: { max: 1, ttl: \"%dm\" }\n", int(ResponseTTL/time.Minute))
|
||||
}
|
||||
b.WriteString(" } }\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// WatchmanModule is the one module whose principal hears the bus's connect and disconnect events and asks its list of
|
||||
// connections (novox/hq ADR 0431 §2, issue 1147): the watchman of the repository mesh-delivery, which writes presence
|
||||
// from them. No other principal is granted the subjects below, and nothing is a wildcard over them.
|
||||
@@ -1705,6 +1441,21 @@ func CreateConsumerGrants(consumers []ModuleConsumer) []string {
|
||||
// part, which composes them from the manifests and placement. It defines no stream and is granted no other subject here.
|
||||
const GrantsModule = "mesh-grants"
|
||||
|
||||
// The grants part's state memberships (novox/hq ADR 0571 clause 7): one key per assignment, `<node>.<module>`, its
|
||||
// membership (Membership) bare, as the assignments stream carries it, written only when it differs. A module's runtime
|
||||
// reads its own key, the tool runner its node's; the grants part alone writes it. Both sides go through
|
||||
// membership.EncodeGrantsMembership and membership.DecodeGrantsMembership (or BareGrantsMembership for a reader with a
|
||||
// type of its own), never json of a type of their own: the grants part once wrote its wrapper here and every reader
|
||||
// served nothing (issue 774). The decoder still reads that wrapper, for the transition.
|
||||
const (
|
||||
GrantsMembershipsState = "memberships"
|
||||
GrantsMembershipsBucket = GrantsModule + "_" + GrantsMembershipsState
|
||||
GrantsMembershipsRead = GrantsModule + "." + GrantsMembershipsState
|
||||
)
|
||||
|
||||
// MembershipKey is an assignment's key in the grants part's memberships: `<node>.<module>`.
|
||||
func MembershipKey(node, module string) string { return node + "." + module }
|
||||
|
||||
// The bus's own events, imported into MESH from the default system account `$SYS` under subjects of MESH's own
|
||||
// (novox/hq ADR 0431 §1, issue 1147): the server says an account's connections opening and closing only to the system
|
||||
// account, and the list of an account's connections only to a request made there. `$SYS` holds no user, so nobody
|
||||
@@ -1819,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,
|
||||
@@ -1851,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 {
|
||||
|
||||
@@ -3,9 +3,11 @@ package buscompose
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/provisions"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
@@ -34,15 +36,20 @@ var ConfigResolvedBucket = BucketName("mesh-config", "resolved")
|
||||
// ProvisionerReads are the domains' buckets a provisioner client reads its own machine's keys of, each keyed
|
||||
// `<node>.…` (research 051): placement, the controller's assignments mirror (`<node>.<module>`), whose value carries
|
||||
// the provisioner's plan of a module handed to it (inventory.Provisioning, design 57 P5); and config,
|
||||
// mesh-config's resolved values. **Named one by one**, as the controller's asks are: a domain that moves to the
|
||||
// mesh-config's resolved values; and the seat mesh-provisioner's plans of its machine, `<node>.<module>` (novox/hq ADR
|
||||
// 0570 clause 2). **Named one by one**, as the controller's asks are: a domain that moves to the
|
||||
// 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.
|
||||
var ProvisionerReadsWhole = []string{AssignmentsBucket}
|
||||
// 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{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
|
||||
@@ -68,8 +75,13 @@ var (
|
||||
)
|
||||
|
||||
// ProvisionerWrites are the server's buckets a client writes, and reads, its own machine's keys of (`<node>.>`)
|
||||
// and no other's: the needs it records, what it applied, and the items of the collections on its machine. A
|
||||
// client cannot write another machine's needs, or say another machine applied a file.
|
||||
// and no other's: the needs it records, what it applied, and the items of the collections on its machine. A client
|
||||
// cannot write another machine's needs, or say another machine applied a file.
|
||||
//
|
||||
// **The needs stay granted for one release, and are removed after the lease write** (review round 3 of design 47's
|
||||
// release): the client's V3 reads its machine's plans from the seat mesh-provisioner's `plans` (novox/hq ADR 0570 clause
|
||||
// 2) and records no needs, but the client before it still runs on a node until its delivery reaches it, and it writes
|
||||
// them.
|
||||
var ProvisionerWrites = []string{ProvisionerNeedsBucket, ProvisionerAppliedBucket, ProvisionerCollectBucket}
|
||||
|
||||
// VerbsTheProvisionerAsksTheVault are the vault's verbs a provisioner client asks in its own name (ADR 0349
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -58,6 +58,17 @@ func SeatTrafficOf(module string, holds, uses, watches []Seat) SeatTraffic {
|
||||
var t SeatTraffic
|
||||
for _, s := range holds {
|
||||
if !s.isNewTraffic() {
|
||||
// **A holder says its own seat's events** (novox/hq ADR 0564 clause 6, issue 1952): its own account is
|
||||
// granted them by the held seats' rule (PermissionsFor, step 3), and a module the tool runner carries had
|
||||
// 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.
|
||||
for _, e := range s.Emits {
|
||||
if s.Scope == "node" && perMachineEvents[e] {
|
||||
continue
|
||||
}
|
||||
t.Publish = append(t.Publish, seatSubject(s, "event", e))
|
||||
}
|
||||
continue
|
||||
}
|
||||
kind := ""
|
||||
|
||||
+14
-3
@@ -156,6 +156,12 @@ func stateGrants(a stateAccess) []string {
|
||||
}
|
||||
for _, r := range a.Reads {
|
||||
if bucket, ok := bucketOfRead(r); ok {
|
||||
if NodeKeyedReads[r] {
|
||||
if machineKey != "" {
|
||||
read(bucket, machineKey+".>")
|
||||
}
|
||||
continue
|
||||
}
|
||||
read(bucket, "")
|
||||
}
|
||||
}
|
||||
@@ -167,6 +173,11 @@ func stateGrants(a stateAccess) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// NodeKeyedReads are the states keyed `<node>.>` whose reader reads its own machine's keys alone, whatever its `reads`
|
||||
// names: the node-engine's data items, each node's disk-load reading its own node's (novox/hq ADR 0577 clause 1). A
|
||||
// machine with no usable name reaches none of them.
|
||||
var NodeKeyedReads = map[string]bool{delivery.EngineDataRead: true}
|
||||
|
||||
// StreamCreatedAdvisory is the subject the bus says a stream was created on (the server's own advisory), here for a
|
||||
// bucket's stream KV_<bucket>.
|
||||
func StreamCreatedAdvisory(bucket string) string {
|
||||
@@ -298,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)
|
||||
}
|
||||
}
|
||||
|
||||
+16
-274
@@ -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"},
|
||||
@@ -171,14 +159,9 @@ func MeshStreams() []Stream {
|
||||
Why: "one declaration per node, always the newest; a node that sees sequence n refuses " +
|
||||
"n-1 by construction (issue 107)",
|
||||
},
|
||||
{
|
||||
Name: AssignmentsStream,
|
||||
Subjects: []string{"mesh.assignment.*.*"},
|
||||
Retention: RetentionLastPerSubject,
|
||||
Direct: true,
|
||||
Why: "one membership per assignment, always the newest: what the mesh issued this module " +
|
||||
"on this machine to serve and to reach (ADR 0160); read directly by the runtime it is for",
|
||||
},
|
||||
// The assignments stream (ADR 0160) is no longer the mesh's: a runtime reads its membership from the grants
|
||||
// part's state memberships (ADR 0571 clause 7), and the bus module's tools delete the stream at the lease write
|
||||
// (research 072 row 33), so it is not declared here, which would make it again.
|
||||
{
|
||||
Name: EventsStream,
|
||||
// A seat's own events ride here too: they are 1:many like any event, and the
|
||||
@@ -240,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
|
||||
@@ -326,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 (
|
||||
@@ -489,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.
|
||||
@@ -598,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
@@ -3,8 +3,11 @@ package buscompose
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/announce"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
)
|
||||
|
||||
// **The grants part's user list, sealed to the bus node** (novox/hq ADR 0547 clause 7, issue 1945). The grants part writes
|
||||
@@ -77,12 +80,24 @@ func grantsOwnObjects(p Principal, pattern string) bool {
|
||||
// readers too (announce.State.Readers); both are honoured.
|
||||
const VaultBusAccounts = "mesh-vault.bus-accounts"
|
||||
|
||||
// ArtifactStoreTools is the module holding the seat mesh-artifact-sweep (novox/hq ADR 0571 clause 2).
|
||||
const ArtifactStoreTools = "artifact-store-tools"
|
||||
|
||||
// ConfigModule is the module that holds the seat mesh-config, a reader of the controller's lease (NamedReaders).
|
||||
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).
|
||||
var NamedReaders = map[string][]string{VaultBusAccounts: {GrantsModule}, GrantsAccounts: {VaultSeat},
|
||||
ControllerLease: {VaultSeat, GrantsModule, delivery.AssignerModule}}
|
||||
// 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},
|
||||
// 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.
|
||||
@@ -103,6 +118,23 @@ func readsNamed(module string, reads []string) (granted, refused []string) {
|
||||
return granted, refused
|
||||
}
|
||||
|
||||
// withAnnouncedReads is d with its reads judged for d's module (ReadsAnnouncedBy) against what the owners announced:
|
||||
// every read its manifest names, granted or refused by the caller, judged again here, so a caller that split them
|
||||
// without the reader (the controller's own copy of a reader-less judgement, issue 774: the vault, the assigner, the
|
||||
// grants part and the artifact store's tools refused the lease they are named for) cannot refuse a read the reader is
|
||||
// named for, nor grant one named for another. With no announcements read it is withNamedReaders.
|
||||
func withAnnouncedReads(d Declared, announced map[string]announce.States) Declared {
|
||||
if announced == nil {
|
||||
return withNamedReaders(d)
|
||||
}
|
||||
all := append(append([]string(nil), d.Reads...), d.ReadsRefused...)
|
||||
if len(all) == 0 {
|
||||
return d
|
||||
}
|
||||
d.Reads, d.ReadsRefused = ReadsAnnouncedBy(d.Module, all, announced)
|
||||
return d
|
||||
}
|
||||
|
||||
// withNamedReaders is d with the reads named for other modules moved to its refused reads.
|
||||
func withNamedReaders(d Declared) Declared {
|
||||
granted, refused := readsNamed(d.Module, d.Reads)
|
||||
@@ -113,3 +145,30 @@ func withNamedReaders(d Declared) Declared {
|
||||
d.ReadsRefused = append(append([]string(nil), d.ReadsRefused...), refused...)
|
||||
return d
|
||||
}
|
||||
|
||||
// **The hearings the grants part withholds, on the bus** (issue 1955 L7, mesh-delivery e564eaf): a hearing of a restricted
|
||||
// stream a module asks in the tool runner, withheld because only an account of its own may hear it (ADR 0376 §2), kept
|
||||
// in the grants part's state `withheld`, one key `<module>.<stream, lower case>.<machine>` (the controller's condition id
|
||||
// of it), written on change only and deleted once heard; the grants part's tools read it (D-heard was dropped on
|
||||
// 2026-10-30, so no other module reads it).
|
||||
const (
|
||||
GrantsWithheldState = "withheld"
|
||||
GrantsWithheldBucket = GrantsModule + "_" + GrantsWithheldState
|
||||
GrantsWithheldRead = GrantsModule + "." + GrantsWithheldState
|
||||
WithheldVersion = 1
|
||||
)
|
||||
|
||||
// WithheldHearing is one withheld hearing: the machine, the module, the restricted stream and its emitter, the plain
|
||||
// words of it and the remedy (Withheld.Said), and when the grants part first wrote it as it stands.
|
||||
type WithheldHearing struct {
|
||||
V int `json:"v"`
|
||||
Node string `json:"node"`
|
||||
Module string `json:"module"`
|
||||
Stream string `json:"stream"`
|
||||
Emitter string `json:"emitter"`
|
||||
Said string `json:"said"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// WithheldKey is a withheld hearing's key: `<module>.<stream, lower case>.<machine>`.
|
||||
func WithheldKey(w Withheld) string { return w.Module + "." + strings.ToLower(w.Stream) + "." + w.Node }
|
||||
|
||||
+90
-37
@@ -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.
|
||||
@@ -64,6 +67,9 @@ type Declared struct {
|
||||
// ReadsRefused are the reads its manifest names that the owner's announcement does not make readable (novox/hq ADR
|
||||
// 0495 clause 3, issue 1790): granted nothing, kept to be said.
|
||||
ReadsRefused []string
|
||||
// ReadLogs are, of its reads, those naming another module's log (`<module>.<log>`, novox/hq ADR 0571 clause 1): filled
|
||||
// by Users and PlacementsOf from the logs the modules placed anywhere declare, never by a caller.
|
||||
ReadLogs []string
|
||||
// KeyedReads are keys of other modules' state it reads, each one key alone: what a seat's holder is
|
||||
// granted for the pieces the modules beside it offer (novox/hq ADR 0260).
|
||||
KeyedReads []KeyedRead
|
||||
@@ -105,6 +111,9 @@ type ProcessPrincipal struct {
|
||||
Emits []string
|
||||
// Queues are the module's own work-queue streams this process is the one consumer of.
|
||||
Queues []QueueOwner
|
||||
// Removes are what its account may remove from the bus, as its bus declares them (the indexer's judge refuses any other; novox/hq
|
||||
// ADR 0564 Limits 4): a tools bundle's account, whose whole authority is then BusRemovalGrants.
|
||||
Removes []string
|
||||
// State is the module's own state, by local name, this process reads and writes.
|
||||
State []string
|
||||
// Asks says whether this process reads the records of its module's own asks (novox/hq ADR 0259 §3), on
|
||||
@@ -123,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
|
||||
@@ -139,39 +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] {
|
||||
@@ -193,9 +202,14 @@ func Users(r Records) ([]Principal, error) {
|
||||
}
|
||||
}
|
||||
for _, d := range r.Assigned[node] {
|
||||
// A state with named readers is read by them alone (ADR 0547 clause 9): a read named for another module is
|
||||
// granted nothing, here and in the runtime carrying the module.
|
||||
d = withNamedReaders(d)
|
||||
// **The node-engine's manifest is no placed module's** (novox/hq ADR 0563 §2, §3; issue 774): where a placement
|
||||
// names it, it adds no user, no process and nothing the runner carries; its grants are the node's account's.
|
||||
if !Composed(d) {
|
||||
continue
|
||||
}
|
||||
// A state with named readers is read by them alone (ADR 0547 clause 9), and a read is granted what its owner
|
||||
// announced (ADR 0495 clause 3): judged here, for this reader, whatever split the caller made (issue 774).
|
||||
d = withReadLogs(withAnnouncedReads(d, r.Announced), logs)
|
||||
// **A process account is its own, delivered in its own secret** (broker-<process id>, novox/hq
|
||||
// ADR 0436 §1, §2), never the module's `broker` — so it is composed whether or not the module
|
||||
// itself has an account, and is never carried by the machine's runtime, which runs as the
|
||||
@@ -204,8 +218,12 @@ func Users(r Records) ([]Principal, error) {
|
||||
out = append(out, Principal{
|
||||
Kind: KindProcess, Node: node, Module: d.Module, Process: pr.Process,
|
||||
Emits: pr.Emits, Queues: pr.Queues, State: pr.State, Uses: d.Uses, Asks: pr.Asks,
|
||||
Invokes: pr.Invokes,
|
||||
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
|
||||
@@ -223,12 +241,15 @@ func Users(r Records) ([]Principal, error) {
|
||||
Kind: KindModule, Node: node, Module: d.Module,
|
||||
Emits: d.Emits, Consumes: d.Consumes, Serves: d.Serves,
|
||||
Holds: d.Holds, Uses: d.Uses, Watches: d.Watches, Invokes: d.Invokes,
|
||||
State: stateNames(d.State), Reads: d.Reads, SnapshotsTheBus: d.SnapshotsTheBus,
|
||||
State: stateNames(d.State), Reads: d.Reads, ReadLogs: d.ReadLogs, SnapshotsTheBus: d.SnapshotsTheBus,
|
||||
PerMachine: perMachineNames(d.State), KeyedReads: d.KeyedReads,
|
||||
Logs: logNames(d.Logs), Streams: d.Streams, Hears: ComposedHears(d), Queues: d.Queues,
|
||||
})
|
||||
// **The grants part creates what a manifest declares** (novox/hq ADR 0547 §5; the operator, 2026-10-26
|
||||
// 08:54): every placed module's buckets, logs and streams, each by its stream's name.
|
||||
if d.Module == WatchmanModule {
|
||||
out[len(out)-1].DeadLetterSources = deadLetterSourcesOf(r)
|
||||
}
|
||||
if d.Module == GrantsModule {
|
||||
out[len(out)-1].CreatesStreams = declaredStreamsOf(r)
|
||||
grants = len(out) - 1
|
||||
@@ -243,8 +264,8 @@ func Users(r Records) ([]Principal, error) {
|
||||
if runtimeHere {
|
||||
var carried []Declared
|
||||
for _, d := range r.Assigned[node] {
|
||||
if d.RunsAs == "" && runnerServes(d) {
|
||||
d = withNamedReaders(d)
|
||||
if CarriedByRunner(d) {
|
||||
d = withReadLogs(withAnnouncedReads(d, r.Announced), logs)
|
||||
// Never a restricted stream for what the tool runner carries (novox/hq ADR 0376 §1).
|
||||
d.Hears = ComposedHears(d)
|
||||
carried = append(carried, d)
|
||||
@@ -253,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.
|
||||
@@ -324,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.
|
||||
@@ -364,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"
|
||||
|
||||
@@ -376,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{}
|
||||
@@ -440,6 +481,18 @@ func boundBucketsOf(r Records) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// Composed says a placed module is composed as one: its own user, its processes and what the runner carries of it.
|
||||
// The node-engine's manifest (EngineModule) is not, though a placement may name it: each node's engine speaks as the
|
||||
// node's account, which alone writes the engine's state under its own key (engineGrants, novox/hq ADR 0563 §2, §3), and
|
||||
// Records.Engine creates that state. Granted to another account it is a second writer, and the writers table refuses
|
||||
// the whole list (issue 774: met for mesh-host_acts and then mesh-host_health, 2026-10-30).
|
||||
func Composed(d Declared) bool { return d.Module != EngineModule }
|
||||
|
||||
// CarriedByRunner says the machine's tool runner carries this placed module: a module composed at all (Composed) that
|
||||
// runs as no account of its own and has something for the runner to serve (runnerServes). The one test of what the
|
||||
// runner's grants are composed from.
|
||||
func CarriedByRunner(d Declared) bool { return Composed(d) && d.RunsAs == "" && runnerServes(d) }
|
||||
|
||||
// runnerServes says the machine's tool runner has something of this module to serve (novox/hq ADR 0436 §1,
|
||||
// ADR 0437 §4, issue 1329): tools or loads it answers, a seat it holds, event families a session waits on,
|
||||
// a bundle the runtime loads (Loads), or a check its health asks. A module with none of these is a process of its own on its own credential, and
|
||||
|
||||
+71
-120
@@ -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,13 +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) {
|
||||
if removesLeftover(p, pattern) {
|
||||
return true
|
||||
}
|
||||
name, ok := strings.CutPrefix(pattern, "$JS.API.STREAM.CREATE.")
|
||||
@@ -93,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)
|
||||
@@ -128,155 +138,102 @@ 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)"},
|
||||
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: isController},
|
||||
// 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},
|
||||
// The acts a node's engine was asked through its node seat, and its own asks (novox/hq ADR 0563 §2, §3): the
|
||||
// node-engine's declared state, created by the grants part, written by each node's engine under its own keys.
|
||||
// A node's own health report (issue 774): its engine's own key, read by the watchman.
|
||||
{State: "a machine's health report", Writer: "the node-engine of the machine, its key `<node>` alone",
|
||||
KeptIn: "key-value " + delivery.NodeHealthBucket, Others: "read by the watchman through `reads`",
|
||||
Subjects: kvOf(delivery.NodeHealthBucket), Writes: ownNodeKeys},
|
||||
// A node's declared packages (novox/hq ADR 0565 clause 6): its engine's own key, read by pacman's verbs.
|
||||
{State: "a machine's declared packages", Writer: "the node-engine of the machine, its key `<node>` alone",
|
||||
KeptIn: "key-value " + EnginePackagesBucket, Others: "read by pacman through `reads`",
|
||||
Subjects: kvOf(EnginePackagesBucket), Writes: ownNodeKeys},
|
||||
// A node's placed modules' data items (novox/hq ADR 0577 clause 1): its engine's own keys, read by that node's disk-load.
|
||||
{State: "a machine's data items", Writer: "the node-engine of the machine, under its own keys `<node>.>`",
|
||||
KeptIn: "key-value " + EngineDataBucket, Others: "read by the node's disk-load through `reads`, its own node's keys alone",
|
||||
Subjects: kvOf(EngineDataBucket), Writes: ownNodeKeys},
|
||||
{State: "a machine's hand acts", Writer: "the node-engine of the machine, under its own keys `<node>.>`, compare-and-set",
|
||||
KeptIn: "key-value " + EngineActsBucket + ", the node-engine's declared state acts",
|
||||
Others: "read through the node seat's verb acts", Subjects: kvOf(EngineActsBucket), Writes: ownNodeKeys},
|
||||
// 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)",
|
||||
Others: "read through the node seat's verb acts", Subjects: kvOf(EngineActsBucket),
|
||||
Writes: func(p Principal, pattern string) bool {
|
||||
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 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},
|
||||
}
|
||||
|
||||
@@ -323,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,76 @@
|
||||
// Package busretry waits out a bus that answers nothing for a few seconds (issue 774, the sweep of 2026-10-30: bus
|
||||
// blackouts at every user-list reload). nats-server 2.11's reloadAuthorization rebuilds the accounts, and for a few
|
||||
// seconds the mesh's account has none of its imports, $JS.API.> among them: every request is answered "no responders"
|
||||
// and every JetStream call "jetstream not enabled". A write or a call made in that window failed, and was lost.
|
||||
//
|
||||
// Only an error that says the request reached no one is waited out (Undelivered): nothing was done, so asking again
|
||||
// is safe even for a write that is not idempotent. A timeout, or any answer, is never asked again: the request may
|
||||
// have been delivered. The wait is bounded (Bound, about 30 s, backing off), and said on standard error when it ends
|
||||
// without an answer.
|
||||
//
|
||||
// This is the quick fix. The thorough one is a reload that keeps the account's imports, in the bus's holder.
|
||||
package busretry
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// Bound is how long a request that reached no one is asked again; FirstWait the first pause, doubled to MostWait.
|
||||
var (
|
||||
Bound = 30 * time.Second
|
||||
FirstWait = 250 * time.Millisecond
|
||||
MostWait = 4 * time.Second
|
||||
)
|
||||
|
||||
// Undelivered says err means the request reached no one: no responders, JetStream not enabled (for the account), or
|
||||
// no response from the stream after the client's own retries on no responders; also said in words by a runtime that
|
||||
// passed the error on.
|
||||
func Undelivered(err error) bool {
|
||||
var answered *Answered
|
||||
if err == nil || errors.As(err, &answered) {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, nats.ErrNoResponders) || errors.Is(err, jetstream.ErrJetStreamNotEnabled) ||
|
||||
errors.Is(err, jetstream.ErrJetStreamNotEnabledForAccount) || errors.Is(err, jetstream.ErrNoStreamResponse) ||
|
||||
errors.Is(err, nats.ErrJetStreamNotEnabled) || errors.Is(err, nats.ErrJetStreamNotEnabledForAccount) {
|
||||
return true
|
||||
}
|
||||
words := strings.ToLower(err.Error())
|
||||
return strings.Contains(words, "no responders") || strings.Contains(words, "jetstream not enabled")
|
||||
}
|
||||
|
||||
// Answered is an error a callee answered with: the request was delivered, whatever its words say ("jetstream not
|
||||
// enabled" in a callee's own answer), so Undelivered never counts it and Do never asks it again.
|
||||
type Answered struct{ Words string }
|
||||
|
||||
func (a *Answered) Error() string { return a.Words }
|
||||
|
||||
// Do runs try, and again while its error says the request reached no one, until Bound has passed. blackout, when
|
||||
// given, is asked after each such error and Do stops waiting when it says the bus is not blacked out: a call nobody
|
||||
// answers because its callee is not running fails at once, as before. what names the request in the error.
|
||||
func Do[T any](what string, try func() (T, error), blackout func() bool) (T, error) {
|
||||
v, err := try()
|
||||
if !Undelivered(err) || (blackout != nil && !blackout()) {
|
||||
return v, err
|
||||
}
|
||||
start, wait := time.Now(), FirstWait
|
||||
for time.Since(start) < Bound {
|
||||
time.Sleep(wait)
|
||||
wait = min(wait*2, MostWait)
|
||||
v, err = try()
|
||||
if !Undelivered(err) || (blackout != nil && !blackout()) {
|
||||
return v, err
|
||||
}
|
||||
}
|
||||
err = fmt.Errorf("%s: the bus answered nothing for %s, asked again until then (a user-list reload drops the "+
|
||||
"account's imports for a few seconds; this was longer): %w", what, time.Since(start).Round(time.Second), err)
|
||||
fmt.Fprintf(os.Stderr, "[mesh-sdk] %v\n", err)
|
||||
return v, err
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package busretry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// Issue 774 (bus blackouts at every user-list reload): only a request that reached no one is asked again, within the
|
||||
// bound; a blackout probe that says the bus answers ends the wait at once.
|
||||
func TestABusThatAnswersNothingIsWaitedOut(t *testing.T) {
|
||||
Bound, FirstWait, MostWait = 60*time.Millisecond, time.Millisecond, 4*time.Millisecond
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
errs []error // what each try answers; the last repeats
|
||||
blackout func() bool
|
||||
tries int // at least
|
||||
atMost int
|
||||
delivered bool
|
||||
}{
|
||||
{"no responders, then an answer", []error{nats.ErrNoResponders, nats.ErrNoResponders, nil}, nil, 3, 3, true},
|
||||
{"jetstream not enabled, then an answer", []error{jetstream.ErrJetStreamNotEnabled, nil}, nil, 2, 2, true},
|
||||
{"said in words by a runtime", []error{errors.New("nats: no responders available for request"), nil}, nil, 2, 2, true},
|
||||
{"a timeout may have been delivered: never asked again", []error{context.DeadlineExceeded}, nil, 1, 1, false},
|
||||
{"an answer that refuses is an answer", []error{errors.New("refused")}, nil, 1, 1, false},
|
||||
{"nobody answers on a bus that answers: the callee is not running", []error{nats.ErrNoResponders}, func() bool { return false }, 1, 1, false},
|
||||
{"blacked out past the bound: said", []error{jetstream.ErrJetStreamNotEnabledForAccount}, func() bool { return true }, 3, 1000, false},
|
||||
} {
|
||||
tries := 0
|
||||
_, err := Do("the test", func() (int, error) {
|
||||
e := row.errs[min(tries, len(row.errs)-1)]
|
||||
tries++
|
||||
return tries, e
|
||||
}, row.blackout)
|
||||
if tries < row.tries || tries > row.atMost || (err == nil) != row.delivered {
|
||||
t.Errorf("%s: %d tries, %v", row.name, tries, err)
|
||||
}
|
||||
if strings.HasPrefix(row.name, "blacked out") && (!Undelivered(err) || !strings.Contains(fmt.Sprint(err), "the bus answered nothing")) {
|
||||
t.Errorf("%s: %v", row.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -31,7 +30,7 @@ func aServiceModule(t *testing.T) Manifest {
|
||||
"build": {"artifacts": [{"name": "code", "kind": "bundle", "language": "go", "system": "arch",
|
||||
"from": "cmd/svc", "binary": "svc"}]}
|
||||
}`
|
||||
m, err := ParseManifest([]byte(raw))
|
||||
m, err := decodeForTest([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("the service's manifest is refused: %v", err)
|
||||
}
|
||||
@@ -145,22 +144,3 @@ func TestWhatAProcessReplacesIsNamedAsTheHostRecordedIt(t *testing.T) {
|
||||
t.Fatalf("the process replaces %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhatReplacesMayNameIsRefusedNearItsAuthor(t *testing.T) {
|
||||
for what, resource := range map[string]string{
|
||||
"a container": `{"id":"c","type":"container","name":"c","image":"x@` + aServiceDigest + `","replaces":["old"]}`,
|
||||
"a step": `{"id":"p","type":"process","name":"p","run":["./p"],"run-once":true,"replaces":["old"]}`,
|
||||
"something declared": `{"id":"p","type":"process","name":"p","run":["./p"],"replaces":["p"]}`,
|
||||
"another module's": `{"id":"p","type":"process","name":"p","run":["./p"],"replaces":["other.old"]}`,
|
||||
"not a list": `{"id":"p","type":"process","name":"p","run":["./p"],"replaces":"old"}`,
|
||||
} {
|
||||
raw := `{"module":"m","version":"1","resources":[` + resource + `]}`
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "replace") {
|
||||
t.Errorf("replaces on %s was accepted: %v", what, err)
|
||||
}
|
||||
}
|
||||
ok := `{"module":"m","version":"1","resources":[{"id":"p","type":"process","name":"p","run":["./p"],"replaces":["old"]}]}`
|
||||
if _, err := ParseManifest([]byte(ok)); err != nil {
|
||||
t.Errorf("a process replacing what its module no longer declares was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,33 +76,3 @@ func TestAModuleMayNotOwnWhatAnotherAccesses(t *testing.T) {
|
||||
t.Errorf("the refusal does not explain the ownership conflict: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A manifest states an access's extent, the way a listening port states its source. An absolute
|
||||
// path and a mode of read or read-write; anything else is refused rather than acted on wrongly.
|
||||
func TestAnAccessIsValidated(t *testing.T) {
|
||||
good := []byte(`{"module":"m","accesses":[{"path":"/services/media","mode":"read-write"}]}`)
|
||||
if _, err := ParseManifest(good); err != nil {
|
||||
t.Fatalf("a valid access was refused: %v", err)
|
||||
}
|
||||
|
||||
// Mode is optional and narrows to read — the safe default, because the danger with an access
|
||||
// is being given more than was meant, not less.
|
||||
bare := []byte(`{"module":"m","accesses":[{"path":"/services/media"}]}`)
|
||||
m, err := ParseManifest(bare)
|
||||
if err != nil {
|
||||
t.Fatalf("an access with no mode was refused: %v", err)
|
||||
}
|
||||
if m.Accesses[0].At() != AccessRead {
|
||||
t.Errorf("an access with no mode is %q, want %q", m.Accesses[0].At(), AccessRead)
|
||||
}
|
||||
|
||||
relative := []byte(`{"module":"m","accesses":[{"path":"services/media","mode":"read"}]}`)
|
||||
if _, err := ParseManifest(relative); err == nil {
|
||||
t.Error("a relative access path was accepted")
|
||||
}
|
||||
|
||||
wrong := []byte(`{"module":"m","accesses":[{"path":"/services/media","mode":"append"}]}`)
|
||||
if _, err := ParseManifest(wrong); err == nil {
|
||||
t.Error("an access with an unknown mode was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,15 +258,6 @@ WantedBy=multi-user.target
|
||||
}
|
||||
}
|
||||
|
||||
func TestAGuardedPortMustBeAPort(t *testing.T) {
|
||||
if _, err := ParseManifest([]byte(`{"module":"postgres","guards":[5432]}`)); err != nil {
|
||||
t.Fatalf("guards is refused: %v", err)
|
||||
}
|
||||
if _, err := ParseManifest([]byte(`{"module":"postgres","guards":[0]}`)); err == nil {
|
||||
t.Fatal("guarding port 0 was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func keys[V any](m map[string]V) []string {
|
||||
return sortedKeys(m)
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ const agentModule = `{"module": "agent", "version": "1", "resources": [
|
||||
]}`
|
||||
|
||||
func TestTheAgentAccountIsDeclaredNeverRootOnlyToAnEngineThatJudgesIt(t *testing.T) {
|
||||
m, err := ParseManifest([]byte(agentModule))
|
||||
m, err := decodeForTest([]byte(agentModule))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -83,37 +83,3 @@ func TestTheAgentAccountIsDeclaredNeverRootOnlyToAnEngineThatJudgesIt(t *testing
|
||||
t.Errorf("with no agent account, the agent's directory is the operator's: %v", home)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheRuntimeIsToldTheAgentAccount(t *testing.T) {
|
||||
with := Rendering{ArtifactStore: "anchor.internal:5101",
|
||||
Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}}
|
||||
envOf := func(r Resolution) map[string]string {
|
||||
t.Helper()
|
||||
r.Node, r.Modules = "anchor", []Manifest{aToolsModule(t, "nftables", "tools/index.js"), theRuntime(t)}
|
||||
out, err := r.Declaration(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
process := fileNamed(out, RuntimeModule+"."+RuntimeProcessID())
|
||||
if process == nil {
|
||||
t.Fatal("no runtime process was composed")
|
||||
}
|
||||
return process["env"].(map[string]string)
|
||||
}
|
||||
env := envOf(Resolution{Account: "ops", AgentAccount: "agent"})
|
||||
if env[RuntimeAgentAccount] != "agent" || env[RuntimeAgentHome] != "/home/agent" || env[RuntimeOperatorAccount] != "ops" {
|
||||
t.Errorf("the runtime is not told whom agents run as: %v", env)
|
||||
}
|
||||
env = envOf(Resolution{Account: "ops"})
|
||||
if env[RuntimeAgentAccount] != "ops" || env[RuntimeAgentHome] != "/home/ops" {
|
||||
t.Errorf("with no agent account, agents run as the operator: %v", env)
|
||||
}
|
||||
env = envOf(Resolution{})
|
||||
if _, set := env[RuntimeAgentAccount]; set {
|
||||
t.Errorf("a machine with no account names an agent account: %v", env)
|
||||
}
|
||||
if problems := bundleEnvProblems("x", Artifact{Name: "b", Kind: ArtifactBundle, Loads: []string{"x"},
|
||||
Env: map[string]string{RuntimeAgentAccount: "me"}}); len(problems) == 0 {
|
||||
t.Error("a bundle may tell the runtime whom agents run as")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,22 +14,6 @@ import (
|
||||
// the old wire protocol asks for the one server being retired, so both directions are refused at the
|
||||
// parser — this is judged from the manifest alone, no store needed.
|
||||
|
||||
func TestAManifestProvidingAmqpIsRefused(t *testing.T) {
|
||||
raw := []byte(`{"module":"old-broker","version":"1","provides":[{"name":"amqp","scope":"mesh"}]}`)
|
||||
_, err := ParseManifest(raw)
|
||||
if err == nil || !strings.Contains(err.Error(), `provides "amqp", which is not a provision`) {
|
||||
t.Fatalf("a module providing amqp was not refused, or not for the reason: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAManifestRequiringAmqpIsRefused(t *testing.T) {
|
||||
raw := []byte(`{"module":"forwarder","version":"1","requires":["amqp"]}`)
|
||||
_, err := ParseManifest(raw)
|
||||
if err == nil || !strings.Contains(err.Error(), `requires "amqp", which is not a provision`) {
|
||||
t.Fatalf("a module requiring amqp was not refused, or not for the reason: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// And the catalogue (internal/beside) names it nowhere — the three modules that did are removed under
|
||||
// design 28 task 5.4, not converted.
|
||||
func TestNoCatalogueManifestNamesAmqp(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package catalogue
|
||||
|
||||
// **The artifact sweep, out of the controller** (novox/hq ADR 0571 clause 2, issue 1955): what the artifact store keeps
|
||||
// and lets go of, the seat `mesh-artifact-sweep` (named for its scope, ADR 0121 §1; the operator, 2026-10-29), held
|
||||
// once for the mesh by artifact-store-tools, serves what the controller served of its store (artifacts, held, mirrors, images, collect) and says `artifacts-gone`. Its keep set is the store's own listing joined
|
||||
// with every want's fingerprint and each module's five newest builds from the builder's log (ADR 0526 §1); it lets go
|
||||
// of nothing until the controller's lease names a holder that is no controller.
|
||||
|
||||
// ArtifactSweepSeat is the seat artifact-store-tools holds. Not the seat mesh-artifact-store, the store itself held by
|
||||
// distribution, and not the provision artifact-store it delivers.
|
||||
const ArtifactSweepSeat = "mesh-artifact-sweep"
|
||||
|
||||
// artifactSweepVerbs are the seat's verbs, with the controller's arguments so a caller changes only the seat it asks.
|
||||
func artifactSweepVerbs() []Verb {
|
||||
return []Verb{
|
||||
{Name: "artifacts", Description: "Every artifact the mesh made that lives in its artifact store, each with its " +
|
||||
"repository, digest, kind (image or archive) and state: kept, and why (a want names it, or one of the five " +
|
||||
"newest builds of its module in the builder's log), eligible (made, kept for no reason, not yet let go), or " +
|
||||
"unrecorded (in the store and named by nothing the mesh made: never removed). Reads only.",
|
||||
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 (counted always)",
|
||||
}, nil, "collected")},
|
||||
{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. " +
|
||||
"Reads only.",
|
||||
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 is eligible is let go of. Without confirm a " +
|
||||
"dry run: what would be let go, nothing held or deleted. With confirm and why: every kept archive held first, " +
|
||||
"then each eligible artifact let go of, oldest first, an index with the platform manifests no kept index " +
|
||||
"names, each deletion recorded in the hand-act log. Bounded by most (500 by default, at most 5000) and 45 " +
|
||||
"seconds; what is left is said. Never touches a digest the mesh did not make.",
|
||||
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",
|
||||
"most": "let go of at most this many (500 by default, at most 5000)",
|
||||
}, nil, "confirm")},
|
||||
{Name: "mirrors", Description: "The bases the mesh copied into its artifact store, each kept (a kept build stood on " +
|
||||
"it), eligible (only builds no longer kept stood on it) or unrecorded (no build the builder's log holds stood on " +
|
||||
"it: never removed), by what the builder's log says each build stood on. Records nothing by hand.",
|
||||
Input: schema(map[string]string{}, nil)},
|
||||
{Name: "images", Description: "Every container image one machine's want and have name, each with the resources " +
|
||||
"naming it: what the machine keeps when it prunes its images. Reads only.",
|
||||
Input: schema(map[string]string{"node": "the machine's name"}, []string{"node"})},
|
||||
}
|
||||
}
|
||||
@@ -5,28 +5,16 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A backup contribution written by hand still names its module's own directories; one it does not
|
||||
// declare is refused where it is written rather than reaching the holder as the literal text. (The
|
||||
// catalogue check refuses a hand-written backup line at all since ADR 0233; parsing still reads one,
|
||||
// because a module on the shelf was written before.)
|
||||
func TestABackupNamingAnUndeclaredDirectoryIsRefused(t *testing.T) {
|
||||
_, err := ParseManifest([]byte(`{"module":"pg","version":"1",
|
||||
"contributions":[{"seat":"node-backup","kind":"backup","content":"path ${dir:nowhere}"}]}`))
|
||||
if err == nil || !strings.Contains(err.Error(), "nowhere") {
|
||||
t.Fatalf("a backup of an undeclared directory was accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A module on the shelf from before ADR 0233, which writes its backup lines by hand and declares no
|
||||
// data, is still backed up: its lines are placed as written, each module's under a comment naming it.
|
||||
func TestHandWrittenBackupLinesOfAnOlderModuleArePlaced(t *testing.T) {
|
||||
pg, err := ParseManifest([]byte(`{"module":"pg","version":"1",
|
||||
pg, err := decodeForTest([]byte(`{"module":"pg","version":"1",
|
||||
"resources":[{"id":"dumps","type":"directory","path":"/srv/pg/dumps","mode":"0700"}],
|
||||
"contributions":[{"seat":"node-backup","kind":"backup","content":"run pg-dump-all\npath ${dir:dumps}"}]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mail, err := ParseManifest([]byte(`{"module":"mail","version":"1",
|
||||
mail, err := decodeForTest([]byte(`{"module":"mail","version":"1",
|
||||
"resources":[{"id":"spool","type":"directory","mode":"0700"}],
|
||||
"contributions":[{"seat":"node-backup","kind":"backup","content":"path ${dir:spool}"}]}`))
|
||||
if err != nil {
|
||||
|
||||
@@ -1,34 +1,12 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The module that provides the artifact store may not be delivered through it.
|
||||
//
|
||||
// Building publishes to the store and the builder will not start without one, so a module that
|
||||
// provides the store and also builds something asks the mesh to put an artifact into the thing
|
||||
// that artifact is needed to create. On a mesh new enough to have no registry, that is a build
|
||||
// that never returns (novox/hq 04-ISSUES/029).
|
||||
func TestTheArtifactStoreCannotBeDeliveredThroughItself(t *testing.T) {
|
||||
_, err := ParseManifest([]byte(`{"module":"registry","version":"1",` +
|
||||
`"provides":[{"name":"artifact-store","scope":"mesh"}],` +
|
||||
`"build":{"artifacts":[{"name":"registry","kind":"upstream","from":"registry:2"}]},` +
|
||||
`"resources":[{"id":"store","type":"container","name":"mesh-registry",` +
|
||||
`"artifact":"registry","ports":["5000:5000"]}]}`))
|
||||
if err == nil {
|
||||
t.Fatal("a registry module that builds its own image was accepted; the build has " +
|
||||
"nowhere to publish until the module it belongs to is already running")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "artifact-store") {
|
||||
t.Fatalf("refused without naming the provision the cycle turns on: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Naming the image directly is the way out, and must stay accepted.
|
||||
func TestAnArtifactStoreThatNamesItsImageIsAccepted(t *testing.T) {
|
||||
_, err := ParseManifest([]byte(`{"module":"registry","version":"1",` +
|
||||
_, err := decodeForTest([]byte(`{"module":"registry","version":"1",` +
|
||||
`"provides":[{"name":"artifact-store","scope":"mesh"}],` +
|
||||
`"resources":[{"id":"store","type":"container","name":"mesh-registry",` +
|
||||
`"image":"registry@sha256:` +
|
||||
@@ -41,7 +19,7 @@ func TestAnArtifactStoreThatNamesItsImageIsAccepted(t *testing.T) {
|
||||
|
||||
// And an ordinary module still builds whatever it likes.
|
||||
func TestAModuleThatDoesNotProvideTheStoreStillBuilds(t *testing.T) {
|
||||
_, err := ParseManifest([]byte(`{"module":"forge","version":"1",` +
|
||||
_, err := decodeForTest([]byte(`{"module":"forge","version":"1",` +
|
||||
`"build":{"artifacts":[{"name":"forge","kind":"upstream","from":"gitea/gitea:1.22"}]},` +
|
||||
`"resources":[{"id":"run","type":"container","name":"forge","artifact":"forge"}]}`))
|
||||
if err != nil {
|
||||
|
||||
@@ -174,7 +174,7 @@ func TestALocalRequirementIsStillAnsweredLocally(t *testing.T) {
|
||||
func TestTheShortFormStillMeansHere(t *testing.T) {
|
||||
// `"provides": ["shell"]` must keep meaning what it meant, or every existing manifest
|
||||
// silently changes meaning.
|
||||
m, err := ParseManifest([]byte(`{"module":"zsh","version":"1","provides":["shell"]}`))
|
||||
m, err := decodeForTest([]byte(`{"module":"zsh","version":"1","provides":["shell"]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -183,19 +183,6 @@ func TestTheShortFormStillMeansHere(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestASiteScopedProvisionIsRefused(t *testing.T) {
|
||||
// Meaningful for a claim — one DHCP server per segment — and not yet meaningful for a
|
||||
// provision, because nothing knows how to reach "the one at my site".
|
||||
_, err := ParseManifest([]byte(
|
||||
`{"module":"dns","version":"1","provides":[{"name":"resolver","scope":"site"}]}`))
|
||||
if err == nil {
|
||||
t.Fatal("a site-scoped provision was accepted")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "site") {
|
||||
t.Fatalf("unhelpful refusal: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Being told, which is the difference between knowing and being able to.
|
||||
|
||||
func boundShelf() map[string]Manifest {
|
||||
@@ -308,25 +295,3 @@ func TestBindingSomethingAnsweredHereWritesNothing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindingWhatYouDoNotRequireIsRefused(t *testing.T) {
|
||||
_, err := ParseManifest([]byte(`{"module":"app","version":"1",
|
||||
"binds":{"postgres-database":"/etc/app/db.json"}}`))
|
||||
if err == nil {
|
||||
t.Fatal("a module was told about something it never asked for")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "does not require") {
|
||||
t.Fatalf("unhelpful refusal: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServingWhatYouDoNotProvideIsRefused(t *testing.T) {
|
||||
_, err := ParseManifest([]byte(`{"module":"app","version":"1",
|
||||
"serves":{"postgres-database":{"port":5432}}}`))
|
||||
if err == nil {
|
||||
t.Fatal("a module served something it does not provide")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "does not provide") {
|
||||
t.Fatalf("unhelpful refusal: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ func TestAMalformedShareIsRefused(t *testing.T) {
|
||||
"other require": `{"name":"route","shares":[{"to":"a","methods":["get"]}]}`,
|
||||
}
|
||||
for name, entry := range cases {
|
||||
_, err := ParseManifest([]byte(`{"module":"files","version":"1","requires":[` + entry + `]}`))
|
||||
_, err := decodeForTest([]byte(`{"module":"files","version":"1","requires":[` + entry + `]}`))
|
||||
if err == nil {
|
||||
t.Errorf("%s: accepted", name)
|
||||
continue
|
||||
@@ -98,7 +98,7 @@ func TestAMalformedShareIsRefused(t *testing.T) {
|
||||
}
|
||||
}
|
||||
// The stored form is held to the same rules.
|
||||
_, err := ParseManifest([]byte(`{"module":"files","version":"1","requires":["s3-bucket"],
|
||||
_, err := decodeForTest([]byte(`{"module":"files","version":"1","requires":["s3-bucket"],
|
||||
"requires-shares":{"s3-bucket":[{"to":"a","methods":[]}]}}`))
|
||||
if err == nil || !strings.Contains(err.Error(), "ADR 0468 §8") {
|
||||
t.Errorf("the stored form was accepted: %v", err)
|
||||
@@ -169,7 +169,7 @@ func TestChangingOnlyTheSharesDoesNotRecreateTheConsumer(t *testing.T) {
|
||||
|
||||
// 1591/AC5: a consumer whose contributes to s3-bucket names `shares` itself is refused.
|
||||
func TestAConsumerCannotContributeSharesItself(t *testing.T) {
|
||||
_, err := ParseManifest([]byte(`{"module":"files","version":"1",
|
||||
_, err := decodeForTest([]byte(`{"module":"files","version":"1",
|
||||
"contributes":{"s3-bucket":{"shares":"x"}},
|
||||
"requires":[{"name":"s3-bucket","shares":[{"to":"a","methods":["get"]}]}]}`))
|
||||
if err == nil || !strings.Contains(err.Error(), "ADR 0468 §8") {
|
||||
|
||||
+26
-364
@@ -2,10 +2,7 @@ package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"path"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
@@ -126,23 +123,7 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) {
|
||||
continue
|
||||
}
|
||||
made := by[a.Name]
|
||||
// What the runtime loads: what the artifact said, else every entrypoint of a module
|
||||
// that declares tools, else nothing (the field's own rule; see Artifact.Loads).
|
||||
//
|
||||
// **Never, unasked, a bundle one of the module's own resources runs** (novox/hq issue 213).
|
||||
// A process the host runs is the module's service, not its tools: the controller declares
|
||||
// the verbs it answers as `tools` and serves them itself, and its bundle would otherwise
|
||||
// have been launched a second time by the node's runtime, as an MCP child it is not.
|
||||
loads := append([]string(nil), a.Loads...)
|
||||
if a.Loads == nil && len(m.Tools) > 0 && !run[a.Name] {
|
||||
loads = append([]string(nil), a.Entrypoints...)
|
||||
// A bundle compiled to a binary has no entrypoints: the binary is what it is, and what
|
||||
// the runtime starts to serve it (novox/hq ADR 0193). So a Go tools bundle is served
|
||||
// as Go — the runtime execs it — exactly as a TypeScript one is through its launcher.
|
||||
if bin := BinaryOf(a); bin != "" {
|
||||
loads = []string{bin}
|
||||
}
|
||||
}
|
||||
loads := BundleLoads(m, a, run)
|
||||
// **Kept, never routed** (ADR 0155): the builder publishes to the store at the address
|
||||
// it reached it by, and a manifest carrying that address names an installation —
|
||||
// registration refused node-tools for exactly this on 2026-10-02. The build record
|
||||
@@ -152,7 +133,7 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) {
|
||||
Name: a.Name, Source: Recorded(made.Reference), Digest: made.Digest,
|
||||
Language: a.Language, Entrypoints: append([]string(nil), a.Entrypoints...),
|
||||
Loads: loads, Env: copyWords(a.Env), Launchers: copyWords(made.Launchers),
|
||||
Binary: BinaryOf(a),
|
||||
Binary: BinaryOf(a), Bus: a.Bus,
|
||||
})
|
||||
}
|
||||
sort.Slice(out.Bundles, func(i, j int) bool { return out.Bundles[i].Name < out.Bundles[j].Name })
|
||||
@@ -253,152 +234,6 @@ func withVersion(value any, version string) any {
|
||||
return value
|
||||
}
|
||||
|
||||
// checkBuild is the manifest's own account of what it builds.
|
||||
func (b *Build) problems(module string) []string {
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
var problems []string
|
||||
seen := map[string]bool{}
|
||||
for _, a := range b.Artifacts {
|
||||
if a.Name == "" {
|
||||
problems = append(problems, module+" builds an artifact with no name")
|
||||
continue
|
||||
}
|
||||
if seen[a.Name] {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s builds two artifacts called %q, and a resource naming it could mean either",
|
||||
module, a.Name))
|
||||
}
|
||||
seen[a.Name] = true
|
||||
switch a.Kind {
|
||||
case ArtifactImage, ArtifactArchive, ArtifactUpstream, ArtifactBundle, ArtifactPackage:
|
||||
default:
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: %q is a %q, and an artifact is %q, %q, %q or %q",
|
||||
module, a.Name, a.Kind, ArtifactImage, ArtifactArchive, ArtifactUpstream,
|
||||
ArtifactBundle+", "+ArtifactPackage))
|
||||
}
|
||||
// **A bundle is built from the module itself, so it says a language instead.** Everything
|
||||
// else names what it is built from: a Dockerfile, a directory, somebody else's reference.
|
||||
// A bundle's source is the module's own directory by definition, and what it needs to say
|
||||
// is which compiler — because the mesh chooses that, and cannot choose for a module that
|
||||
// has not said.
|
||||
if len(a.External) > 0 && (a.Kind != ArtifactBundle || a.Language != "typescript") {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: %q names packages it keeps external, and only a TypeScript bundle is bundled into "+
|
||||
"one file with some kept out (novox/hq ADR 0193)", module, a.Name))
|
||||
}
|
||||
if len(a.Env) > 0 && a.Kind != ArtifactBundle {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: %q is a %q and says what it is given (env). Only a bundle the node's runtime "+
|
||||
"serves is given words (novox/hq ADR 0192); a container says its own environment",
|
||||
module, a.Name, a.Kind))
|
||||
}
|
||||
if a.Kind == ArtifactBundle || a.Kind == ArtifactPackage {
|
||||
// **Except for a language that compiles to a binary, where it names which one**
|
||||
// (novox/hq 04-ISSUES/142). A bundle in an interpreted language is the module's own
|
||||
// directory compiled whole, and naming a source would be describing its own build. A
|
||||
// repository written in a compiled language holds several commands — the host and its
|
||||
// bootstrap live in one, and the mesh needs the host — and "the module's own directory"
|
||||
// is then not a package at all. So the compiled case may say which package, and says
|
||||
// the module root by saying nothing.
|
||||
if a.From != "" && !compilesToABinary(a.Language) {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: %q is a bundle and names what it is built from (%q). A bundle is built "+
|
||||
"from the module's own directory; what it says is the language",
|
||||
module, a.Name, a.From))
|
||||
}
|
||||
if strings.TrimSpace(a.Language) == "" {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: %q is a bundle and says no language, so nothing can choose a compiler "+
|
||||
"for it", module, a.Name))
|
||||
}
|
||||
problems = append(problems, bundleEnvProblems(module, a)...)
|
||||
// What the runtime loads is among what was compiled (ADR 0175): a name here that is
|
||||
// not an entrypoint is a file the bundle does not contain, and the runtime would
|
||||
// fail to import it on every machine rather than here.
|
||||
for _, load := range a.Loads {
|
||||
found := false
|
||||
for _, e := range a.Entrypoints {
|
||||
found = found || e == load
|
||||
}
|
||||
// A bundle compiled to a binary is one executable: the runtime loads that or nothing
|
||||
// (novox/hq ADR 0193).
|
||||
if bin := BinaryOf(a); bin != "" {
|
||||
found = load == bin
|
||||
}
|
||||
if !found {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: %q says the runtime loads %q, which is not among its entrypoints — "+
|
||||
"what is loaded is compiled, so it is named there too", module, a.Name, load))
|
||||
}
|
||||
}
|
||||
// **A system, for a language that compiles to a binary** (novox/hq ADR 0142). A binary
|
||||
// is pinned to one operating system at link time so a host refuses to touch a machine
|
||||
// it was not built for (novox/hq ADR 0005); an artifact that says nothing would be
|
||||
// compiled for whatever the build machine happened to be, which reads as portable and
|
||||
// is not.
|
||||
if compiled := compilesToABinary(a.Language); compiled && strings.TrimSpace(a.System) == "" {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: %q is compiled to a binary and says no system, so it would be built for "+
|
||||
"whatever the build machine happens to be. Declare one artifact per "+
|
||||
"system: %s", module, a.Name, spokenSystems()))
|
||||
} else if !compiled && strings.TrimSpace(a.System) != "" {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: %q names the system %q and is written in %q, which compiles to code that "+
|
||||
"runs anywhere — a system that decides nothing reads as though it did",
|
||||
module, a.Name, a.System, a.Language))
|
||||
} else if compiled && !knownSystem(a.System) {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: %q is built for %q, and a system is %s",
|
||||
module, a.Name, a.System, spokenSystems()))
|
||||
}
|
||||
} else {
|
||||
if a.From == "" && a.URL == "" {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: %q says nothing about what it is built from", module, a.Name))
|
||||
}
|
||||
if a.Language != "" {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: %q is a %q and names a language. Only a bundle is compiled by the mesh; "+
|
||||
"everything else brings its own recipe", module, a.Name, a.Kind))
|
||||
}
|
||||
}
|
||||
if a.Compiles != "" {
|
||||
if a.Kind != ArtifactImage {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: %q is a %q and names a Go package it compiles. Only an image's recipe is "+
|
||||
"told which; a bundle names its package in from (novox/hq ADR 0267)", module, a.Name, a.Kind))
|
||||
} else if c := strings.TrimSpace(a.Compiles); strings.HasPrefix(c, "/") || c == ".." ||
|
||||
strings.HasPrefix(c, "../") || strings.Contains(c, "/../") || strings.HasSuffix(c, "/..") {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: %q compiles %q, which leaves the tree it is built in", module, a.Name, a.Compiles))
|
||||
}
|
||||
}
|
||||
// An upstream image is named, not read from the repository, so the path rule does not
|
||||
// apply to it — and applying it anyway would refuse every reference with a registry host
|
||||
// in it.
|
||||
if a.Kind != ArtifactUpstream &&
|
||||
(strings.HasPrefix(a.From, "/") || strings.Contains(a.From, "..")) {
|
||||
// A build reads its own repository and nothing else. A path leaving it would make
|
||||
// what gets built depend on whatever happens to be on the machine building it.
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: %q is built from %q, which is outside its own repository",
|
||||
module, a.Name, a.From))
|
||||
}
|
||||
if a.Kind == ArtifactUpstream && !strings.Contains(a.From, ":") {
|
||||
// Without a tag or digest, what gets mirrored is whatever `latest` means today, and
|
||||
// a module pinned to that is not pinned.
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: %q mirrors %q, which names no tag or digest", module, a.Name, a.From))
|
||||
}
|
||||
problems = append(problems, upstreamFileProblems(module, a)...)
|
||||
problems = append(problems, pathsProblems(module, a)...)
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// NamedPaths are the paths a module's fingerprint takes its tree from (novox/hq ADR 0499 clause 2): the union of the
|
||||
// paths its artifacts name, cleaned and sorted, and every true only when the module builds something and every artifact
|
||||
// names paths; otherwise none, and the module keeps its directory's tree.
|
||||
@@ -426,122 +261,6 @@ func (m Manifest) NamedPaths() (paths []string, every bool) {
|
||||
return paths, true
|
||||
}
|
||||
|
||||
// pathsProblems is what is wrong with an artifact's `paths` (novox/hq ADR 0499 clause 1): named on a kind whose build
|
||||
// source is derived or named otherwise, or a path that leaves the module's directory, is its whole directory, or is a
|
||||
// pattern.
|
||||
func pathsProblems(module string, a Artifact) []string {
|
||||
if a.Paths == nil {
|
||||
return nil
|
||||
}
|
||||
switch {
|
||||
case a.Kind == ArtifactBundle && a.Language == "go":
|
||||
return []string{fmt.Sprintf("%s: %q is a Go program and names paths: its build source is derived from the "+
|
||||
"package it compiles (novox/hq ADR 0267 clause 1, ADR 0499 clause 1)", module, a.Name)}
|
||||
case a.Kind != ArtifactBundle && a.Kind != ArtifactPackage:
|
||||
return []string{fmt.Sprintf("%s: %q is a %q and names paths; only a package or a bundle in a language other "+
|
||||
"than Go names the paths it is built from (novox/hq ADR 0499 clause 1)", module, a.Name, a.Kind)}
|
||||
case len(a.Paths) == 0:
|
||||
return []string{fmt.Sprintf("%s: %q names no path in paths; leave the word out to build from the module's "+
|
||||
"directory", module, a.Name)}
|
||||
}
|
||||
var problems []string
|
||||
for _, p := range a.Paths {
|
||||
clean := path.Clean(p)
|
||||
switch {
|
||||
case strings.TrimSpace(p) == "":
|
||||
problems = append(problems, fmt.Sprintf("%s: %q names an empty path", module, a.Name))
|
||||
case strings.HasPrefix(p, "/") || clean == ".." || strings.HasPrefix(clean, "../"):
|
||||
problems = append(problems, fmt.Sprintf("%s: %q names the path %q, which leaves the module's directory",
|
||||
module, a.Name, p))
|
||||
case clean == ".":
|
||||
problems = append(problems, fmt.Sprintf("%s: %q names the module's whole directory (%q) as a path; leave "+
|
||||
"the word out for that", module, a.Name, p))
|
||||
case strings.ContainsAny(p, "*?[]{}\\"):
|
||||
problems = append(problems, fmt.Sprintf("%s: %q names %q, which is a pattern; paths names files and "+
|
||||
"directories", module, a.Name, p))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// reservedSuffixes are names no public vendor publishes under: an installation's own and documentation's.
|
||||
var reservedSuffixes = []string{".internal", ".local", ".lan", ".home", ".corp", ".localhost", ".home.arpa",
|
||||
".test", ".invalid", ".example", ".intranet", ".private"}
|
||||
|
||||
// notAPublicHost says why a host is not a public vendor's name, or "".
|
||||
func notAPublicHost(host string) string {
|
||||
h := strings.ToLower(strings.TrimSuffix(host, "."))
|
||||
if net.ParseIP(h) != nil {
|
||||
return "an address rather than a name"
|
||||
}
|
||||
if !strings.Contains(h, ".") {
|
||||
return "a name with no domain"
|
||||
}
|
||||
for _, s := range reservedSuffixes {
|
||||
if strings.HasSuffix(h, s) {
|
||||
return "a name under " + s + ", which is nobody's public domain"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// sha256Hex is a pinned sha256 as a manifest writes it: 64 lower-case hexadecimal characters.
|
||||
var sha256Hex = regexp.MustCompile(`^[0-9a-f]{64}$`)
|
||||
|
||||
// upstreamFileProblems is what is wrong with an archive pinned by URL (novox/hq ADR 0316 §2, §6): it is an
|
||||
// archive, fetched over https, pinned by a sha256 and a version, and it names no directory of the repository
|
||||
// besides; a file name or a directory within is a plain path that stays where it is put.
|
||||
func upstreamFileProblems(module string, a Artifact) []string {
|
||||
pinned := a.URL != "" || a.SHA256 != "" || a.Version != "" || a.File != "" || a.Within != ""
|
||||
if !pinned {
|
||||
return nil
|
||||
}
|
||||
var problems []string
|
||||
say := func(format string, args ...any) {
|
||||
problems = append(problems, fmt.Sprintf("%s: %q ", module, a.Name)+fmt.Sprintf(format, args...))
|
||||
}
|
||||
if a.Kind != ArtifactArchive {
|
||||
say("is a %q and names an upstream file (url, sha256, version, file, within): only an archive is "+
|
||||
"mirrored from one (novox/hq ADR 0316)", a.Kind)
|
||||
return problems
|
||||
}
|
||||
if a.URL == "" {
|
||||
say("names a sha256, a version, a file or a directory within, and no url to fetch it from")
|
||||
return problems
|
||||
}
|
||||
if a.From != "" {
|
||||
say("is built from %q and fetched from %s: an archive is one or the other", a.From, a.URL)
|
||||
}
|
||||
if u, err := url.Parse(a.URL); err != nil || u.Scheme != "https" || u.Host == "" {
|
||||
say("is fetched from %q, and an upstream file is fetched over https from a named host", a.URL)
|
||||
} else if why := notAPublicHost(u.Hostname()); why != "" {
|
||||
say("is fetched from %s, %s: an upstream file is somebody else's release, published under a public name", a.URL, why)
|
||||
}
|
||||
if !sha256Hex.MatchString(a.SHA256) {
|
||||
say("is fetched from %s without a pinned sha256 (64 lower-case hexadecimal characters): what that "+
|
||||
"address serves changes under the mesh, and only the digest says it is what was reviewed", a.URL)
|
||||
}
|
||||
if strings.TrimSpace(a.Version) == "" {
|
||||
say("pins %s and says no version, so a person reading the pin cannot tell which release it is", a.URL)
|
||||
}
|
||||
if a.File != "" && a.Within != "" {
|
||||
say("names a file (%q) and a directory within (%q): a file is kept whole, a directory is taken out "+
|
||||
"of an archive", a.File, a.Within)
|
||||
}
|
||||
for field, p := range map[string]string{"file": a.File, "within": a.Within} {
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
clean := path.Clean(p)
|
||||
if clean != p || strings.HasPrefix(p, "/") || clean == "." || clean == ".." || strings.HasPrefix(clean, "../") ||
|
||||
(field == "file" && strings.Contains(p, "/")) {
|
||||
say("names %s %q, which is not a plain relative %s", field, p, map[string]string{
|
||||
"file": "file name", "within": "directory"}[field])
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// oneOrOther keeps the message readable for one artifact and for several, because a message that
|
||||
// says "neither" about one thing reads as a bug in the message.
|
||||
func oneOrOther(n int) string {
|
||||
@@ -551,30 +270,6 @@ func oneOrOther(n int) string {
|
||||
return "them"
|
||||
}
|
||||
|
||||
// Systems the mesh builds binaries for, which is the set a host may be pinned to (novox/hq ADR 0005).
|
||||
//
|
||||
// **A closed list, and the host's own, not the compiler's.** These are not the values a Go toolchain
|
||||
// would call an operating system — the difference between two of them is a C library, not a kernel.
|
||||
// They are what a machine reports itself to be and what a host is linked to refuse, so the list that
|
||||
// matters is the one the host understands.
|
||||
var systems = []string{"alpine", "android", "arch"}
|
||||
|
||||
// knownSystem is whether the mesh builds for it.
|
||||
func knownSystem(system string) bool {
|
||||
want := strings.ToLower(strings.TrimSpace(system))
|
||||
for _, s := range systems {
|
||||
if s == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// spokenSystems is the list as a refusal says it, so a reader is one edit from right.
|
||||
func spokenSystems() string {
|
||||
return strings.Join(systems, ", ")
|
||||
}
|
||||
|
||||
// compilesToABinary is whether this language's bundle is a binary for one operating system rather
|
||||
// than code that runs wherever its interpreter does.
|
||||
//
|
||||
@@ -615,42 +310,6 @@ func versionOf(digest string) string {
|
||||
return hex
|
||||
}
|
||||
|
||||
// bundleEnvWords are the words the runtime sets for itself; a bundle that named one would be
|
||||
// telling the runtime what it is, which is the mesh's to say (novox/hq ADR 0192).
|
||||
var bundleEnvWords = map[string]bool{
|
||||
RuntimeToolModules: true, RuntimeBrokerFile: true, RuntimeOperatorAccount: true,
|
||||
RuntimeOperatorHome: true, RuntimeToolEnv: true, RuntimeAgentAccount: true, RuntimeAgentHome: true,
|
||||
}
|
||||
|
||||
// bundleEnvProblems says what is wrong with what a bundle says it is given (novox/hq ADR 0192):
|
||||
// a value is a path or a constant written with the references a container's environment may use
|
||||
// for a place or a port, and never a secret's content or another module's binding — a secret
|
||||
// reaches a tool as a file whose path is named.
|
||||
func bundleEnvProblems(module string, a Artifact) []string {
|
||||
if len(a.Env) == 0 {
|
||||
return nil
|
||||
}
|
||||
var problems []string
|
||||
for _, word := range sortedKeys(a.Env) {
|
||||
value := a.Env[word]
|
||||
if bundleEnvWords[word] {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: %q gives itself %s, which the node's runtime sets for itself; a bundle is "+
|
||||
"given its own words beside the runtime's, never in place of them (novox/hq ADR 0192)",
|
||||
module, a.Name, word))
|
||||
}
|
||||
rest := ofPort.ReplaceAllString(dirRef.ReplaceAllString(value, ""), "")
|
||||
if strings.Contains(rest, "${") {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: %q gives %s the value %q. A bundle's word is a path or a constant, written with "+
|
||||
"${dir:…} and ${port:…} only; a secret reaches a tool as a file the mesh places, "+
|
||||
"named by its path, never as its content (novox/hq ADR 0192)",
|
||||
module, a.Name, word, value))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
func copyWords(in map[string]string) map[string]string {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
@@ -691,27 +350,30 @@ func runByAResource(m Manifest) map[string]bool {
|
||||
return named
|
||||
}
|
||||
|
||||
// undeliveredBundles says which of a module's bundles nothing would ever put on a machine (novox/hq
|
||||
// 04-ISSUES/216). A bundle reaches a machine three ways: the node's runtime serves it (it says
|
||||
// `loads`, or its module declares `tools`), a resource names it (a process, a step, an archive), or
|
||||
// it is the runtime itself. One reached by none of them was built, recorded and pushed as success,
|
||||
// and was simply absent — seven modules' tools went missing that way on 2026-10-03. Refused here,
|
||||
// naming the field that would deliver it.
|
||||
func undeliveredBundles(m Manifest) []string {
|
||||
if m.Build == nil || m.Module == RuntimeModule {
|
||||
return nil
|
||||
}
|
||||
named := runByAResource(m)
|
||||
var problems []string
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a.Kind != ArtifactBundle || named[a.Name] || len(a.Loads) > 0 || len(m.Tools) > 0 {
|
||||
continue
|
||||
// BundleLoads is what the runtime loads of the bundle artifact a of m (run: the bundles one of m's own resources runs,
|
||||
// runByAResource): what the artifact said, else every entrypoint of a module that declares tools, else nothing; a
|
||||
// bundle compiled to a binary is served by its binary. The rule a build's resolution keeps on Bundle.Loads, and what a
|
||||
// module composed as what runs of it reads again when the runtime already dropped it (issue 1126).
|
||||
func BundleLoads(m Manifest, a Artifact, run map[string]bool) []string {
|
||||
// What the runtime loads: what the artifact said, else every entrypoint of a module
|
||||
// that declares tools, else nothing (the field's own rule; see Artifact.Loads).
|
||||
//
|
||||
// **Never, unasked, a bundle one of the module's own resources runs** (novox/hq issue 213).
|
||||
// A process the host runs is the module's service, not its tools: the controller declares
|
||||
// the verbs it answers as `tools` and serves them itself, and its bundle would otherwise
|
||||
// have been launched a second time by the node's runtime, as an MCP child it is not.
|
||||
loads := append([]string(nil), a.Loads...)
|
||||
if a.Loads == nil && len(m.Tools) > 0 && !run[a.Name] {
|
||||
loads = append([]string(nil), a.Entrypoints...)
|
||||
// A bundle compiled to a binary has no entrypoints: the binary is what it is, and what
|
||||
// the runtime starts to serve it (novox/hq ADR 0193). So a Go tools bundle is served
|
||||
// as Go — the runtime execs it — exactly as a TypeScript one is through its launcher.
|
||||
if bin := BinaryOf(a); bin != "" {
|
||||
loads = []string{bin}
|
||||
}
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: the bundle %q would be built and never reach a machine: nothing loads it, runs it or "+
|
||||
"unpacks it. A tools bundle says `loads` (the entrypoints the node's runtime serves) or its "+
|
||||
"module lists its `tools`; a daemon or a step is a resource naming it (novox/hq 04-ISSUES/216)",
|
||||
m.Module, a.Name))
|
||||
}
|
||||
return problems
|
||||
return loads
|
||||
}
|
||||
|
||||
// RunByAResource is the bundles of m one of its own resources runs (its service, not its tools).
|
||||
func RunByAResource(m Manifest) map[string]bool { return runByAResource(m) }
|
||||
|
||||
@@ -1,82 +1,8 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// bundleFor is a manifest whose one artifact is a bundle in the given language and system.
|
||||
func bundleFor(language, system string) Manifest {
|
||||
return Manifest{Module: "a-component", Build: &Build{Artifacts: []Artifact{
|
||||
{Name: "binary", Kind: ArtifactBundle, Language: language, System: system},
|
||||
}}}
|
||||
}
|
||||
|
||||
func problemsOf(t *testing.T, m Manifest) string {
|
||||
t.Helper()
|
||||
return strings.Join(m.Build.problems(m.Module), "\n")
|
||||
}
|
||||
|
||||
// **A language that compiles to a binary must say which system.**
|
||||
//
|
||||
// A binary is pinned to one operating system at link time, so a host refuses to touch a machine it
|
||||
// was not built for. An artifact that says nothing would be compiled for whatever the build machine
|
||||
// happened to be — which reads as portable and is not, and is the fault this check exists for.
|
||||
func TestABinaryMustSayWhichSystemItIsFor(t *testing.T) {
|
||||
got := problemsOf(t, bundleFor("go", ""))
|
||||
if !strings.Contains(got, "says no system") {
|
||||
t.Fatalf("a compiled bundle with no system was accepted:\n%s", got)
|
||||
}
|
||||
// And the refusal names what it could have said, so a reader is one edit from right.
|
||||
for _, system := range []string{"alpine", "android", "arch"} {
|
||||
if !strings.Contains(got, system) {
|
||||
t.Fatalf("the refusal does not name %q as a choice:\n%s", system, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestABinaryThatNamesASystemIsAccepted(t *testing.T) {
|
||||
if got := problemsOf(t, bundleFor("go", "arch")); got != "" {
|
||||
t.Fatalf("a compiled bundle naming a system was refused:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A system the mesh does not build for is refused where it is written. These are the host's own
|
||||
// names, not a compiler's: the difference between two of them is a C library rather than a kernel,
|
||||
// so a value that looks like an operating system to a toolchain is still wrong here.
|
||||
func TestASystemTheMeshDoesNotBuildForIsRefused(t *testing.T) {
|
||||
for _, wrong := range []string{"linux", "debian", "darwin"} {
|
||||
got := problemsOf(t, bundleFor("go", wrong))
|
||||
if !strings.Contains(got, "and a system is") {
|
||||
t.Fatalf("%q was accepted as a system:\n%s", wrong, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// **And a language that runs anywhere must not name one.** A system that decides nothing reads as
|
||||
// though it did, which is the same fault as a restriction that restricts nothing (novox/hq ADR 0045).
|
||||
func TestAPortableBundleMayNotNameASystem(t *testing.T) {
|
||||
got := problemsOf(t, bundleFor("typescript", "arch"))
|
||||
if !strings.Contains(got, "runs anywhere") {
|
||||
t.Fatalf("a portable bundle was allowed to name a system:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPortableBundleNamingNoSystemIsAccepted(t *testing.T) {
|
||||
if got := problemsOf(t, bundleFor("typescript", "")); got != "" {
|
||||
t.Fatalf("an ordinary bundle was refused:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// One component, one artifact per system: the shape the mesh's own binaries are declared in, and the
|
||||
// reason the target is the artifact's rather than the recipe's.
|
||||
func TestOneArtifactPerSystemIsAccepted(t *testing.T) {
|
||||
m := Manifest{Module: "the-host", Build: &Build{Artifacts: []Artifact{
|
||||
{Name: "arch", Kind: ArtifactBundle, Language: "go", System: "arch"},
|
||||
{Name: "alpine", Kind: ArtifactBundle, Language: "go", System: "alpine"},
|
||||
{Name: "android", Kind: ArtifactBundle, Language: "go", System: "android"},
|
||||
}}}
|
||||
if got := problemsOf(t, m); got != "" {
|
||||
t.Fatalf("one artifact per system was refused:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,68 +108,8 @@ func TestAModuleThatBuildsNothingIsOrdinary(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTwoArtifactsWithOneNameAreRefused(t *testing.T) {
|
||||
_, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[
|
||||
{"name":"x","kind":"image","from":"Dockerfile"},
|
||||
{"name":"x","kind":"archive","from":"files"}]}}`))
|
||||
if err == nil {
|
||||
t.Fatal("two artifacts with one name were accepted")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "could mean either") {
|
||||
t.Fatalf("unhelpful refusal: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnArtifactBuiltFromOutsideItsRepositoryIsRefused(t *testing.T) {
|
||||
// A build reads its own repository and nothing else. A path leaving it would make what gets
|
||||
// built depend on whatever happens to be on the machine doing the building.
|
||||
for _, from := range []string{"/etc/passwd", "../elsewhere"} {
|
||||
_, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[
|
||||
{"name":"x","kind":"archive","from":"` + from + `"}]}}`))
|
||||
if err == nil {
|
||||
t.Fatalf("%q was accepted as a build input", from)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "outside its own repository") {
|
||||
t.Fatalf("refused for the wrong reason: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnArtifactOfAnUnknownKindIsRefused(t *testing.T) {
|
||||
_, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[
|
||||
{"name":"x","kind":"binary","from":"main.go"}]}}`))
|
||||
if err == nil {
|
||||
t.Fatal("an artifact of an unknown kind was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPackageParsesLikeABundleAndNeedsALanguage(t *testing.T) {
|
||||
// The SDK's shape: a package built from the module's own directory, naming a language.
|
||||
m, err := ParseManifest([]byte(`{"module":"mesh-sdk","version":"1","slug":"sdk",
|
||||
"build":{"artifacts":[{"name":"lib","kind":"package","language":"typescript"}]},
|
||||
"resources":[]}`))
|
||||
if err != nil {
|
||||
t.Fatalf("the SDK's package manifest did not parse: %v", err)
|
||||
}
|
||||
if m.Build.Artifacts[0].Kind != ArtifactPackage {
|
||||
t.Fatalf("expected a package artifact, got %q", m.Build.Artifacts[0].Kind)
|
||||
}
|
||||
|
||||
// A package that names what it is built from is refused, exactly as a bundle is: it is built
|
||||
// from the module's own directory.
|
||||
if _, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[
|
||||
{"name":"lib","kind":"package","language":"typescript","from":"Dockerfile"}]}}`)); err == nil {
|
||||
t.Fatal("a package naming a source was accepted")
|
||||
}
|
||||
// A package with no language cannot choose a toolchain.
|
||||
if _, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[
|
||||
{"name":"lib","kind":"package"}]}}`)); err == nil {
|
||||
t.Fatal("a package with no language was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAResourceNamingAPackageIsRefused(t *testing.T) {
|
||||
m, err := ParseManifest([]byte(`{"module":"a","version":"1","slug":"a",
|
||||
m, err := decodeForTest([]byte(`{"module":"a","version":"1","slug":"a",
|
||||
"build":{"artifacts":[{"name":"lib","kind":"package","language":"typescript"}]},
|
||||
"resources":[{"id":"svc","type":"container","name":"a","artifact":"lib"}]}`))
|
||||
if err != nil {
|
||||
@@ -180,24 +120,3 @@ func TestAResourceNamingAPackageIsRefused(t *testing.T) {
|
||||
t.Fatal("a resource backed by a package was accepted; a package is not a resource")
|
||||
}
|
||||
}
|
||||
|
||||
// An image names the Go package its recipe compiles (novox/hq ADR 0267): within the tree it is built in,
|
||||
// and only an image says one.
|
||||
func TestOnlyAnImageNamesThePackageItCompilesWithinItsTree(t *testing.T) {
|
||||
m, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[
|
||||
{"name":"x","kind":"image","from":"Dockerfile","compiles":"cmd/x"}]}}`))
|
||||
if err != nil || m.Build.Artifacts[0].Compiles != "cmd/x" {
|
||||
t.Fatalf("an image naming its package was refused or lost it: %v", err)
|
||||
}
|
||||
for _, c := range []struct{ artifact, says string }{
|
||||
{`{"name":"x","kind":"archive","from":"files","compiles":"cmd/x"}`, "Only an image"},
|
||||
{`{"name":"x","kind":"image","from":"Dockerfile","compiles":"../x"}`, "leaves the tree"},
|
||||
{`{"name":"x","kind":"image","from":"Dockerfile","compiles":"/x"}`, "leaves the tree"},
|
||||
{`{"name":"x","kind":"image","from":"Dockerfile","compiles":"a/../../x"}`, "leaves the tree"},
|
||||
} {
|
||||
_, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[` + c.artifact + `]}}`))
|
||||
if err == nil || !strings.Contains(err.Error(), c.says) {
|
||||
t.Errorf("%s: %v, wanted a refusal saying %q", c.artifact, err, c.says)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,40 +9,6 @@ import (
|
||||
"git.novox.be/novox/mesh-sdk/go/internal/beside"
|
||||
)
|
||||
|
||||
// The module holding mesh-broker is the bus, and its account is granted the bus's snapshot API and
|
||||
// nothing else (novox/hq ADR 0235). Anything it declared to say or hear on the bus would be granted
|
||||
// nothing, so it is refused at the parser rather than silently dropped.
|
||||
func TestTheBussAccountSaysNothingOnTheBus(t *testing.T) {
|
||||
raw := []byte(`{"module":"bus","version":"1","provides":[{"name":"mesh-bus","scope":"mesh"}],
|
||||
"claims":[{"name":"mesh-broker","scope":"mesh"}],"own-secrets":{"broker":"/run/broker"},
|
||||
"emits":["something.happened"]}`)
|
||||
_, err := ParseManifest(raw)
|
||||
if err == nil || !strings.Contains(err.Error(), "granted the bus's snapshot API and nothing else") {
|
||||
t.Fatalf("a bus module declaring what it emits was not refused, or not for the reason: %v", err)
|
||||
}
|
||||
|
||||
// Its tools bundle says what it emits (novox/hq ADR 0357): the node's runtime carries the bundle and is
|
||||
// granted the module's events, so a bus module that ships one may declare them.
|
||||
bundled := []byte(`{"module":"bus","version":"1","provides":[{"name":"mesh-bus","scope":"mesh"}],
|
||||
"claims":[{"name":"mesh-broker","scope":"mesh"}],"own-secrets":{"broker":"/run/broker"},
|
||||
"emits":["bus.slow-consumer"],"tools":["bus_streams"],
|
||||
"build":{"artifacts":[{"name":"tools","kind":"bundle","language":"go","system":"arch","from":"cmd/t","binary":"t"}]}}`)
|
||||
if _, err := ParseManifest(bundled); err != nil {
|
||||
t.Fatalf("a bus module whose tools bundle declares what it emits was refused: %v", err)
|
||||
}
|
||||
|
||||
quiet := []byte(`{"module":"bus","version":"1","provides":[{"name":"mesh-bus","scope":"mesh"}],
|
||||
"claims":[{"name":"mesh-broker","scope":"mesh"}],"own-secrets":{"broker":"/run/broker"},
|
||||
"tools":["bus_streams"]}`)
|
||||
m, err := ParseManifest(quiet)
|
||||
if err != nil {
|
||||
t.Fatalf("a bus module with an account and tools was refused: %v", err)
|
||||
}
|
||||
if !m.ClaimsSeat(BrokerSeat) {
|
||||
t.Fatal("it does not read as holding the broker seat")
|
||||
}
|
||||
}
|
||||
|
||||
// The catalogue's bus protects its streams by the snapshot, not as live files: its streams' item is a
|
||||
// dump into its snapshots, it has the account the dump runs as, and the dump runs the snapshot
|
||||
// program in the bus's own container.
|
||||
@@ -51,7 +17,7 @@ func TestTheCataloguesBusIsBackedUpBySnapshot(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, err := ParseManifest(raw)
|
||||
m, err := decodeForTest(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -80,34 +80,6 @@ func ReadBusy(r map[string]any) (Busy, bool, []string) {
|
||||
return b, true, problems
|
||||
}
|
||||
|
||||
// busyProblems is everything wrong with a manifest's `busy` fields, in the manifest's words: a command on
|
||||
// one line, a timeout within its bounds, and only on a container that stays up.
|
||||
func busyProblems(m Manifest) []string {
|
||||
var problems []string
|
||||
for _, r := range m.Resources {
|
||||
b, has, read := ReadBusy(r)
|
||||
if !has {
|
||||
continue
|
||||
}
|
||||
where := m.Module + ": " + fmt.Sprint(r["id"])
|
||||
for _, p := range read {
|
||||
problems = append(problems, m.Module+": "+p)
|
||||
}
|
||||
if fmt.Sprint(r["type"]) != "container" || !LongRunning(r) {
|
||||
problems = append(problems, fmt.Sprintf("%s declares busy and is not a container that stays up: busy is "+
|
||||
"asked of a running container before it is replaced (ADR 0335)", where))
|
||||
}
|
||||
if b.Command == "" {
|
||||
problems = append(problems, where+"'s busy names no command: one run inside the container, exiting 0 "+
|
||||
"while it is busy")
|
||||
}
|
||||
if strings.ContainsAny(b.Command, "\n\r") {
|
||||
problems = append(problems, where+"'s busy command is one line")
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// busyInto composes a container's `busy` into the node-engine's words, or takes it away: for an engine that
|
||||
// reads it, the command and its timeout written out; for one that does not, the field is not sent, and the
|
||||
// container is replaced at once, as before.
|
||||
|
||||
@@ -2,7 +2,6 @@ package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/validate"
|
||||
@@ -32,35 +31,8 @@ func busyManifest(t *testing.T, on string, busy any) []byte {
|
||||
return out
|
||||
}
|
||||
|
||||
func TestABusyIsHeldToItsShape(t *testing.T) {
|
||||
if _, err := ParseManifest(busyManifest(t, "agent", map[string]any{"command": "mesh-builder busy"})); err != nil {
|
||||
t.Fatalf("a well-formed busy was refused: %v", err)
|
||||
}
|
||||
if _, err := ParseManifest(busyManifest(t, "agent", map[string]any{"command": "x", "timeout": "30s"})); err != nil {
|
||||
t.Fatalf("a busy with its timeout was refused: %v", err)
|
||||
}
|
||||
for name, c := range map[string]struct {
|
||||
on string
|
||||
busy any
|
||||
says string
|
||||
}{
|
||||
"text": {"agent", "mesh-builder busy", "it is an object"},
|
||||
"no command": {"agent", map[string]any{"timeout": "5s"}, "names no command"},
|
||||
"two lines": {"agent", map[string]any{"command": "a\nb"}, "one line"},
|
||||
"a long timeout": {"agent", map[string]any{"command": "x", "timeout": "5m"}, "at most 1m0s"},
|
||||
"an unknown key": {"agent", map[string]any{"command": "x", "every": "1m"}, `says "every"`},
|
||||
"on a step": {"seed", map[string]any{"command": "x"}, "not a container that stays up"},
|
||||
"on a service": {"daemon", map[string]any{"command": "x"}, "not a container that stays up"},
|
||||
} {
|
||||
_, err := ParseManifest(busyManifest(t, c.on, c.busy))
|
||||
if err == nil || !strings.Contains(err.Error(), c.says) {
|
||||
t.Errorf("%s: want a refusal saying %q, got %v", name, c.says, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestABusyIsSentOnlyToAnEngineThatReadsIt(t *testing.T) {
|
||||
m, err := ParseManifest(busyManifest(t, "agent", map[string]any{"command": "mesh-builder busy"}))
|
||||
m, err := decodeForTest(busyManifest(t, "agent", map[string]any{"command": "mesh-builder busy"}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package catalogue
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/internal/beside"
|
||||
@@ -45,78 +44,3 @@ func TestEveryCatalogueManifestParses(t *testing.T) {
|
||||
t.Fatal("no endpoint in the catalogue is named, so this proved nothing")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoCatalogueManifestNamesAnInstallation is ADR 0112's check, run over the real catalogue: no
|
||||
// definition names a domain or a public address the mesh acts on, and every value that must for now
|
||||
// carries its reason (novox/hq ADR 0155, issue 134). The list it prints is the one that shrinks.
|
||||
func TestNoCatalogueManifestNamesAnInstallation(t *testing.T) {
|
||||
_, found := catalogueManifests(t)
|
||||
var named []string
|
||||
for _, r := range found {
|
||||
m, err := r.manifest, r.err
|
||||
if err != nil {
|
||||
t.Errorf("%s: %v", r.path, err)
|
||||
continue
|
||||
}
|
||||
named = append(named, InstallationProblems(m)...)
|
||||
}
|
||||
if len(named) > 0 {
|
||||
t.Fatalf("%d value(s) name an installation:\n %s", len(named), strings.Join(named, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
// TestEveryCatalogueModuleDeclaresItsData is ADR 0233's check over the real catalogue (it replaced ADR
|
||||
// 0214's store list): every provider that grants says what it keeps for its consumers, nothing writes a
|
||||
// backup line by hand, every directory a container writes is declared, and every irreplaceable item is
|
||||
// backed up — so a store added is a store backed up, without a list of stores to keep in step.
|
||||
func TestEveryCatalogueModuleDeclaresItsData(t *testing.T) {
|
||||
_, found := catalogueManifests(t)
|
||||
shelf := Shelf{}
|
||||
granting := 0
|
||||
for _, r := range found {
|
||||
m, err := r.manifest, r.err
|
||||
if err != nil {
|
||||
continue // TestEveryCatalogueManifestParses says why
|
||||
}
|
||||
if len(m.Grants) > 0 {
|
||||
granting++
|
||||
}
|
||||
shelf[m.Module] = m
|
||||
}
|
||||
for _, problem := range DataProblems(shelf) {
|
||||
t.Error(problem)
|
||||
}
|
||||
if granting == 0 {
|
||||
t.Fatal("no module in the catalogue grants a provision, so this proved nothing")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEveryCatalogueIdentityFitsWhatItRequires is ADR 0225's check over the real catalogue: every
|
||||
// module's identity, on the longest machine name, fits the bound of every provision it wants. An
|
||||
// overflow fails here, in the pull request that introduces it, rather than on the provider's machine
|
||||
// the first time a real machine's name meets the module's (issue 263).
|
||||
func TestEveryCatalogueIdentityFitsWhatItRequires(t *testing.T) {
|
||||
_, found := catalogueManifests(t)
|
||||
shelf := Shelf{}
|
||||
for _, r := range found {
|
||||
m, err := r.manifest, r.err
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", r.path, err)
|
||||
}
|
||||
shelf[m.Module] = m
|
||||
}
|
||||
for _, p := range IdentityProblems(shelf, DefaultLongestMachine) {
|
||||
t.Error(p)
|
||||
}
|
||||
// The night it was found: the resolver provision bounds nothing, and the object store still 20.
|
||||
if dns, ok := shelf["dnsmasq"]; ok {
|
||||
if b := dns.IdentityBoundOf("wildcard-resolution"); b.Bounded() {
|
||||
t.Errorf("the resolver provision bounds its consumers' identities: %+v", b)
|
||||
}
|
||||
}
|
||||
if store, ok := shelf["minio"]; ok {
|
||||
if b := store.IdentityBoundOf("s3-bucket"); b.Max != 20 {
|
||||
t.Errorf("the object store's access key is not bounded at 20: %+v", b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-210
@@ -113,12 +113,7 @@ var oldCollectionNamespaces = []string{"shell", "environment", "contribution"}
|
||||
|
||||
var (
|
||||
// ofCollected is where a collector places a collection. Loose inside the braces, so a misspelt name is refused.
|
||||
ofCollected = regexp.MustCompile(`\$\{collected:([^}]*)\}`)
|
||||
// collectionName is words joined by colons, so an old namespace's placeholder names its collection as it reads.
|
||||
collectionName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*(:[a-z0-9][a-z0-9-]*)*$`)
|
||||
// itemName, attributeKey and attributeValue are the words an item's name and attributes are made of; none holds
|
||||
// a colon, a comma or an equals sign, which tell a selector from a collection's name.
|
||||
itemName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
|
||||
ofCollected = regexp.MustCompile(`\$\{collected:([^}]*)\}`)
|
||||
attributeKey = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
|
||||
attributeValue = regexp.MustCompile(`^[a-z0-9][a-z0-9_.-]*$`)
|
||||
)
|
||||
@@ -242,190 +237,6 @@ func placerOfOld(name string) string {
|
||||
return canonicalSeat(seat)
|
||||
}
|
||||
|
||||
// collectionProblems is what is wrong with a module's collections and items, from the manifest alone.
|
||||
func collectionProblems(m Manifest) []string {
|
||||
var problems []string
|
||||
declared := map[string]bool{}
|
||||
for i, c := range m.Collections {
|
||||
switch {
|
||||
case !collectionName.MatchString(c.Name):
|
||||
problems = append(problems, fmt.Sprintf("%s's collection %d is %q, which is not a collection's name: "+
|
||||
"words of lower-case letters, digits and hyphens, joined by colons", m.Module, i+1, c.Name))
|
||||
case declared[c.Name]:
|
||||
problems = append(problems, fmt.Sprintf("%s collects %s twice", m.Module, c.Name))
|
||||
}
|
||||
if _, old, _ := oldCollection(c.Name, false); old {
|
||||
problems = append(problems, fmt.Sprintf("%s collects %s, which is read from its placeholder: a module "+
|
||||
"places it as ${%s} where it holds the seat that places it, and declares nothing", m.Module, c.Name,
|
||||
c.Name))
|
||||
}
|
||||
if strings.ContainsAny(c.Comment, "\n\r") {
|
||||
problems = append(problems, fmt.Sprintf("%s's collection %s has a comment holding a line break",
|
||||
m.Module, c.Name))
|
||||
}
|
||||
declared[c.Name] = true
|
||||
}
|
||||
// A collection declared and placed nowhere is a word read by nothing; judged where every file's text is here.
|
||||
if !m.Templated() {
|
||||
named := map[string]bool{}
|
||||
for _, r := range m.Resources {
|
||||
if text, ok := r["content"].(string); ok {
|
||||
for _, f := range ofCollected.FindAllStringSubmatch(text, -1) {
|
||||
base, _ := splitSelector(f[1])
|
||||
named[base] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, c := range m.Collections {
|
||||
if !named[c.Name] && collectionName.MatchString(c.Name) {
|
||||
problems = append(problems, fmt.Sprintf("%s collects %s and no file of its places it as "+
|
||||
"${collected:%s}", m.Module, c.Name, c.Name))
|
||||
}
|
||||
}
|
||||
}
|
||||
contributed := map[string]bool{}
|
||||
for i, it := range m.Items {
|
||||
where := fmt.Sprintf("%s's item %d", m.Module, i+1)
|
||||
if !collectionName.MatchString(it.Collection) {
|
||||
problems = append(problems, fmt.Sprintf("%s is to %q, which is not a collection's name", where,
|
||||
it.Collection))
|
||||
} else if _, old, wrong := oldCollection(it.Collection, true); old && wrong != nil {
|
||||
problems = append(problems, fmt.Sprintf("%s: %v", where, wrong))
|
||||
}
|
||||
switch {
|
||||
case it.Name != "" && !itemName.MatchString(it.Name):
|
||||
problems = append(problems, fmt.Sprintf("%s: the name %q is not a name: lower-case letters, digits and "+
|
||||
"hyphens", where, it.Name))
|
||||
case contributed[it.key()]:
|
||||
problems = append(problems, fmt.Sprintf("%s contributes to %s twice; a module contributes one item to "+
|
||||
"a collection, or several each with a name of its own", m.Module, it.key()))
|
||||
case it.Name == "" && slices.ContainsFunc(m.Items, func(o Item) bool {
|
||||
return o.Collection == it.Collection && o.Name != ""
|
||||
}):
|
||||
problems = append(problems, fmt.Sprintf("%s contributes several items to %s and %s has no name; each "+
|
||||
"of several items has a name of its own", m.Module, it.Collection, where))
|
||||
}
|
||||
contributed[it.key()] = true
|
||||
problems = append(problems, itemAttributeProblems(m.Module, where, it)...)
|
||||
if it.Collection == EnvironmentCollection {
|
||||
problems = append(problems, environmentItemProblems(where, it)...)
|
||||
}
|
||||
switch {
|
||||
case it.Template == "" && it.Content == "":
|
||||
problems = append(problems, fmt.Sprintf("%s names no template: an item is a template of the module's "+
|
||||
"own, \"template\": \"%s/<name>\" (novox/hq ADR 0349 §13)", where, m.TemplatesDir()))
|
||||
case it.Template != "" && it.Content != "":
|
||||
problems = append(problems, fmt.Sprintf("%s names its template and says its content too", where))
|
||||
case it.Template != "":
|
||||
if wrong := itemTemplateProblem(m, it.Template); wrong != "" {
|
||||
problems = append(problems, fmt.Sprintf("%s: %s", where, wrong))
|
||||
}
|
||||
// **Filled by the provisioner's client alone**: the controller renders no item.
|
||||
if !m.Moved {
|
||||
problems = append(problems, fmt.Sprintf("%s: an item is filled by the provisioner's client of the "+
|
||||
"node its contributor is placed on, so a module contributing one is marked moved "+
|
||||
"(\"moved\": true; novox/hq ADR 0349 §13, §17)", where))
|
||||
}
|
||||
}
|
||||
if it.Content != "" {
|
||||
problems = append(problems, itemTextProblems(m.Module, it)...)
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// itemAttributeProblems is what is wrong with an item's attributes and capability. The attributes of an item of a kind
|
||||
// received as data are the fields its shape places by (and `order`), each with a value the shape takes.
|
||||
func itemAttributeProblems(module, where string, it Item) []string {
|
||||
var problems []string
|
||||
for _, k := range sortedKeys(it.Attributes) {
|
||||
v := it.Attributes[k]
|
||||
switch {
|
||||
case !attributeKey.MatchString(k):
|
||||
problems = append(problems, fmt.Sprintf("%s: the attribute %q is not a name: lower-case letters, digits "+
|
||||
"and hyphens", where, k))
|
||||
continue
|
||||
case !attributeFits(it.Collection, k, v):
|
||||
problems = append(problems, fmt.Sprintf("%s: %s=%q is not a value: lower-case letters, digits, hyphens, "+
|
||||
"dots and underscores (an environment variable's name, letters, digits and underscores)", where, k, v))
|
||||
continue
|
||||
}
|
||||
if n, err := strconv.Atoi(v); k == OrderAttribute && (err != nil || n < 0 || n > 99) {
|
||||
problems = append(problems, fmt.Sprintf("%s: order=%q is not a whole number from 0 to 99", where, v))
|
||||
}
|
||||
}
|
||||
if namespace, key, _ := strings.Cut(it.Collection, ":"); namespace == "contribution" {
|
||||
seat, kind, _ := strings.Cut(key, ":")
|
||||
if _, r, ok := receivable(seat, kind); ok && r.Shape != nil {
|
||||
for _, k := range sortedKeys(it.Attributes) {
|
||||
var place *PlaceField
|
||||
for i := range r.Shape.Places {
|
||||
if r.Shape.Places[i].Field == k {
|
||||
place = &r.Shape.Places[i]
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case k == OrderAttribute:
|
||||
case place == nil:
|
||||
problems = append(problems, fmt.Sprintf("%s: %s is placed by %s, and %q is none of them", where,
|
||||
it.Collection, placeFields(r), k))
|
||||
case !oneOf(place.Values, it.Attributes[k]):
|
||||
problems = append(problems, fmt.Sprintf("%s: %s=%s; %s is one of %s", where, k, it.Attributes[k], k,
|
||||
strings.Join(place.Values, ", ")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case it.IfCapability == "":
|
||||
case !capabilityName.MatchString(it.IfCapability):
|
||||
problems = append(problems, fmt.Sprintf("%s: if-capability %q is not a capability's name", where, it.IfCapability))
|
||||
case !oneOf(KnownCapabilities, it.IfCapability):
|
||||
problems = append(problems, fmt.Sprintf("%s: if-capability %q is one no machine reports; the node-engine "+
|
||||
"detects %s", where, it.IfCapability, strings.Join(KnownCapabilities, ", ")))
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// AttributeFits is whether an item's attribute is of the shape its collection allows: words of lower case, except
|
||||
// the environment's `name`, which is a variable's name (design 57 L3, issue 795). The same rule is mesh-catalog's
|
||||
// provisioner client's, and the inventory's.
|
||||
func attributeFits(collection, k, v string) bool {
|
||||
if collection == EnvironmentCollection && k == "name" {
|
||||
return variableName.MatchString(v)
|
||||
}
|
||||
return attributeKey.MatchString(k) && attributeValue.MatchString(v)
|
||||
}
|
||||
|
||||
// environmentItemProblems is what is wrong with an item of the environment collection: typed by `kind`, a variable
|
||||
// with its `name`, a PATH entry `at` the start or the end, and no attribute besides `order`.
|
||||
func environmentItemProblems(where string, it Item) []string {
|
||||
var problems []string
|
||||
for _, k := range sortedKeys(it.Attributes) {
|
||||
if !slices.Contains([]string{"kind", "name", "at", OrderAttribute}, k) {
|
||||
problems = append(problems, fmt.Sprintf("%s: %s is typed by kind, name and at, and %q is none of them",
|
||||
where, it.Collection, k))
|
||||
}
|
||||
}
|
||||
switch kind := it.Attributes["kind"]; kind {
|
||||
case "variable":
|
||||
if it.Attributes["name"] == "" || it.Attributes["at"] != "" {
|
||||
problems = append(problems, fmt.Sprintf("%s: a variable names itself with name=<NAME> and has no at", where))
|
||||
} else if it.Attributes["name"] == "PATH" {
|
||||
problems = append(problems, fmt.Sprintf("%s: PATH is not a variable; a PATH entry is kind=path", where))
|
||||
}
|
||||
case "path":
|
||||
if it.Attributes["name"] != "" || (it.Attributes["at"] != PathAtStart && it.Attributes["at"] != PathAtEnd) {
|
||||
problems = append(problems, fmt.Sprintf("%s: a PATH entry is at=%s or at=%s and has no name", where,
|
||||
PathAtStart, PathAtEnd))
|
||||
}
|
||||
default:
|
||||
problems = append(problems, fmt.Sprintf("%s: an item of %s is kind=variable or kind=path, not %q", where,
|
||||
it.Collection, kind))
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// itemTemplateProblem is what is wrong with an item's template path, or "".
|
||||
func itemTemplateProblem(m Manifest, name string) string {
|
||||
dir := m.TemplatesDir()
|
||||
@@ -437,26 +248,6 @@ func itemTemplateProblem(m Manifest, name string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// itemTextProblems is what an item's text may not name: a secret or a bound value, since an item is kept in a
|
||||
// bucket and no secret is (ADR 0349 clause 7), and a collection, since an item is not a collector. And every
|
||||
// placeholder no pass fills, as in a file's content.
|
||||
func itemTextProblems(module string, it Item) []string {
|
||||
var problems []string
|
||||
for _, f := range templatePlaceholder.FindAllStringSubmatch(it.Content, -1) {
|
||||
namespace, key := f[1], f[2]
|
||||
switch _, collection := collectionOfPlaceholder(namespace, key); {
|
||||
case namespace == "secret" || namespace == "bound":
|
||||
problems = append(problems, fmt.Sprintf("%s's item of %s names %s: an item is kept in a bucket, where "+
|
||||
"no secret is (novox/hq ADR 0349 §7)", module, it.Collection, f[0]))
|
||||
case collection:
|
||||
problems = append(problems, fmt.Sprintf("%s's item of %s names %s: an item is not a collector",
|
||||
module, it.Collection, f[0]))
|
||||
}
|
||||
}
|
||||
return append(problems, unconsumedPlaceholders(module, map[string]any{"id": "item:" + it.key(),
|
||||
"type": "file", "content": it.Content})...)
|
||||
}
|
||||
|
||||
// isOld is whether a collection's name is an old namespace's.
|
||||
func isOld(name string) bool {
|
||||
_, old, _ := oldCollection(name, true)
|
||||
|
||||
@@ -69,7 +69,7 @@ func TestASelectorIsRefusedWhenMalformedOrNamingACollectionNotCollected(t *testi
|
||||
raw := strings.Replace(barCollector, `"template":"files/bars"`, `"content":"`+placeholder+`"`, 1)
|
||||
raw = strings.Replace(raw, `"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},`, "", 1)
|
||||
raw = strings.Replace(raw, `"moved":true,`, "", 1)
|
||||
m, err := ParseManifest([]byte(raw))
|
||||
m, err := decodeForTest([]byte(raw))
|
||||
if err == nil {
|
||||
_, err = (Resolution{Node: "n", Modules: []Manifest{m}}).Declaration(Rendering{})
|
||||
}
|
||||
@@ -82,33 +82,6 @@ func TestASelectorIsRefusedWhenMalformedOrNamingACollectionNotCollected(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
func TestItemNamesAttributesAndCapabilitiesAreJudged(t *testing.T) {
|
||||
item := func(extra string) string {
|
||||
return `{"module":"p","moved":true,"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},` +
|
||||
`"items":[` + extra + `]}`
|
||||
}
|
||||
for items, want := range map[string]string{
|
||||
`{"collection":"c","name":"a","template":"files/a"},{"collection":"c","template":"files/b"}`: "has no name",
|
||||
`{"collection":"c","name":"a","template":"files/a"},{"collection":"c","name":"a","template":"files/b"}`: "twice",
|
||||
`{"collection":"c","name":"Big","template":"files/a"}`: "is not a name",
|
||||
`{"collection":"c","attributes":{"order":"100"},"template":"files/a"}`: "0 to 99",
|
||||
`{"collection":"c","attributes":{"bar":"a b"},"template":"files/a"}`: "is not a value",
|
||||
`{"collection":"c","if-capability":"batery","template":"files/a"}`: "no machine reports",
|
||||
`{"collection":"contribution:node-bar:block","attributes":{"bar":"left"},"template":"files/a"}`: "bar=left",
|
||||
`{"collection":"contribution:node-bar:block","attributes":{"colour":"red"},"template":"files/a"}`: `"colour" is none of them`,
|
||||
`{"collection":"c","name":"a","template":"files/a","colour":"red"}`: "colour",
|
||||
} {
|
||||
_, err := ParseManifest([]byte(item(items)))
|
||||
if err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("%s: %v, want %q", items, err, want)
|
||||
}
|
||||
}
|
||||
ok := item(`{"collection":"contribution:node-bar:block","name":"a","attributes":{"bar":"top","place":"status","order":"5"},"if-capability":"battery","template":"files/a"}`)
|
||||
if _, err := ParseManifest([]byte(ok)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// A moved contributor's items, selected by the old placeholder's pair, render in the old order (order, module, item
|
||||
// name) and with the same lines as the contributions they replace.
|
||||
func TestASelectedMovedItemRendersIntoTheOldBarPlaceholderAsTheContributionDid(t *testing.T) {
|
||||
|
||||
@@ -37,7 +37,7 @@ func collectionsOn(t *testing.T) Resolution {
|
||||
t.Helper()
|
||||
zsh := held(t, collectorZsh, builtWith("zsh", map[string]string{"zshrc": zshrcText}))
|
||||
prompt := held(t, contributorP10k, builtWith("prompt", map[string]string{"zsh": contributorText}))
|
||||
aliases, err := ParseManifest([]byte(oldFormAliases))
|
||||
aliases, err := decodeForTest([]byte(oldFormAliases))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -141,39 +141,3 @@ func TestAMovedContributorsItemRendersAsItsOldFormDid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionsAndItemsAreJudged(t *testing.T) {
|
||||
for name, c := range map[string]struct{ raw, says string }{
|
||||
"an item of a module not moved": {`{"module":"m","version":"1",
|
||||
"items":[{"collection":"shell:zsh:normal","template":"files/x"}],
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`, "is marked moved"},
|
||||
"two items to one collection": {`{"module":"m","version":"1","moved":true,
|
||||
"items":[{"collection":"a","template":"files/x"},{"collection":"a","template":"files/y"}],
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`, "contributes to a twice"},
|
||||
"an item to no slot": {`{"module":"m","version":"1","moved":true,
|
||||
"items":[{"collection":"shell:zsh:middle","template":"files/x"}],
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`, "is no slot"},
|
||||
"an item outside the templates": {`{"module":"m","version":"1","moved":true,
|
||||
"items":[{"collection":"a","template":"x"}],
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`, "is not a file under files/"},
|
||||
"an item naming a secret": {`{"module":"m","version":"1",
|
||||
"items":[{"collection":"a","content":"t=${secret:token}"}]}`, "where no secret is"},
|
||||
"a collection not declared": {`{"module":"m","version":"1",
|
||||
"resources":[{"id":"f","type":"file","path":"/f","content":"${collected:a}"}]}`, "does not collect"},
|
||||
"an old namespace declared": {`{"module":"m","version":"1","collections":[{"name":"shell:zsh:normal"}],
|
||||
"resources":[{"id":"f","type":"file","path":"/f","content":"x"}]}`, "read from its placeholder"},
|
||||
"a collection placed nowhere": {`{"module":"m","version":"1","collections":[{"name":"a"}],
|
||||
"resources":[{"id":"f","type":"file","path":"/f","content":"x"}]}`, "no file of its places it"},
|
||||
"a collection in a path": {`{"module":"m","version":"1","collections":[{"name":"a"}],
|
||||
"resources":[{"id":"f","type":"file","path":"/${collected:a}","content":"${collected:a}"}]}`,
|
||||
"only in a file's content"},
|
||||
} {
|
||||
_, err := ParseManifest([]byte(c.raw))
|
||||
if err == nil || !strings.Contains(err.Error(), c.says) {
|
||||
t.Errorf("%s: %v, want it to say %q", name, err, c.says)
|
||||
}
|
||||
}
|
||||
if _, err := ParseManifest([]byte(collectorZsh)); err != nil {
|
||||
t.Errorf("a collector: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,19 +104,6 @@ func TestAGeneratorThisControlPlaneDoesNotHaveIsRefused(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAModuleIsEitherWrittenOrComputedNotBoth(t *testing.T) {
|
||||
// Otherwise nobody could say where a given file on a machine came from.
|
||||
_, err := ParseManifest([]byte(`{"module":"mesh-network","version":"1",
|
||||
"computed":"mesh-network",
|
||||
"resources":[{"id":"a","type":"package","package":"wireguard-tools"}]}`))
|
||||
if err == nil {
|
||||
t.Fatal("a module that both ships files and has them computed was accepted")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "one or the other") {
|
||||
t.Fatalf("unhelpful refusal: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsApplyToAComputedModuleToo(t *testing.T) {
|
||||
// Being computed is about where the files come from, not about whether they are configurable.
|
||||
// A line drawn there would be arbitrary and nobody could predict it.
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A module declares its config (novox/hq ADR 0348 §1, §4, §5, §6, §8, §11; design 57 package K1, issue 660).
|
||||
@@ -123,26 +122,6 @@ func toConfigForm(raw []byte) ([]byte, error) {
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// inConfigWords is a refusal said of a module in the config form in its words: `${config:<key>}` and
|
||||
// takes-config, where the rules shared with the settings form say `${setting:<key>}` and takes-settings.
|
||||
func inConfigWords(problem string) string {
|
||||
problem = settingRef.ReplaceAllStringFunc(problem, func(ref string) string {
|
||||
return "${config:" + settingRef.FindStringSubmatch(ref)[1] + "}"
|
||||
})
|
||||
return strings.ReplaceAll(problem, TakesSettingsField, TakesConfigField)
|
||||
}
|
||||
|
||||
// MixedFormProblems is a manifest using both forms, said with the words of each (ADR 0348 §1).
|
||||
func MixedFormProblems(m Manifest) []string {
|
||||
if len(m.forms.config) == 0 || len(m.forms.settings) == 0 {
|
||||
return nil
|
||||
}
|
||||
return []string{fmt.Sprintf("%s mixes the config form (%s) with the settings form (%s): a module uses one form "+
|
||||
"or the other, never both; write %s, %s and ${config:<key>} throughout (novox/hq ADR 0348 §1)",
|
||||
m.Module, strings.Join(m.forms.config, ", "), strings.Join(m.forms.settings, ", "),
|
||||
ConfigField, TakesConfigField)}
|
||||
}
|
||||
|
||||
// configDeclaration is the config form's declaration as written: nothing of the settings form's, and nothing
|
||||
// unsaid.
|
||||
type configDeclaration struct {
|
||||
@@ -171,166 +150,6 @@ func (d SettingDeclaration) MarshalJSON() ([]byte, error) {
|
||||
Default: d.Default, Required: d.Required, Runtime: d.Runtime})
|
||||
}
|
||||
|
||||
// configShaped is whether a declaration says anything of the config form's.
|
||||
func configShaped(d SettingDeclaration) bool {
|
||||
return d.Type != "" || d.Description != "" || len(d.Enum) > 0 || d.Minimum != nil || d.Maximum != nil ||
|
||||
d.Pattern != "" || d.Trusted || d.Predefined != "" || d.Required || d.Runtime
|
||||
}
|
||||
|
||||
// TypedSettingProblems is a declaration under `settings` that says what only `config` says: a value with a type,
|
||||
// a description and its validation is declared in the config form (ADR 0348 §1, §4).
|
||||
func TypedSettingProblems(m Manifest) []string {
|
||||
var problems []string
|
||||
for _, key := range sortedSettingKeys(m.Settings) {
|
||||
if configShaped(m.Settings[key]) {
|
||||
problems = append(problems, fmt.Sprintf("%s's setting %q says its type, description or validation "+
|
||||
"under %s: a value declared with those is declared under config, with ${config:<key>} and "+
|
||||
"takes-config, never under %s (novox/hq ADR 0348 §1, §4)", m.Module, key, SettingsField, SettingsField))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// ConfigProblems is every way a module in the config form declares its config wrongly, each said by name with
|
||||
// what to do (ADR 0348 How it is checked, design 57 package K1).
|
||||
func ConfigProblems(m Manifest) []string {
|
||||
if !m.DeclaresConfig() {
|
||||
return nil
|
||||
}
|
||||
var problems []string
|
||||
placed := configPlaced(m)
|
||||
fallsBack := fileFallsBack(m)
|
||||
renamed := renamedTaken(m)
|
||||
for _, key := range sortedSettingKeys(m.Settings) {
|
||||
d := m.Settings[key]
|
||||
say := func(format string, args ...any) {
|
||||
problems = append(problems, fmt.Sprintf("%s's config value %q: ", m.Module, key)+fmt.Sprintf(format, args...))
|
||||
}
|
||||
if !settingRef.MatchString("${setting:" + key + "}") {
|
||||
say("not a usable key: lower-case letters, digits, dots, dashes and underscores")
|
||||
continue
|
||||
}
|
||||
if meshWords[key] || accountFacts[key] {
|
||||
say("%q is placement, which the mesh decides, never a config value (novox/hq ADR 0348 §11)", key)
|
||||
continue
|
||||
}
|
||||
if d.Kind != "" || d.Why != "" {
|
||||
say("kind and why are the settings form's; under config a value says its type and its description " +
|
||||
"(novox/hq ADR 0348 §1, §4)")
|
||||
}
|
||||
switch d.Type {
|
||||
case ConfigNumber, ConfigString, ConfigBoolean:
|
||||
case ConfigObject, ConfigList:
|
||||
if !d.Trusted {
|
||||
say("is %s and not trusted: an object or a list decides more than one value can say, so it is "+
|
||||
"trusted, \"trusted\": true, or split into numbers, strings and booleans (novox/hq ADR 0348 §4)", d.Type)
|
||||
}
|
||||
case "":
|
||||
say("no type: say \"type\": number, string or boolean, or object or list when trusted (novox/hq ADR 0348 §4)")
|
||||
default:
|
||||
say("type %q is none of number, string, boolean, object and list (novox/hq ADR 0348 §4)", d.Type)
|
||||
}
|
||||
if strings.TrimSpace(d.Description) == "" {
|
||||
say("no description: say in a sentence what the value is, including its unit (novox/hq ADR 0348 §4)")
|
||||
}
|
||||
switch {
|
||||
case d.Required && d.Default != nil:
|
||||
say("both a default and required: a required value is the operator's own and has no default; give " +
|
||||
"one or the other (novox/hq ADR 0348 §4)")
|
||||
case !d.Required && d.Default == nil:
|
||||
say("neither a default nor required: give a default, or say \"required\": true for a value that is " +
|
||||
"the operator's own (novox/hq ADR 0348 §4)")
|
||||
}
|
||||
if as := renamed[key]; as != "" && d.Required {
|
||||
say("is laid into its file as %q, and only when it is set, so the file and its code fall back when "+
|
||||
"it is not: it is never required; give it the file's default, the empty value of its type "+
|
||||
"(novox/hq issue 751)", as)
|
||||
}
|
||||
for _, wrong := range validationProblems(d) {
|
||||
say("%s", wrong)
|
||||
}
|
||||
if word := operatorsWord(key); word != "" && d.Default != nil && !fallsBack[key] {
|
||||
say("a key naming %q is the operator's value: declare it \"required\": true, with no default, "+
|
||||
"unless a mergeable file takes it and no placeholder asks for it, where the file falls back "+
|
||||
"(novox/hq ADR 0348 §4, ADR 0112, issue 735)", word)
|
||||
}
|
||||
if d.Predefined != "" {
|
||||
p, listed := m.predefined[d.Predefined]
|
||||
switch {
|
||||
case m.predefined == nil:
|
||||
say("falls back to the predefined key %q, and the list of predefined keys (%s at the root of "+
|
||||
"the catalogue's repository) is not present where it is judged: a value falls back only to a "+
|
||||
"key on that list; judge it in the catalogue, or leave \"predefined\" out (novox/hq ADR 0348 §7)",
|
||||
d.Predefined, PredefinedFile)
|
||||
case !listed:
|
||||
say("falls back to %q, which is not on the list of predefined keys: name a key on it, or leave "+
|
||||
"\"predefined\" out (novox/hq ADR 0348 §7)", d.Predefined)
|
||||
default:
|
||||
if p.Type != d.Type {
|
||||
say("falls back to the predefined key %q, which is a %s, and this value is a %s: a value falls "+
|
||||
"back only to a key of its own type (novox/hq ADR 0348 §4)", d.Predefined, p.Type, d.Type)
|
||||
}
|
||||
if p.Trusted && !d.Trusted {
|
||||
say("falls back to the trusted predefined key %q and is not trusted itself: a value that falls "+
|
||||
"back to a trusted key is trusted, \"trusted\": true (novox/hq ADR 0348 §8)", d.Predefined)
|
||||
}
|
||||
}
|
||||
}
|
||||
// A template's text is not here yet. Resolve fills it from the build, WithTemplatesFrom from the tree; neither
|
||||
// judges again itself, but WithTemplatesFrom parses its result again, and a resolved build is parsed again
|
||||
// where it is registered, and this is judged then.
|
||||
if !placed[key] && !d.Runtime && !m.Templated() {
|
||||
say("placed nowhere: no file says ${config:%s} or takes it under %s, and it is not marked "+
|
||||
"\"runtime\": true for a value the module's code reads itself (novox/hq ADR 0348 §5)", key, TakesConfigField)
|
||||
}
|
||||
}
|
||||
used := settingKeysUsedBy(m)
|
||||
for _, key := range sortedKeys(used) {
|
||||
if _, declared := m.Settings[key]; !declared {
|
||||
problems = append(problems, fmt.Sprintf("%s says ${config:%s} and declares no such value under %s: "+
|
||||
"declare %q with its type, description and a default or required (novox/hq ADR 0348 §4, §5)",
|
||||
m.Module, key, ConfigField, key))
|
||||
}
|
||||
}
|
||||
problems = append(problems, configTakesProblems(m)...)
|
||||
problems = append(problems, reflectsProblems(m)...)
|
||||
return problems
|
||||
}
|
||||
|
||||
// configPlaced is every value a file, a unit, a directory's owner, a contribution or a served fact asks for, and
|
||||
// every value a mergeable file takes.
|
||||
func configPlaced(m Manifest) map[string]bool {
|
||||
placed := settingKeysUsedBy(m)
|
||||
for _, r := range m.Resources {
|
||||
for _, k := range takenKeyList(r) {
|
||||
placed[k] = true
|
||||
}
|
||||
}
|
||||
return placed
|
||||
}
|
||||
|
||||
// fileFallsBack is every value a mergeable file takes and no placeholder asks for. Unset, such a value is simply
|
||||
// not laid over the file: the file's own content, or the module's code reading the file, falls back. So one named
|
||||
// after the operator's value (a url, a user, an address) is not `required`, and its default is the file's: its
|
||||
// value in the content, else the empty value of its type, never laid over the file (the operator's decision on
|
||||
// issue 664, issue 735). A placeholder has no such fallback: its default would be filled in where the operator's
|
||||
// value belongs (ADR 0112).
|
||||
func fileFallsBack(m Manifest) map[string]bool {
|
||||
asked := settingKeysUsedBy(m)
|
||||
out := map[string]bool{}
|
||||
for _, r := range m.Resources {
|
||||
if how, _ := r["merge"].(string); how == "" {
|
||||
continue
|
||||
}
|
||||
for _, k := range takenKeyList(r) {
|
||||
if !asked[k] {
|
||||
out[k] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// renamedTaken is every value a mergeable file takes under a name of its own, with that name (issue 751).
|
||||
func renamedTaken(m Manifest) map[string]string {
|
||||
out := map[string]string{}
|
||||
@@ -417,88 +236,3 @@ func ofType(v any, kind string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// configTakesProblems is what ADR 0348 §11 adds for a module in the config form: an account fact in
|
||||
// takes-config (the mesh's own words are refused for both forms by TakenSettingsProblems), and a mergeable file
|
||||
// naming no keys where none of the module's does (where one does, TakenSettingsProblems names every one that
|
||||
// does not).
|
||||
func configTakesProblems(m Manifest) []string {
|
||||
var problems, silent []string
|
||||
naming := false
|
||||
for _, r := range m.Resources {
|
||||
if how, _ := r["merge"].(string); how == "" {
|
||||
continue
|
||||
}
|
||||
if _, said := r[TakesSettingsField]; !said {
|
||||
silent = append(silent, fmt.Sprint(r["id"]))
|
||||
continue
|
||||
}
|
||||
naming = true
|
||||
for _, k := range takenKeyList(r) {
|
||||
_, declared := m.Settings[k]
|
||||
switch {
|
||||
case accountFacts[k]:
|
||||
problems = append(problems, fmt.Sprintf("%s's %v names %q in %s, and %q is an account fact the "+
|
||||
"mesh places, never a config value (novox/hq ADR 0348 §11)", m.Module, r["id"], k,
|
||||
TakesConfigField, k))
|
||||
case meshWords[k]:
|
||||
// Refused for both forms by TakenSettingsProblems, in its words.
|
||||
case !declared:
|
||||
problems = append(problems, fmt.Sprintf("%s's %v names %q in %s and declares no such value under "+
|
||||
"%s: a value a file takes is declared like every other, with its type, description and a "+
|
||||
"default or required, or the config verb could never set it (novox/hq ADR 0348 §4, §11)",
|
||||
m.Module, r["id"], k, TakesConfigField, ConfigField))
|
||||
}
|
||||
}
|
||||
}
|
||||
if !naming {
|
||||
for _, id := range silent {
|
||||
problems = append(problems, fmt.Sprintf("%s's mergeable %s names no %s: a module that declares config "+
|
||||
"names the keys each mergeable file takes, an empty list for none (novox/hq ADR 0348 §11)",
|
||||
m.Module, id, TakesConfigField))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// meshMadeIDs are the resources the mesh composes into a module's own set, which its `restart-on` may name
|
||||
// without declaring them; meshMadePrefixes are those named after what they are for.
|
||||
var (
|
||||
meshMadeIDs = []string{ComposedJailsID(), FilteringID(), CertificateID(), AuthorityID(), BusUsersID()}
|
||||
meshMadePrefixes = []string{NeedID(""), SecretID(""), strings.TrimSuffix(GrantID("", ""), "-"), BoundID(""), ReceivedID(""), AccessID("/")}
|
||||
)
|
||||
|
||||
// reflectsProblems is a `reload-on` or `restart-on` naming a file the module does not declare (ADR 0348 §6): not
|
||||
// one of its own resources, not one the mesh composes for it, and not another module's, which is named
|
||||
// `<module>.<id>`.
|
||||
func reflectsProblems(m Manifest) []string {
|
||||
own := map[string]bool{}
|
||||
for _, r := range m.Resources {
|
||||
own[fmt.Sprint(r["id"])] = true
|
||||
}
|
||||
meshMade := func(id string) bool {
|
||||
if slices.Contains(meshMadeIDs, id) {
|
||||
return true
|
||||
}
|
||||
for _, p := range meshMadePrefixes {
|
||||
if strings.HasPrefix(id, p) && len(id) > len(p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
var problems []string
|
||||
for _, r := range m.Resources {
|
||||
for _, field := range []string{"reload-on", "restart-on"} {
|
||||
for _, id := range stringsOf(r[field]) {
|
||||
if strings.Contains(id, ".") || own[id] || meshMade(id) {
|
||||
continue
|
||||
}
|
||||
problems = append(problems, fmt.Sprintf("%s's %v names %q in its %s, and %s declares no such "+
|
||||
"file: name a file of its own, or another module's as <module>.<id> (novox/hq ADR 0348 §6)",
|
||||
m.Module, r["id"], id, field, m.Module))
|
||||
}
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
@@ -143,43 +143,7 @@ func TestARenamedEntryIsWrittenBackAsItWasSaid(t *testing.T) {
|
||||
if !strings.Contains(string(raw), `"takes-config":["position","theme",{"as":"NOTIFIER_URL","key":"url"}]`) {
|
||||
t.Fatalf("written back as %s", raw)
|
||||
}
|
||||
if _, err := ParseManifest(raw); err != nil {
|
||||
if _, err := decodeForTest(raw); err != nil {
|
||||
t.Fatalf("read again: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARenamedEntryIsRefusedWhereItCouldNotHold(t *testing.T) {
|
||||
entry := func(m map[string]any, e any) map[string]any {
|
||||
resourceOf(m, "rules")["takes-config"] = []any{"position", "theme", e}
|
||||
return m
|
||||
}
|
||||
refused(t, entry(renaming(), map[string]any{"key": "url"}), "notifier's rules", `"key" and "as"`)
|
||||
refused(t, entry(renaming(), map[string]any{"key": "url", "as": "NOTIFIER_URL", "when": "set"}), `"key" and "as"`)
|
||||
refused(t, entry(renaming(), map[string]any{"key": "URL", "as": "NOTIFIER_URL"}), "not a usable key")
|
||||
refused(t, entry(renaming(), map[string]any{"key": "url", "as": "NOTIFIER URL"}), "not a usable name")
|
||||
refused(t, entry(renaming(), map[string]any{"key": "url", "as": "endpoints"}), `"endpoints"`, "the mesh reads")
|
||||
refused(t, entry(renaming(), map[string]any{"key": "url", "as": "theme"}), `"theme"`, "twice")
|
||||
refused(t, entry(renaming(), map[string]any{"key": "theme", "as": "THEME"}), `"theme"`, "twice")
|
||||
refused(t, entry(renaming(), map[string]any{"key": "nothing", "as": "NOTHING"}), `"nothing"`, "declares no such value")
|
||||
|
||||
kept := entry(renaming(), map[string]any{"key": "url", "as": "NOTIFIER_URL"})
|
||||
resourceOf(kept, "rules")["protected"] = []any{"NOTIFIER_URL"}
|
||||
refused(t, kept, `"NOTIFIER_URL"`, "protected")
|
||||
|
||||
// Laid only when set, so the code's own fallback holds: never required.
|
||||
required := renaming()
|
||||
declaration(required, "url")["required"] = true
|
||||
delete(declaration(required, "url"), "default")
|
||||
refused(t, required, `"url"`, "only when it is set", "never required")
|
||||
}
|
||||
|
||||
func TestARenamedEntryIsTheConfigFormsOnly(t *testing.T) {
|
||||
m := map[string]any{
|
||||
"module": "notifier", "version": "1",
|
||||
"resources": []any{
|
||||
map[string]any{"id": "rules", "type": "file", "path": "/x/rules.json", "merge": "json", "content": "{}",
|
||||
"takes-settings": []any{map[string]any{"key": "url", "as": "NOTIFIER_URL"}}},
|
||||
},
|
||||
}
|
||||
refused(t, m, "notifier's rules", "config form")
|
||||
}
|
||||
|
||||
+3
-234
@@ -63,7 +63,7 @@ func parseRaw(t *testing.T, m map[string]any) (Manifest, error) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ParseManifest(raw)
|
||||
return decodeForTest(raw)
|
||||
}
|
||||
|
||||
// refused is that the manifest is refused with every one of these words.
|
||||
@@ -97,66 +97,6 @@ func TestAModuleInTheConfigFormRegisters(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 1: a value without type or description.
|
||||
func TestConfigRefusesAValueWithoutTypeOrDescription(t *testing.T) {
|
||||
m := inConfigForm()
|
||||
delete(declaration(m, "width"), "type")
|
||||
refused(t, m, `notifier's config value "width"`, "no type")
|
||||
m = inConfigForm()
|
||||
declaration(m, "width")["type"] = "colour"
|
||||
refused(t, m, `"width"`, `type "colour"`)
|
||||
m = inConfigForm()
|
||||
delete(declaration(m, "width"), "description")
|
||||
refused(t, m, `"width"`, "no description")
|
||||
}
|
||||
|
||||
// Rule 2: a value with neither a default nor required.
|
||||
func TestConfigRefusesAValueWithNeitherDefaultNorRequired(t *testing.T) {
|
||||
m := inConfigForm()
|
||||
delete(declaration(m, "width"), "default")
|
||||
refused(t, m, `"width"`, "neither a default nor required")
|
||||
m = inConfigForm()
|
||||
declaration(m, "sender")["default"] = "noreply@example.org"
|
||||
refused(t, m, `"sender"`, "both a default and required")
|
||||
}
|
||||
|
||||
// Rule 3: a default failing its validation.
|
||||
func TestConfigRefusesADefaultFailingItsValidation(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
key string
|
||||
value any
|
||||
said string
|
||||
}{
|
||||
{"font-size", 4, "below its minimum"},
|
||||
{"font-size", 41, "above its maximum"},
|
||||
{"font-size", "10", "not a number"},
|
||||
{"position", "bottom", "not one of"},
|
||||
{"theme", "Dark", "does not match"},
|
||||
} {
|
||||
m := inConfigForm()
|
||||
declaration(m, c.key)["default"] = c.value
|
||||
refused(t, m, `"`+c.key+`"`, c.said)
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 4: an object or a list not trusted.
|
||||
func TestConfigRefusesAnObjectOrListNotTrusted(t *testing.T) {
|
||||
for _, kind := range []string{"object", "list"} {
|
||||
m := inConfigForm()
|
||||
def := any(map[string]any{"a": 1})
|
||||
if kind == "list" {
|
||||
def = []any{"a"}
|
||||
}
|
||||
m["config"].(map[string]any)["rules"] = map[string]any{"type": kind, "description": "the rules, as " + kind,
|
||||
"default": def, "runtime": true}
|
||||
refused(t, m, `"rules"`, "is "+kind+" and not trusted")
|
||||
m["config"].(map[string]any)["rules"].(map[string]any)["trusted"] = true
|
||||
if _, err := parseRaw(t, m); err != nil {
|
||||
t.Fatalf("a trusted %s is refused: %v", kind, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// withPredefined is the list of predefined keys the mesh published, for the length of a test.
|
||||
func withPredefined(t *testing.T, keys PredefinedList) {
|
||||
t.Helper()
|
||||
@@ -164,127 +104,6 @@ func withPredefined(t *testing.T, keys PredefinedList) {
|
||||
t.Cleanup(func() { ReplacePublished(was) })
|
||||
}
|
||||
|
||||
// Rule 5: a predefined key not on the list, or of another type. Where no list is present, a value naming one is
|
||||
// refused saying so.
|
||||
func TestConfigRefusesAPredefinedKeyNotOnTheListOrOfAnotherType(t *testing.T) {
|
||||
withPredefined(t, nil)
|
||||
m := inConfigForm()
|
||||
declaration(m, "font-size")["predefined"] = "font-size"
|
||||
refused(t, m, `"font-size"`, "the list of predefined keys", "is not present")
|
||||
|
||||
withPredefined(t, map[string]PredefinedKey{"font-size": {Type: "number"}, "accent": {Type: "string"}})
|
||||
if _, err := parseRaw(t, m); err != nil {
|
||||
t.Fatalf("a value falling back to a listed key of its type is refused: %v", err)
|
||||
}
|
||||
m = inConfigForm()
|
||||
declaration(m, "font-size")["predefined"] = "font-scale"
|
||||
refused(t, m, `"font-size"`, `"font-scale"`, "not on the list")
|
||||
m = inConfigForm()
|
||||
declaration(m, "font-size")["predefined"] = "accent"
|
||||
refused(t, m, `"font-size"`, `"accent"`, "is a string")
|
||||
}
|
||||
|
||||
// Rule 6: an untrusted value falling back to a trusted predefined key.
|
||||
func TestConfigRefusesAnUntrustedValueFallingBackToATrustedKey(t *testing.T) {
|
||||
withPredefined(t, nil)
|
||||
m := inConfigForm()
|
||||
declaration(m, "theme")["predefined"] = "theme"
|
||||
refused(t, m, `"theme"`, "is not present")
|
||||
|
||||
withPredefined(t, map[string]PredefinedKey{"theme": {Type: "string", Trusted: true}})
|
||||
refused(t, m, `"theme"`, "trusted", "is not")
|
||||
declaration(m, "theme")["trusted"] = true
|
||||
if _, err := parseRaw(t, m); err != nil {
|
||||
t.Fatalf("a trusted value falling back to a trusted key is refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 7: a value neither placed in a template nor read at runtime.
|
||||
func TestConfigRefusesAValueNeitherPlacedNorReadAtRuntime(t *testing.T) {
|
||||
m := inConfigForm()
|
||||
delete(declaration(m, "history"), "runtime")
|
||||
refused(t, m, `"history"`, "${config:history}", `"runtime": true`)
|
||||
// Taken by a mergeable file is placed.
|
||||
m = inConfigForm()
|
||||
if _, err := parseRaw(t, m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 8: a placeholder naming an undeclared value.
|
||||
func TestConfigRefusesAPlaceholderNamingAnUndeclaredValue(t *testing.T) {
|
||||
m := inConfigForm()
|
||||
resourceOf(m, "configuration")["content"] = "font = ${config:font-size}\nwidth = ${config:width}\n" +
|
||||
"from = ${config:sender}\ncolour = ${config:colour}\n"
|
||||
refused(t, m, "${config:colour}", "declares no such value")
|
||||
}
|
||||
|
||||
// Rule 9: a reload-on or restart-on naming a file the module does not declare.
|
||||
func TestConfigRefusesAReloadOrRestartOnNamingAnUndeclaredFile(t *testing.T) {
|
||||
m := inConfigForm()
|
||||
resourceOf(m, "notifier")["reload-on"] = []any{"configuration", "dunstrc"}
|
||||
refused(t, m, "reload-on", `"dunstrc"`, "declares no such")
|
||||
m = inConfigForm()
|
||||
resourceOf(m, "notifier")["restart-on"] = []any{"rule"}
|
||||
refused(t, m, "restart-on", `"rule"`)
|
||||
// Another module's file, and one the mesh makes for the module, are not the module's to declare.
|
||||
m = inConfigForm()
|
||||
resourceOf(m, "notifier")["restart-on"] = []any{"rules", "resolver.fact-zones", "secret-mail", "composed-jails"}
|
||||
if _, err := parseRaw(t, m); err != nil {
|
||||
t.Fatalf("refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 10: a moved module's mergeable file not naming its keys under takes-config.
|
||||
func TestConfigRefusesAMergeableFileNotNamingItsKeys(t *testing.T) {
|
||||
m := inConfigForm()
|
||||
delete(resourceOf(m, "rules"), "takes-config")
|
||||
// position and theme are then placed nowhere; say they are read at runtime so only the file's rule speaks.
|
||||
declaration(m, "position")["runtime"] = true
|
||||
declaration(m, "theme")["runtime"] = true
|
||||
refused(t, m, "rules", "names no takes-config")
|
||||
}
|
||||
|
||||
// Rule 11: a mesh word, puid or pgid in takes-config.
|
||||
func TestConfigRefusesAMeshWordOrAnAccountFactInTakesConfig(t *testing.T) {
|
||||
for _, word := range []string{"ports", "expose", "reach", "endpoints", "places", "accesses", "networks", "puid", "pgid"} {
|
||||
m := inConfigForm()
|
||||
resourceOf(m, "rules")["takes-config"] = []any{"position", "theme", word}
|
||||
refused(t, m, `"`+word+`"`, "takes-config")
|
||||
}
|
||||
}
|
||||
|
||||
// A module uses one form or the other, never both.
|
||||
func TestAManifestMixingTheConfigAndSettingsFormsIsRefused(t *testing.T) {
|
||||
m := inConfigForm()
|
||||
resourceOf(m, "configuration")["content"] = "font = ${config:font-size}\nwidth = ${setting:width}\nfrom = ${config:sender}\n"
|
||||
refused(t, m, "mixes", "${setting:", "config")
|
||||
|
||||
m = inConfigForm()
|
||||
delete(resourceOf(m, "rules"), "takes-config")
|
||||
resourceOf(m, "rules")["takes-settings"] = []any{"position", "theme"}
|
||||
refused(t, m, "mixes", "takes-settings")
|
||||
|
||||
m = inConfigForm()
|
||||
m["settings"] = map[string]any{"colour": map[string]any{"kind": "preference", "default": "red", "why": "red"}}
|
||||
refused(t, m, "mixes", "settings")
|
||||
}
|
||||
|
||||
// The settings form keeps its own declaration; the config form's has no kind or why.
|
||||
func TestEachFormKeepsItsOwnDeclaration(t *testing.T) {
|
||||
m := inConfigForm()
|
||||
declaration(m, "width")["why"] = "fits a title"
|
||||
refused(t, m, `"width"`, "why")
|
||||
|
||||
err := parsed(t, Manifest{Module: "notifier",
|
||||
Settings: map[string]SettingDeclaration{"width": {Kind: KindPreference, Default: float64(250),
|
||||
Why: "fits a title", Type: "number"}},
|
||||
Resources: []map[string]any{{"id": "c", "type": "file", "path": "/x", "content": "${setting:width}"}}})
|
||||
if err == nil || !strings.Contains(err.Error(), "under config") {
|
||||
t.Fatalf("a typed value under settings: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// What the mesh holds is what was written: a module in the config form is written back in it, and read again
|
||||
// the same.
|
||||
func TestAConfigManifestIsWrittenBackInItsOwnForm(t *testing.T) {
|
||||
@@ -306,7 +125,7 @@ func TestAConfigManifestIsWrittenBackInItsOwnForm(t *testing.T) {
|
||||
t.Fatalf("written back with %s: %s", unsaid, raw)
|
||||
}
|
||||
}
|
||||
again, err := ParseManifest(raw)
|
||||
again, err := decodeForTest(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -365,7 +184,7 @@ const configDunst = `{"module": "dunst", "version": "1",
|
||||
func TestAModuleRewrittenFromSettingsToConfigComposesByteIdentically(t *testing.T) {
|
||||
composed := func(manifest string, layers []Layer) []byte {
|
||||
t.Helper()
|
||||
m, err := ParseManifest([]byte(manifest))
|
||||
m, err := decodeForTest([]byte(manifest))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -404,14 +223,6 @@ func TestAModuleRewrittenFromSettingsToConfigComposesByteIdentically(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
// A key a mergeable file takes is a config value like every other: one not declared under config is refused by
|
||||
// name (ADR 0348 §4, review of mesh-controller#320).
|
||||
func TestConfigRefusesATakesConfigKeyNotDeclared(t *testing.T) {
|
||||
m := inConfigForm()
|
||||
resourceOf(m, "rules")["takes-config"] = []any{"position", "theme", "urgency"}
|
||||
refused(t, m, `"urgency"`, "takes-config", "declares no such value under config")
|
||||
}
|
||||
|
||||
// A required value nothing sets is refused at composition in the config form's words: ${config:<key>} and the
|
||||
// config verb, never `settings set` (review of mesh-controller#320). The settings form's words are unchanged.
|
||||
func TestAnUnsetRequiredValueIsRefusedInTheConfigFormsWords(t *testing.T) {
|
||||
@@ -466,36 +277,6 @@ func TestAnUnsetRequiredValueIsRefusedInTheConfigFormsWords(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A value named after the operator's own (a url, a user, an address) is `required` where nothing falls back when
|
||||
// it is unset; where a mergeable file takes it and no placeholder asks for it, the file's own content (or the
|
||||
// module's code reading the file) falls back, so it keeps a default and stays optional, as it is today: the
|
||||
// operator's decision on issue 664's second question (issue 735).
|
||||
func TestAnOperatorNamedValueAFileFallsBackOnMayHaveADefault(t *testing.T) {
|
||||
m := inConfigForm()
|
||||
m["config"].(map[string]any)["url"] = map[string]any{"type": "string",
|
||||
"description": "the address the notifier posts to, a URL; empty for the one the file's software picks",
|
||||
"default": ""}
|
||||
resourceOf(m, "rules")["takes-config"] = []any{"position", "theme", "url"}
|
||||
if _, err := parseRaw(t, m); err != nil {
|
||||
t.Fatalf("a taken value named after the operator's, with a default, was refused: %v", err)
|
||||
}
|
||||
|
||||
// A placeholder asking for it has no fallback but the default, which would put a value in the definition
|
||||
// where the operator's belongs (ADR 0112).
|
||||
asked := inConfigForm()
|
||||
asked["config"].(map[string]any)["url"] = map[string]any{"type": "string", "description": "a URL", "default": ""}
|
||||
resourceOf(asked, "rules")["takes-config"] = []any{"position", "theme", "url"}
|
||||
resourceOf(asked, "configuration")["content"] = "font = Inter ${config:font-size}\nwidth = ${config:width}\n" +
|
||||
"from = ${config:sender}\npost = ${config:url}\n"
|
||||
refused(t, asked, `"url"`, `a key naming "url" is the operator's value`)
|
||||
|
||||
// Read by the module's code alone, nothing in the definition says it falls back.
|
||||
runtime := inConfigForm()
|
||||
runtime["config"].(map[string]any)["url"] = map[string]any{"type": "string", "description": "a URL",
|
||||
"default": "", "runtime": true}
|
||||
refused(t, runtime, `"url"`, `a key naming "url" is the operator's value`)
|
||||
}
|
||||
|
||||
// 1658/AC1: a value whose only placement is ${config:key} in an item's text is placed.
|
||||
func TestConfigCountsAnItemsTextAsAPlacement(t *testing.T) {
|
||||
m := map[string]any{
|
||||
@@ -509,15 +290,3 @@ func TestConfigCountsAnItemsTextAsAPlacement(t *testing.T) {
|
||||
t.Fatalf("a value placed only in an item's text is refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1658/AC2: a declared value placed nowhere, with an item that does not say it, is still refused.
|
||||
func TestConfigStillRefusesAValueNoItemPlaces(t *testing.T) {
|
||||
m := map[string]any{
|
||||
"module": "m", "version": "1",
|
||||
"config": map[string]any{
|
||||
"k": map[string]any{"type": "string", "description": "a word nothing says", "default": "x"},
|
||||
},
|
||||
"items": []any{map[string]any{"collection": "a", "content": "k = fixed\n"}},
|
||||
}
|
||||
refused(t, m, `"k"`, "placed nowhere")
|
||||
}
|
||||
|
||||
@@ -124,55 +124,6 @@ func asDNSLabel(as string) string {
|
||||
return strings.ReplaceAll(as, "_", "-")
|
||||
}
|
||||
|
||||
// CheckServes refuses a `serves` block that names a consumer fact or an alphabet the mesh does not
|
||||
// have, when the definition is parsed rather than when a consumer is resolved.
|
||||
//
|
||||
// A provision nobody consumes yet still has its rule read: a definition that would be refused the
|
||||
// first time somebody required it is a definition that is wrong now.
|
||||
func CheckServes(m Manifest) []string {
|
||||
var problems []string
|
||||
for _, provision := range sortedServes(m.Serves) {
|
||||
for _, key := range sortedAnyKeys(m.Serves[provision]) {
|
||||
text, ok := m.Serves[provision][key].(string)
|
||||
if !ok {
|
||||
// A provider's own name is filled only in a value of its own (novox/hq ADR 0346): one
|
||||
// nested in a list or an object would be served as the placeholder, unsaid.
|
||||
problems = append(problems, nestedOwnNames(m, provision, key, m.Serves[provision][key])...)
|
||||
continue
|
||||
}
|
||||
// A probe identity, because what is checked is the shape of the statement and not
|
||||
// what any consumer is called.
|
||||
if _, err := consumerInto(text, "mesh_node_module"); err != nil {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s serves %s, and the value it serves as %q %s", m.Module, provision, key, err))
|
||||
}
|
||||
// And the provider's own names it may serve (novox/hq ADR 0346).
|
||||
problems = append(problems, checkServedOwnNames(m, provision, key, text)...)
|
||||
// A label longer than DNS keeps is not truncated here (asDNSLabel), so the offer's own
|
||||
// bound has to keep the identity inside one (ADR 0225).
|
||||
if strings.Contains(text, "${consumer:as:dns}") {
|
||||
if b := m.IdentityBoundOf(provision); !b.Bounded() || b.Max > dnsLabelLimit {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s serves %s's consumers their identity as a DNS label in %q, and bounds "+
|
||||
"that identity at %s: a label keeps %d — state an `identity` of at most %d",
|
||||
m.Module, provision, key, boundWords(b), dnsLabelLimit, dnsLabelLimit))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// dnsLabelLimit is the longest DNS label (RFC 1035 §2.3.4).
|
||||
const dnsLabelLimit = 63
|
||||
|
||||
func boundWords(b IdentityBound) string {
|
||||
if !b.Bounded() {
|
||||
return "nothing"
|
||||
}
|
||||
return fmt.Sprintf("%d", b.Max)
|
||||
}
|
||||
|
||||
func sortedServes(serves map[string]map[string]any) []string {
|
||||
out := make([]string, 0, len(serves))
|
||||
for k := range serves {
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
func TestAConsumesEntryAskingWhereItStartsIsRefused(t *testing.T) { // 1279/AC4
|
||||
for _, key := range []string{"from", "start", "deliver"} {
|
||||
raw := `{"module":"audit","version":"1","consumes":[{"event":"shop.order.placed","` + key + `":"beginning"}]}`
|
||||
_, err := ParseManifest([]byte(raw))
|
||||
_, err := decodeForTest([]byte(raw))
|
||||
if err == nil {
|
||||
t.Fatalf("a consumes entry with %q was accepted", key)
|
||||
}
|
||||
@@ -18,7 +18,7 @@ func TestAConsumesEntryAskingWhereItStartsIsRefused(t *testing.T) { // 1279/AC4
|
||||
t.Errorf("the refusal for %q does not name the key and the field: %v", key, err)
|
||||
}
|
||||
}
|
||||
if _, err := ParseManifest([]byte(`{"module":"audit","version":"1","consumes":["shop.order.placed"]}`)); err != nil {
|
||||
if _, err := decodeForTest([]byte(`{"module":"audit","version":"1","consumes":["shop.order.placed"]}`)); err != nil {
|
||||
t.Errorf("a plain consumes entry was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,30 +218,6 @@ func TestASettingReachesAContributionOnlyWhereItDeclaresTheKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReceivingWhatYouDoNotProvideIsRefused(t *testing.T) {
|
||||
// It would create a file nobody ever writes to, on a machine where nothing asked for it.
|
||||
_, err := ParseManifest([]byte(`{"module":"traefik","version":"1",
|
||||
"receives":{"reverse-proxy":"/etc/traefik/mesh.json"}}`))
|
||||
if err == nil {
|
||||
t.Fatal("a module received contributions to something it does not provide")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "does not provide") {
|
||||
t.Fatalf("unhelpful refusal: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEmptyContributionIsRefused(t *testing.T) {
|
||||
// Either a mistake or a requirement written the long way round, and both are better said.
|
||||
_, err := ParseManifest([]byte(`{"module":"board","version":"1",
|
||||
"contributes":{"reverse-proxy":{}}}`))
|
||||
if err == nil {
|
||||
t.Fatal("a module contributed nothing and was accepted")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "require it") {
|
||||
t.Fatalf("the refusal does not say what to do instead: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A provision answered from anywhere in the mesh has consumers on other machines, and the
|
||||
// provider has to know who they are. Contributions were node-local until this, which meant the
|
||||
// one case that most needed them was the one they did not reach.
|
||||
|
||||
@@ -113,24 +113,3 @@ func TestAPackageDeclaredPresentAndAbsentIsRefusedNamingBoth(t *testing.T) {
|
||||
t.Errorf("an unrelated assignment is refused for a contradiction already there: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeForAPowerMomentDependsOnThePowerSeat(t *testing.T) {
|
||||
for _, moment := range powerMoments {
|
||||
m := mod("laptop", nil, nil, nil)
|
||||
m.Shell = []ShellCode{{For: moment, Slot: "normal", Code: "true"}}
|
||||
if got := DependsOn(nil, m); !reflect.DeepEqual(got, []string{PowerSeat}) {
|
||||
t.Errorf("code for %s depends on %v, want %s", moment, got, PowerSeat)
|
||||
}
|
||||
if p := m.shellProblems(); len(p) != 0 {
|
||||
t.Errorf("code for %s is refused: %v", moment, p)
|
||||
}
|
||||
}
|
||||
m := mod("laptop", nil, nil, nil)
|
||||
m.Shell = []ShellCode{{For: "after-lunch", Slot: "normal", Code: "true"}}
|
||||
if p := m.shellProblems(); len(p) == 0 {
|
||||
t.Error("code for a moment that does not exist was accepted")
|
||||
}
|
||||
if s, ok := SeatNamed(PowerSeat); !ok || s.Scope != ScopeNode {
|
||||
t.Errorf("%s is not a node seat of the mesh's own: %+v %v", PowerSeat, s, ok)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package catalogue
|
||||
|
||||
// **The control records, out of the controller** (novox/hq ADR 0565 clause 1, issue 1953): two seats at mesh scope,
|
||||
// each held once, and the node seat's verb `packages`.
|
||||
//
|
||||
// - mesh-wants, held by the assigner (mesh-delivery-assigner): a person's upgrade policy and the bus step (clauses 7,
|
||||
// 8, 9);
|
||||
// - mesh-hand-acts, held by the module mesh-hand-acts: the hand-act log and the warrants recorded (clause 10).
|
||||
//
|
||||
// Their events are their holders' to say on the seat; their layouts are go/handacts' and go/delivery's.
|
||||
|
||||
// The two seats.
|
||||
const (
|
||||
WantsSeat = "mesh-wants"
|
||||
HandActsSeat = "mesh-hand-acts"
|
||||
)
|
||||
|
||||
// controlRecordsDecision is the record that made the two seats.
|
||||
const controlRecordsDecision = "novox/hq ADR 0565"
|
||||
|
||||
// wantsVerbs are the assigner's verbs on mesh-wants (ADR 0565 clauses 7, 8, 9, 15).
|
||||
func wantsVerbs() []Verb {
|
||||
return []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, 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\": 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: " +
|
||||
"the step itself is the put on the operator's warrant.",
|
||||
Input: schema(map[string]string{}, nil)},
|
||||
}
|
||||
}
|
||||
|
||||
// handActsVerbs are mesh-hand-acts' verbs (ADR 0565 clause 10), as the controller's took them.
|
||||
func handActsVerbs() []Verb {
|
||||
return []Verb{
|
||||
{Name: "hand-act", Description: "Record one act done by hand: what, why, its cause in a word or a condition's kind " +
|
||||
"(the word a second act for the same reason uses) and the condition it addresses. Who did it is the caller the " +
|
||||
"runtime proves, never said by the caller. Said as act-recorded.",
|
||||
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",
|
||||
"condition": "the key of the condition it addressed, if any (optional)",
|
||||
"verb": "the act's verb in the log: a repair when absent, `hand-act drill` for a drill, or a recorder's own",
|
||||
}, []string{"what", "why"})},
|
||||
{Name: "hand-acts", Description: "What was done by hand lately, over this log and the controller's: who, why and the " +
|
||||
"cause of each, and the causes recorded twice within a fortnight (a healer is wanted).",
|
||||
Input: schema(map[string]string{"days": "how many days back (default 14)", "cause": "only this cause",
|
||||
"who": "only acts whose who contains this", "warrants": "\"true\": every warrant recorded"}, nil, "warrants")},
|
||||
{Name: "warranted", Description: "Record what a module did on the operator's warrant: who chose, through which channel, " +
|
||||
"with which proofs and which answer read from the router's own record of that module's ask, never from the " +
|
||||
"caller; once per ask. 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", "ask": "its ask's id"}, []string{"asker", "ask"})},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ const provisionerClient = `{"module":"mesh-provisioner-client","version":"1",
|
||||
|
||||
func clientOn(t *testing.T) Resolution {
|
||||
t.Helper()
|
||||
m, err := ParseManifest([]byte(provisionerClient))
|
||||
m, err := decodeForTest([]byte(provisionerClient))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -13,80 +13,3 @@ package catalogue
|
||||
// The ordinary five fields — minute, hour, day-of-month, month, day-of-week — with `*`, lists
|
||||
// (`,`), ranges (`-`) and steps (`/`). No names (jan, mon): a numeric cron is what the host
|
||||
// evaluates, and refusing a name here is refusing it near whoever wrote it.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// validateCron reports why a string is not a five-field cron expression, or nil if it is one.
|
||||
func validateCron(expr string) error {
|
||||
fields := strings.Fields(expr)
|
||||
if len(fields) != 5 {
|
||||
return fmt.Errorf(
|
||||
"a schedule is a five-field cron expression (minute hour day-of-month month "+
|
||||
"day-of-week), and this has %d field(s)", len(fields))
|
||||
}
|
||||
bounds := []struct {
|
||||
name string
|
||||
min, max int
|
||||
}{
|
||||
{"minute", 0, 59},
|
||||
{"hour", 0, 23},
|
||||
{"day-of-month", 1, 31},
|
||||
{"month", 1, 12},
|
||||
{"day-of-week", 0, 7}, // 0 and 7 are both Sunday, the convention every cron keeps
|
||||
}
|
||||
for i, b := range bounds {
|
||||
if err := validateCronField(fields[i], b.min, b.max); err != nil {
|
||||
return fmt.Errorf("%s field: %w", b.name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateCronField checks one field's elements are within range and well-formed.
|
||||
func validateCronField(spec string, min, max int) error {
|
||||
if spec == "" {
|
||||
return fmt.Errorf("is empty")
|
||||
}
|
||||
for _, part := range strings.Split(spec, ",") {
|
||||
if part == "" {
|
||||
return fmt.Errorf("%q has an empty element between commas", spec)
|
||||
}
|
||||
|
||||
rangePart := part
|
||||
if slash := strings.IndexByte(part, '/'); slash >= 0 {
|
||||
rangePart = part[:slash]
|
||||
n, err := strconv.Atoi(part[slash+1:])
|
||||
if err != nil || n < 1 {
|
||||
return fmt.Errorf("step in %q is not a positive number", part)
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case rangePart == "*":
|
||||
// Any value; nothing to bound.
|
||||
case strings.IndexByte(rangePart, '-') >= 0:
|
||||
dash := strings.IndexByte(rangePart, '-')
|
||||
lo, errLo := strconv.Atoi(rangePart[:dash])
|
||||
hi, errHi := strconv.Atoi(rangePart[dash+1:])
|
||||
if errLo != nil || errHi != nil {
|
||||
return fmt.Errorf("range %q is not two numbers", rangePart)
|
||||
}
|
||||
if lo < min || hi > max || lo > hi {
|
||||
return fmt.Errorf("range %q is outside the allowed %d-%d", part, min, max)
|
||||
}
|
||||
default:
|
||||
v, err := strconv.Atoi(rangePart)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%q is not a number", rangePart)
|
||||
}
|
||||
if v < min || v > max {
|
||||
return fmt.Errorf("%q is outside the allowed range %d-%d", part, min, max)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -305,182 +304,6 @@ func keepsByClass(class string) bool {
|
||||
return class == ClassIrreplaceable || class == ClassValuable || class == ClassRebuildable
|
||||
}
|
||||
|
||||
// dataID is what an item's id may be: it is a token in a condition's key and a word on a line.
|
||||
var dataID = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
|
||||
|
||||
// dataPathRef is an item's path: one of the module's directories or accesses, and optionally a path
|
||||
// beneath it.
|
||||
var dataPathRef = regexp.MustCompile(`^\$\{(dir|access):([a-z0-9][a-z0-9-]*)\}(/[^\s$]*)?$`)
|
||||
|
||||
// dataProblems is what is wrong with the `data` section itself, from the manifest alone: judged at
|
||||
// registration as every other per-manifest problem is. Whether a module declares what it should is
|
||||
// the catalogue check's (DataProblems), because a module already running was written before it.
|
||||
func (m Manifest) dataProblems() []string {
|
||||
if m.Data == nil {
|
||||
return nil
|
||||
}
|
||||
var problems []string
|
||||
say := func(format string, args ...any) {
|
||||
problems = append(problems, fmt.Sprintf("%s's data: ", m.Module)+fmt.Sprintf(format, args...))
|
||||
}
|
||||
dirs := map[string]bool{}
|
||||
for _, r := range m.Resources {
|
||||
if fmt.Sprint(r["type"]) == "directory" {
|
||||
dirs[fmt.Sprint(r["id"])] = true
|
||||
}
|
||||
}
|
||||
accesses := map[string]bool{}
|
||||
for _, a := range m.Accesses {
|
||||
if a.ID != "" {
|
||||
accesses[a.ID] = true
|
||||
}
|
||||
}
|
||||
ids := map[string]DataItem{}
|
||||
paths := map[string]string{}
|
||||
for i, it := range m.Data.Own {
|
||||
label := it.ID
|
||||
if label == "" {
|
||||
label = fmt.Sprintf("item %d", i+1)
|
||||
}
|
||||
if !dataID.MatchString(it.ID) {
|
||||
say("%s has no usable id: lower-case letters, digits and dashes", label)
|
||||
} else if _, twice := ids[it.ID]; twice {
|
||||
say("%s is declared twice", it.ID)
|
||||
}
|
||||
ids[it.ID] = it
|
||||
ref := dataPathRef.FindStringSubmatch(it.Path)
|
||||
switch {
|
||||
case ref == nil:
|
||||
say("%s's path %q is not one of the module's directories or accesses: ${dir:<id>} or ${access:<id>}, "+
|
||||
"or a path beneath one (a definition names no machine path, novox/hq ADR 0112)", label, it.Path)
|
||||
case ref[1] == "dir" && !dirs[ref[2]]:
|
||||
say("%s's path names ${dir:%s}, and %s declares no directory %q", label, ref[2], m.Module, ref[2])
|
||||
case ref[1] == "access" && !accesses[ref[2]]:
|
||||
say("%s's path names ${access:%s}, and %s declares no access %q", label, ref[2], m.Module, ref[2])
|
||||
case strings.Contains(it.Path, ".."):
|
||||
say("%s's path %q climbs out of its directory", label, it.Path)
|
||||
}
|
||||
if other, twice := paths[it.Path]; twice && it.Path != "" {
|
||||
say("%s and %s name the same path %s", other, label, it.Path)
|
||||
}
|
||||
paths[it.Path] = label
|
||||
switch it.Class {
|
||||
case ClassIrreplaceable, ClassValuable, ClassRebuildable, ClassCache:
|
||||
case ClassNone:
|
||||
say("%s is class none, which only a provision keeping nothing of its consumers' is; own data "+
|
||||
"that is disposable is cache", label)
|
||||
default:
|
||||
say("%s's class %q is not one the mesh protects by: irreplaceable, valuable, rebuildable or cache",
|
||||
label, it.Class)
|
||||
}
|
||||
if it.Class == ClassIrreplaceable && !it.BackedUp() && strings.TrimSpace(it.Redundancy) == "" {
|
||||
say("%s is irreplaceable and is neither backed up nor said to be protected by redundancy; the only "+
|
||||
"copy of something is protected one way or the other (novox/hq ADR 0233) — copy it, dump it into "+
|
||||
"another item, or say `redundancy` with why that is enough", label)
|
||||
}
|
||||
if it.Class == ClassCache && it.Backup != nil && !it.Backup.None {
|
||||
say("%s is a cache and asks to be backed up; a cache is disposable, or it is not a cache", label)
|
||||
}
|
||||
if it.Class == ClassCache && it.Redundancy != "" {
|
||||
say("%s is a cache and says it is protected by redundancy; a cache is not protected", label)
|
||||
}
|
||||
switch it.Measure {
|
||||
case "", MeasureWalk, MeasureDataset, MeasureShallow:
|
||||
default:
|
||||
say("%s's measure %q is walk, dataset or shallow", label, it.Measure)
|
||||
}
|
||||
if _, err := ParseDataDuration(it.Within); err != nil {
|
||||
say("%s's within: %v", label, err)
|
||||
}
|
||||
if _, err := ParseDataDuration(it.Active); err != nil {
|
||||
say("%s's active: %v", label, err)
|
||||
}
|
||||
if it.Within != "" && !it.BackedUp() {
|
||||
say("%s states within, and is not backed up", label)
|
||||
}
|
||||
if it.Active != "" && !Watched(it.Class) {
|
||||
say("%s is %s and expects writes; only irreplaceable and valuable data is watched", label, it.Class)
|
||||
}
|
||||
}
|
||||
// Dumps go into an item of the same module, declared, and not into themselves.
|
||||
for _, it := range m.Data.Own {
|
||||
if it.Backup == nil || it.Backup.Copy || it.Backup.None {
|
||||
continue
|
||||
}
|
||||
switch into, ok := ids[it.Backup.Into]; {
|
||||
case strings.TrimSpace(it.Backup.Dump) == "":
|
||||
say("%s's backup names no dump command", it.ID)
|
||||
case it.Backup.Into == "":
|
||||
say("%s's dump says no item it writes into", it.ID)
|
||||
case !ok:
|
||||
say("%s's dump writes into %q, which is not one of the module's data items", it.ID, it.Backup.Into)
|
||||
case into.ID == it.ID:
|
||||
say("%s's dump writes into itself; a dump is copied from where it lands", it.ID)
|
||||
case into.Class == ClassCache:
|
||||
say("%s's dump writes into %s, a cache; what is copied must not be disposable", it.ID, into.ID)
|
||||
}
|
||||
if it.Backup.Dump != "" {
|
||||
declared := map[string]string{}
|
||||
for d := range dirs {
|
||||
declared[d] = ""
|
||||
}
|
||||
if _, err := dirFill(it.Backup.Dump, declared, m.Module); err != nil {
|
||||
say("%s's dump: %v", it.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
provided := map[string]bool{}
|
||||
for _, o := range m.Provides {
|
||||
provided[o.Name] = true
|
||||
}
|
||||
wants := map[string]bool{}
|
||||
for _, w := range m.Wants() {
|
||||
wants[w] = true
|
||||
}
|
||||
for _, provision := range sortedKeys(m.Data.Consumers) {
|
||||
c := m.Data.Consumers[provision]
|
||||
if !provided[provision] {
|
||||
say("says what it keeps for the consumers of %q, which it does not provide", provision)
|
||||
}
|
||||
switch c.Class {
|
||||
case ClassIrreplaceable, ClassValuable, ClassRebuildable, ClassCache, ClassNone:
|
||||
default:
|
||||
say("its consumers' %s is class %q; one of irreplaceable, valuable, rebuildable, cache or none", provision, c.Class)
|
||||
}
|
||||
switch {
|
||||
case c.Class == ClassNone && c.In != "":
|
||||
say("its consumers' %s keeps nothing and says where it is kept (%s)", provision, c.In)
|
||||
case keepsByClass(c.Class) && c.In == "":
|
||||
say("its consumers' %s is %s and says nowhere it lives: `in` names one of the module's items, or a "+
|
||||
"provision it requires", provision, c.Class)
|
||||
case c.In != "":
|
||||
if own, ok := ids[c.In]; ok {
|
||||
if c.Class == ClassIrreplaceable && !own.BackedUp() && own.Redundancy == "" {
|
||||
say("its consumers' %s is irreplaceable and lives in %s, which is not protected", provision, c.In)
|
||||
}
|
||||
if classRank[own.Class] < classRank[c.Class] {
|
||||
say("its consumers' %s is %s and lives in %s, which is only %s", provision, c.Class, c.In, own.Class)
|
||||
}
|
||||
} else if !wants[c.In] {
|
||||
say("its consumers' %s lives in %q, which is neither one of its data items nor a provision it "+
|
||||
"requires", provision, c.In)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, provision := range sortedKeys(m.Data.KeptBy) {
|
||||
k := m.Data.KeptBy[provision]
|
||||
if !wants[provision] {
|
||||
say("says it keeps data with the provider of %q, which it does not require", provision)
|
||||
}
|
||||
switch k.Class {
|
||||
case ClassIrreplaceable, ClassValuable, ClassRebuildable, ClassCache:
|
||||
default:
|
||||
say("what it keeps with %s is class %q; one of irreplaceable, valuable, rebuildable or cache", provision, k.Class)
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// KeepsConsumerData is whether this module, providing a provision, keeps what each consumer writes
|
||||
// there (novox/hq ADR 0232, ADR 0233): whether a consumer bound to it is bound to its data.
|
||||
//
|
||||
@@ -596,196 +419,9 @@ func (m Manifest) allContributions() []SeatContribution {
|
||||
|
||||
// --- the catalogue check ------------------------------------------------------------------------
|
||||
|
||||
// DataProblems is what the catalogue check refuses about the data a module declares (novox/hq ADR
|
||||
// 0233), judged over the manifests given together:
|
||||
//
|
||||
// - a provider that grants a provision says what it keeps for that provision's consumers;
|
||||
// - nobody says it on the offer any more (`keeps-consumer-data`): the data section is the one place;
|
||||
// - nobody writes a backup line by hand: it is derived from the data section;
|
||||
// - a directory a container writes, mounted whole, is a data item of some class;
|
||||
// - consumer data said to live in a required provision lives where that provision's provider keeps
|
||||
// its consumers' data, as preciously, when the provider is given;
|
||||
// - data a consumer keeps with a provider as irreplaceable is protected there, when the provider is
|
||||
// given.
|
||||
//
|
||||
// **The check's, not registration's**, as ADR 0214's backup rule was: a module already on the shelf
|
||||
// was written before the rule, and refusing it there would refuse the very providers whose data the
|
||||
// rule protects. Each module meets it where it is written.
|
||||
func DataProblems(shelf Shelf) []string {
|
||||
var problems []string
|
||||
for _, name := range shelfOrder(shelf) {
|
||||
m := shelf[name]
|
||||
for _, provision := range sortedKeys(m.Grants) {
|
||||
if _, said := m.ConsumerDataOf(provision); !said {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s grants %s and does not say what it keeps for its consumers: data.consumers.%s with a "+
|
||||
"class — irreplaceable, valuable, rebuildable, cache, or none (novox/hq ADR 0233)", name, provision, provision))
|
||||
}
|
||||
}
|
||||
for _, o := range m.Provides {
|
||||
if o.KeepsConsumerData != nil {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s says keeps-consumer-data on its offer of %s; it is said once, in data.consumers.%s, with a "+
|
||||
"class (novox/hq ADR 0233)", name, o.Name, o.Name))
|
||||
}
|
||||
}
|
||||
for i, c := range m.Contributions {
|
||||
if s, known := SeatNamed(c.Seat); known && s.Name == BackupSeat {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s's contribution %d is a backup line written by hand; backups are derived from the data "+
|
||||
"section — declare the data, with its class and how it is protected (novox/hq ADR 0233)", name, i+1))
|
||||
}
|
||||
}
|
||||
for _, dir := range writtenDirectories(m) {
|
||||
if !coversDirectory(m, dir) {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s mounts its directory %q into a container to be written, and declares no data in it: "+
|
||||
"data.own with a class — cache if it is disposable (novox/hq ADR 0233)", name, dir))
|
||||
}
|
||||
}
|
||||
if m.Data == nil {
|
||||
continue
|
||||
}
|
||||
for _, provision := range sortedKeys(m.Data.Consumers) {
|
||||
c := m.Data.Consumers[provision]
|
||||
if c.In == "" {
|
||||
continue
|
||||
}
|
||||
if _, own := m.DataItem(c.In); own {
|
||||
continue
|
||||
}
|
||||
for _, pname := range shelfOrder(shelf) {
|
||||
p := shelf[pname]
|
||||
pc, said := p.ConsumerDataOf(c.In)
|
||||
if !said || !providesName(p, c.In) {
|
||||
continue
|
||||
}
|
||||
if classRank[pc.Class] < classRank[c.Class] {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s keeps its consumers' %s, %s, in %s — and %s keeps its consumers' %s as %s, "+
|
||||
"so it is not protected as %s", name, provision, c.Class, c.In, pname, c.In, pc.Class, c.Class))
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, provision := range sortedKeys(m.Data.KeptBy) {
|
||||
k := m.Data.KeptBy[provision]
|
||||
if k.Class != ClassIrreplaceable {
|
||||
continue
|
||||
}
|
||||
for _, pname := range shelfOrder(shelf) {
|
||||
p := shelf[pname]
|
||||
pc, said := p.ConsumerDataOf(provision)
|
||||
if !said || !providesName(p, provision) {
|
||||
continue
|
||||
}
|
||||
if !keepsByClass(pc.Class) {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s keeps irreplaceable data with %s, and %s keeps its consumers' %s as %s — nothing of it is "+
|
||||
"protected there", name, provision, pname, provision, pc.Class))
|
||||
continue
|
||||
}
|
||||
if in, own := p.DataItem(pc.In); own && !in.BackedUp() && in.Redundancy == "" {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s keeps irreplaceable data with %s, and %s keeps it in %s, which is neither backed up nor "+
|
||||
"on declared redundancy", name, provision, pname, pc.In))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
func providesName(m Manifest, provision string) bool {
|
||||
for _, o := range m.Provides {
|
||||
if o.Name == provision {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// writtenDirectories are the module's directories a container mounts whole and may write: a volume
|
||||
// `${dir:<id>}:<target>` without `:ro`, or a directory stated by an absolute path mounted the same
|
||||
// way. A file beneath a directory, or a read-only mount, is configuration the mesh wrote.
|
||||
func writtenDirectories(m Manifest) []string {
|
||||
absolute := map[string]string{}
|
||||
for _, r := range m.Resources {
|
||||
if fmt.Sprint(r["type"]) != "directory" {
|
||||
continue
|
||||
}
|
||||
if p, ok := r["path"].(string); ok && strings.HasPrefix(p, "/") {
|
||||
absolute[strings.TrimRight(p, "/")] = fmt.Sprint(r["id"])
|
||||
}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, r := range m.Resources {
|
||||
if fmt.Sprint(r["type"]) != "container" {
|
||||
continue
|
||||
}
|
||||
vols, _ := r["volumes"].([]any)
|
||||
for _, v := range vols {
|
||||
s, _ := v.(string)
|
||||
mount := volumeMount.FindStringSubmatch(s)
|
||||
if mount == nil || volumeReadOnly(mount[3]) {
|
||||
continue
|
||||
}
|
||||
src := strings.TrimRight(mount[1], "/")
|
||||
if ref := dirPlain.FindStringSubmatch(src); ref != nil {
|
||||
seen[ref[1]] = true
|
||||
} else if id, ok := absolute[src]; ok {
|
||||
seen[id] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for id := range seen {
|
||||
out = append(out, id)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// volumeMount is a container's volume, `<source>:<target>[:<options>]`, its source possibly a
|
||||
// `${dir:<id>}` — whose own colon is not the separator.
|
||||
var volumeMount = regexp.MustCompile(`^(\$\{(?:dir|access):[a-z0-9][a-z0-9-]*\}[^:]*|[^:]+):([^:]+)(?::(.*))?$`)
|
||||
|
||||
// volumeReadOnly is whether a volume's options mount it read-only.
|
||||
func volumeReadOnly(options string) bool {
|
||||
for _, o := range strings.Split(options, ",") {
|
||||
if strings.TrimSpace(o) == "ro" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// dirPlain is a whole directory, `${dir:<id>}` and nothing after it.
|
||||
var dirPlain = regexp.MustCompile(`^\$\{dir:([a-z0-9][a-z0-9-]*)\}$`)
|
||||
|
||||
// coversDirectory is whether a data item names the directory, a path within it, or a directory it
|
||||
// is placed beneath.
|
||||
func coversDirectory(m Manifest, dir string) bool {
|
||||
parents := map[string]string{}
|
||||
for _, r := range m.Resources {
|
||||
if fmt.Sprint(r["type"]) != "directory" {
|
||||
continue
|
||||
}
|
||||
if p, ok := r["path"].(string); ok {
|
||||
if ref := dirRef.FindStringSubmatch(p); ref != nil && strings.HasPrefix(p, "${dir:") {
|
||||
parents[fmt.Sprint(r["id"])] = ref[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, it := range m.DataItems() {
|
||||
ref := dataPathRef.FindStringSubmatch(it.Path)
|
||||
if ref == nil || ref[1] != "dir" {
|
||||
continue
|
||||
}
|
||||
for d, hops := dir, 0; d != "" && hops < 4; d, hops = parents[d], hops+1 {
|
||||
if ref[2] == d {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// **The data items of a node's modules, placed, read from the composition** (novox/hq ADR 0571 clause 3, ADR 0233 §22 to
|
||||
// §25): disk-load measures each item every module on its node declares, and the node-backup holder, mounts and nfs-server
|
||||
// read the readings. Each item is its module's declaration with its path resolved to this node's place, its protection
|
||||
// and how it is measured: the same lines the node-backup holder is handed as `${backup:data}`, composed by the same
|
||||
// function, so a reader that composes from the bus and a holder handed the file read one list.
|
||||
|
||||
// PlacedDataItem is one data item a module on a node declares, as placed on that node.
|
||||
type PlacedDataItem struct {
|
||||
Module string `json:"module"`
|
||||
ID string `json:"item"`
|
||||
Class string `json:"class"`
|
||||
// Path is the item's place on the node; CoveredBy the path whose snapshot keeps it (the item, or the item its dump
|
||||
// writes into), empty when it is not backed up.
|
||||
Path string `json:"path"`
|
||||
CoveredBy string `json:"covered_by,omitempty"`
|
||||
// Protection is backup, redundancy, both or none; Measure walk, dataset or shallow.
|
||||
Protection string `json:"protection,omitempty"`
|
||||
Measure string `json:"measure,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
dataModuleHeader = regexp.MustCompile(`^#\s*([a-z0-9][a-z0-9-]*)$`)
|
||||
dataSafePath = regexp.MustCompile(`^/[^\s'"\\$` + "`" + `]*$`)
|
||||
)
|
||||
|
||||
// ParseDataItems reads the node-backup holder's data text (`${backup:data}`: a `# <module>` line, then one line per item,
|
||||
// `item <id> <class> <path> <covered-by or -> <protection> <measure>`) into each module's items, in order. A line it does
|
||||
// not read is refused, naming the module: a path with a space or a quote is never handed to a measuring shell.
|
||||
func ParseDataItems(text string) (map[string][]PlacedDataItem, error) {
|
||||
out := map[string][]PlacedDataItem{}
|
||||
module := ""
|
||||
for _, raw := range strings.Split(text, "\n") {
|
||||
line := strings.TrimSpace(raw)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if m := dataModuleHeader.FindStringSubmatch(line); m != nil {
|
||||
module = m[1]
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "#") || module == "" {
|
||||
continue
|
||||
}
|
||||
f := strings.Fields(line)
|
||||
if len(f) < 5 || len(f) > 7 || f[0] != "item" || !dataSafePath.MatchString(f[3]) || (f[4] != "-" && !dataSafePath.MatchString(f[4])) {
|
||||
return nil, fmt.Errorf("%s declares a data line that is not read: %s", module, line)
|
||||
}
|
||||
it := PlacedDataItem{Module: module, ID: f[1], Class: f[2], Path: f[3], Measure: MeasureWalk}
|
||||
if len(f) >= 6 {
|
||||
it.Protection = f[5]
|
||||
}
|
||||
if len(f) == 7 {
|
||||
it.Measure = f[6]
|
||||
}
|
||||
if it.Measure != MeasureWalk && it.Measure != MeasureDataset && it.Measure != MeasureShallow {
|
||||
return nil, fmt.Errorf("%s declares a data line that is not read: %s", module, line)
|
||||
}
|
||||
if f[4] != "-" {
|
||||
it.CoveredBy = f[4]
|
||||
}
|
||||
out[module] = append(out[module], it)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DataItems is every data item the resolution's modules declare, placed on its node with the rendering given: the text
|
||||
// the node-backup holder is handed, read. A module left out of the node's declaration keeps its items, as its data is
|
||||
// still on the node (ADR 0262); an item that cannot be placed is said in unplaced and left out.
|
||||
func (r Resolution) DataItems(with Rendering) (items map[string][]PlacedDataItem, unplaced []string, err error) {
|
||||
left := r.LeftOut(with.Settings, with.Adopted)
|
||||
for module, why := range with.NotPlaced {
|
||||
if _, already := left[module]; !already {
|
||||
left[module] = why
|
||||
}
|
||||
}
|
||||
modules := make([]Manifest, 0, len(r.Modules))
|
||||
for _, m := range r.Modules {
|
||||
if _, isLeft := left[m.Module]; isLeft {
|
||||
m = backupView(m)
|
||||
}
|
||||
modules = append(modules, m)
|
||||
}
|
||||
text, unplaced, err := seatContributions(modules, Manifest{}, BackupSeat+":"+BackupKindData, with,
|
||||
machineFacts(r, with.Names, with.MeshRange), r.Capabilities)
|
||||
if err != nil {
|
||||
return nil, unplaced, err
|
||||
}
|
||||
items, err = ParseDataItems(text)
|
||||
return items, unplaced, err
|
||||
}
|
||||
+11
-120
@@ -2,7 +2,6 @@ package catalogue
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -24,111 +23,13 @@ const declaredStore = `{"module":"pg","version":"1",
|
||||
|
||||
func mustParse(t *testing.T, raw string) Manifest {
|
||||
t.Helper()
|
||||
m, err := ParseManifest([]byte(raw))
|
||||
m, err := decodeForTest([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("refused: %v", err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func TestADeclaredStoreParsesAndPassesTheCheck(t *testing.T) {
|
||||
m := mustParse(t, declaredStore)
|
||||
if problems := DataProblems(Shelf{"pg": m}); len(problems) > 0 {
|
||||
t.Fatalf("a store declaring its data was refused: %v", problems)
|
||||
}
|
||||
if !m.KeepsConsumerData("postgres-database") {
|
||||
t.Fatal("an irreplaceable consumer class does not keep the consumer's data")
|
||||
}
|
||||
if it, _ := m.DataItem("store"); !it.BackedUp() || it.BackupWithin() != DefaultBackupWithin || it.ActiveWithin().Hours() != 24 {
|
||||
t.Fatalf("the store item reads %+v", it)
|
||||
}
|
||||
if it, _ := m.DataItem("dumps"); it.BackedUp() {
|
||||
t.Fatal("a rebuildable item that says none is backed up")
|
||||
}
|
||||
}
|
||||
|
||||
// Every rule of the section itself, refused at parse in the words of the module.
|
||||
func TestTheDataSectionIsRefusedWhereItIsWrong(t *testing.T) {
|
||||
for _, c := range []struct{ name, data, want string }{
|
||||
{"a class it does not know", `{"own":[{"id":"a","path":"${dir:d}","class":"precious"}]}`, "not one the mesh protects by"},
|
||||
{"an access it does not have", `{"own":[{"id":"a","path":"${access:media}","class":"irreplaceable","redundancy":"an array"}]}`, "declares no access"},
|
||||
{"irreplaceable and protected by nothing", `{"own":[{"id":"a","path":"${dir:d}","class":"irreplaceable","backup":"none"}]}`, "protected one way or the other"},
|
||||
{"a cache on redundancy", `{"own":[{"id":"a","path":"${dir:d}","class":"cache","redundancy":"an array"}]}`, "a cache is not protected"},
|
||||
{"kept with something not required", `{"kept-by":{"s3-bucket":{"class":"irreplaceable"}}}`, "does not require"},
|
||||
{"a measure it does not know", `{"own":[{"id":"a","path":"${dir:d}","class":"cache","measure":"du"}]}`, "walk, dataset or shallow"},
|
||||
{"a cache expecting writes", `{"own":[{"id":"a","path":"${dir:d}","class":"cache","active":"1d"}]}`, "only irreplaceable and valuable"},
|
||||
{"a machine path", `{"own":[{"id":"a","path":"/srv/a","class":"cache"}]}`, "names no machine path"},
|
||||
{"a directory it does not declare", `{"own":[{"id":"a","path":"${dir:nowhere}","class":"cache"}]}`, "declares no directory"},
|
||||
{"a cache backed up", `{"own":[{"id":"a","path":"${dir:d}","class":"cache","backup":"copy"}]}`, "disposable"},
|
||||
{"a dump into nothing", `{"own":[{"id":"a","path":"${dir:d}","class":"irreplaceable","backup":{"dump":"x","into":"b"}}]}`, "not one of the module's data items"},
|
||||
{"a dump into itself", `{"own":[{"id":"a","path":"${dir:d}","class":"irreplaceable","backup":{"dump":"x","into":"a"}}]}`, "into itself"},
|
||||
{"a bad duration", `{"own":[{"id":"a","path":"${dir:d}","class":"irreplaceable","within":"soon"}]}`, "not a duration"},
|
||||
{"the same id twice", `{"own":[{"id":"a","path":"${dir:d}","class":"cache"},{"id":"a","path":"${dir:d}/x","class":"cache"}]}`, "declared twice"},
|
||||
{"own data of class none", `{"own":[{"id":"a","path":"${dir:d}","class":"none"}]}`, "only a provision"},
|
||||
{"consumers of something not provided", `{"consumers":{"s3-bucket":{"class":"cache"}}}`, "does not provide"},
|
||||
{"consumer data that lives nowhere", `{"consumers":{"q":{"class":"irreplaceable"}}}`, "says nowhere it lives"},
|
||||
{"consumer data in an item less precious", `{"own":[{"id":"a","path":"${dir:d}","class":"rebuildable"}],"consumers":{"q":{"class":"irreplaceable","in":"a"}}}`, "which is only rebuildable"},
|
||||
{"consumer data in something unknown", `{"consumers":{"q":{"class":"rebuildable","in":"elsewhere"}}}`, "neither one of its data items"},
|
||||
} {
|
||||
raw := `{"module":"m","version":"1","provides":["q"],"resources":[{"id":"d","type":"directory","mode":"0700"}],"data":` + c.data + `}`
|
||||
_, err := ParseManifest([]byte(raw))
|
||||
if err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%s: %v, want %q", c.name, err, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The catalogue check: a granting provider says what it keeps, says it in one place, writes no backup
|
||||
// line by hand, and every directory a container writes is declared (novox/hq ADR 0233).
|
||||
func TestTheCatalogueCheckAsksEveryModuleForItsData(t *testing.T) {
|
||||
unsaid := mustParse(t, `{"module":"q","version":"1","provides":[{"name":"queue","scope":"mesh"}],
|
||||
"grants":{"queue":"${dir:g}"},"resources":[{"id":"g","type":"directory","mode":"0700"}]}`)
|
||||
onTheOffer := mustParse(t, `{"module":"r","version":"1","provides":[{"name":"resolver","scope":"mesh","keeps-consumer-data":false}]}`)
|
||||
byHand := mustParse(t, `{"module":"h","version":"1","resources":[{"id":"d","type":"directory","mode":"0700"}],
|
||||
"contributions":[{"seat":"node-backup","kind":"backup","content":"path ${dir:d}"}]}`)
|
||||
writes := mustParse(t, `{"module":"w","version":"1","resources":[{"id":"d","type":"directory","mode":"0700"},
|
||||
{"id":"c","type":"directory","mode":"0700"},
|
||||
{"id":"app","type":"container","image":"x@sha256:aa","volumes":["${dir:d}:/data","${dir:c}:/etc/app:ro","${dir:d}/f:/f"]}]}`)
|
||||
problems := strings.Join(DataProblems(Shelf{"q": unsaid, "r": onTheOffer, "h": byHand, "w": writes}), "\n")
|
||||
for _, want := range []string{
|
||||
"q grants queue and does not say what it keeps",
|
||||
"r says keeps-consumer-data on its offer",
|
||||
"h's contribution 1 is a backup line written by hand",
|
||||
`w mounts its directory "d" into a container to be written`,
|
||||
} {
|
||||
if !strings.Contains(problems, want) {
|
||||
t.Errorf("the check does not say %q:\n%s", want, problems)
|
||||
}
|
||||
}
|
||||
if strings.Contains(problems, `"c"`) {
|
||||
t.Errorf("a read-only mount was taken for written data:\n%s", problems)
|
||||
}
|
||||
// The same module declaring the directory, as a cache, passes.
|
||||
declared := mustParse(t, `{"module":"w","version":"1","data":{"own":[{"id":"d","path":"${dir:d}","class":"cache"}]},
|
||||
"resources":[{"id":"d","type":"directory","mode":"0700"},
|
||||
{"id":"app","type":"container","image":"x@sha256:aa","volumes":["${dir:d}:/data"]}]}`)
|
||||
if p := DataProblems(Shelf{"w": declared}); len(p) > 0 {
|
||||
t.Fatalf("a declared directory is still refused: %v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// Consumer data said to live in a provision the module requires is protected only as well as that
|
||||
// provision's provider protects its consumers' data.
|
||||
func TestConsumerDataInARequiredProvisionIsJudgedAgainstItsProvider(t *testing.T) {
|
||||
idp := mustParse(t, `{"module":"idp","version":"1","requires":["postgres-database"],
|
||||
"provides":[{"name":"oidc-client","scope":"mesh"}],"grants":{"oidc-client":"${dir:g}"},
|
||||
"resources":[{"id":"g","type":"directory","mode":"0700"}],
|
||||
"data":{"consumers":{"oidc-client":{"class":"irreplaceable","in":"postgres-database"}}}}`)
|
||||
cachy := mustParse(t, strings.Replace(declaredStore, `"consumers":{"postgres-database":{"class":"irreplaceable","in":"store"}}`,
|
||||
`"consumers":{"postgres-database":{"class":"cache"}}`, 1))
|
||||
if p := strings.Join(DataProblems(Shelf{"idp": idp, "pg": cachy}), "\n"); !strings.Contains(p, "not protected as irreplaceable") {
|
||||
t.Fatalf("irreplaceable data kept in a cache passed: %s", p)
|
||||
}
|
||||
if p := DataProblems(Shelf{"idp": idp, "pg": mustParse(t, declaredStore)}); len(p) > 0 {
|
||||
t.Fatalf("refused against a provider that keeps its consumers' data: %v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// What a binding is sticky by follows the class (ADR 0232, generalised by 0233): a cache's consumers
|
||||
// move freely, a store's do not; an older definition saying nothing still follows its grant.
|
||||
func TestKeepingConsumerDataFollowsTheClass(t *testing.T) {
|
||||
@@ -192,6 +93,16 @@ func TestTheBackupHoldersLinesAreDerivedFromTheData(t *testing.T) {
|
||||
if data != want {
|
||||
t.Fatalf("data lines:\n%s\nwant:\n%s", data, want)
|
||||
}
|
||||
// ADR 0571 clause 3: the same text, read into items, as disk-load and the backup holder read it; a path a shell
|
||||
// would split is refused.
|
||||
items, err := ParseDataItems(data)
|
||||
if err != nil || len(items) != 3 || items["media"][0] != (PlacedDataItem{Module: "media", ID: "films", Class: "irreplaceable",
|
||||
Path: "/tank/films", Protection: "redundancy", Measure: MeasureDataset}) || items["pg"][0].CoveredBy != "/srv/store/dumps" {
|
||||
t.Fatalf("the data lines read as %+v: %v", items, err)
|
||||
}
|
||||
if _, err := ParseDataItems("# a\nitem x cache /a b - none walk\n"); err == nil {
|
||||
t.Fatal("a path with a space was read")
|
||||
}
|
||||
if _, _, err := seatContributions([]Manifest{agent}, Manifest{}, BackupSeat+":"+BackupKindData, Rendering{}, nil, nil); err == nil {
|
||||
t.Fatal("a home directory with no account on the machine reached the holder as a placeholder")
|
||||
}
|
||||
@@ -205,23 +116,3 @@ func TestTheBackupHoldersLinesAreDerivedFromTheData(t *testing.T) {
|
||||
t.Fatalf("an operator's path reads %+v", it)
|
||||
}
|
||||
}
|
||||
|
||||
// Data a consumer keeps with a provider as irreplaceable must be protected there: the check refuses a
|
||||
// provider that keeps its consumers' data as a cache, or in an item with no protection.
|
||||
func TestIrreplaceableDataKeptWithAProviderMustBeProtectedThere(t *testing.T) {
|
||||
photos := mustParse(t, `{"module":"photos","version":"1","requires":["s3-bucket"],
|
||||
"data":{"kept-by":{"s3-bucket":{"class":"irreplaceable"}}}}`)
|
||||
store := func(backup string) Manifest {
|
||||
return mustParse(t, `{"module":"objects","version":"1","provides":[{"name":"s3-bucket","scope":"mesh"}],
|
||||
"grants":{"s3-bucket":"${dir:g}"},
|
||||
"data":{"own":[{"id":"data","path":"${dir:data}","class":"valuable","backup":"`+backup+`"}],
|
||||
"consumers":{"s3-bucket":{"class":"valuable","in":"data"}}},
|
||||
"resources":[{"id":"g","type":"directory","mode":"0700"},{"id":"data","type":"directory","mode":"0700"}]}`)
|
||||
}
|
||||
if p := DataProblems(Shelf{"photos": photos, "objects": store("copy")}); len(p) > 0 {
|
||||
t.Fatalf("a provider backing its consumers' data up was refused: %v", p)
|
||||
}
|
||||
if p := strings.Join(DataProblems(Shelf{"photos": photos, "objects": store("none")}), "\n"); !strings.Contains(p, "neither backed up") {
|
||||
t.Fatalf("irreplaceable photos in an unprotected store passed: %s", p)
|
||||
}
|
||||
}
|
||||
|
||||
+127
-28
@@ -221,6 +221,11 @@ type Rendering struct {
|
||||
// They win over anything the mesh would assign and over a manifest's own long-form mapping.
|
||||
Given map[string]map[int]int
|
||||
|
||||
// Planned says this rendering composes the plans the seat mesh-provisioner keeps (compose.Planning). A contribution a
|
||||
// plan carries is then refused where its port is none the module listens on: no machine port stands for it, and its
|
||||
// provider dials what the plan says (issue 774, research 072 row 34).
|
||||
Planned bool
|
||||
|
||||
// Taken is the modules taken on this adopted node (novox/hq ADR 0100). The guard is derived
|
||||
// from these only (ADR 0103): a port of a module assigned but not taken may still be the
|
||||
// predecessor's.
|
||||
@@ -277,6 +282,10 @@ type Rendering struct {
|
||||
// templated files are handed to the client (handed.go); without either, it composes as before.
|
||||
Hands bool
|
||||
Provisioner string
|
||||
// Assigned is, by module, whether the vault judged it handed (AssignmentHands of the plan in its assignment), as the
|
||||
// vault answered the node composing itself; where it names a module, Handing follows it alone (issue 774). Nil, or a
|
||||
// module it does not name, is judged by the manifest and the machine, as the composer of the plans does.
|
||||
Assigned map[string]bool
|
||||
}
|
||||
|
||||
// RootField is a user resource's field saying the account must never become root without a person
|
||||
@@ -700,13 +709,7 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
handsSecrets := with.Handing(m)
|
||||
for _, name := range sortedKeys(m.OwnSecrets.Plain()) {
|
||||
if handsSecrets {
|
||||
owner := m.SecretsOwner
|
||||
if m.Module == RuntimeModule && r.Account != "" {
|
||||
owner = r.Account
|
||||
}
|
||||
if o := m.OwnSecrets[name].Owner; o != "" {
|
||||
owner = o
|
||||
}
|
||||
owner := r.ownSecretOwner(m, name)
|
||||
first = append(first, ownedBy(owner, map[string]any{
|
||||
"id": NeedID(name), "type": "file", "path": m.OwnSecrets[name].Path, HandedSecretField: name,
|
||||
}))
|
||||
@@ -718,13 +721,7 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
if string(box) == string(ForeseenVaultBox) {
|
||||
*foreseen = append(*foreseen, m.Module+"/"+name)
|
||||
}
|
||||
owner := m.SecretsOwner
|
||||
if m.Module == RuntimeModule && r.Account != "" {
|
||||
owner = r.Account
|
||||
}
|
||||
if o := m.OwnSecrets[name].Owner; o != "" {
|
||||
owner = o
|
||||
}
|
||||
owner := r.ownSecretOwner(m, name)
|
||||
first = append(first, ownedBy(owner, map[string]any{
|
||||
"id": NeedID(name), "type": "file", "path": m.OwnSecrets[name].Path, VaultSealedField: box,
|
||||
}))
|
||||
@@ -754,14 +751,7 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
// the manifest, because a manifest cannot say ${machine:account} safely — a node with no
|
||||
// account has nothing to resolve it to, and then the runtime runs as root and the file
|
||||
// stays root's.
|
||||
owner := m.SecretsOwner
|
||||
if m.Module == RuntimeModule && r.Account != "" {
|
||||
owner = r.Account
|
||||
}
|
||||
if o := m.OwnSecrets[name].Owner; o != "" {
|
||||
owner = o
|
||||
}
|
||||
first = append(first, ownedBy(owner, map[string]any{
|
||||
first = append(first, ownedBy(r.ownSecretOwner(m, name), map[string]any{
|
||||
"id": NeedID(name), "type": "file", "path": m.OwnSecrets[name].Path, "sealed": sealed,
|
||||
}))
|
||||
}
|
||||
@@ -856,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.
|
||||
@@ -1084,15 +1079,22 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// **What the mesh calls the module through each provision it requires** (issue 792): the names its binding
|
||||
// file carries, composed by the same call, handed to the provider through the plan's entry.
|
||||
if _, ok := handed.plans[m.Module]; ok && handing != nil {
|
||||
// **A plan for what the seat keys of it all the same** (catalogue.Keyed, issue 774): a module not handed whose
|
||||
// provider's helper answers a provision of it, its route the commonest, has that provision in a plan of its own,
|
||||
// so the seat keys it and the helper hears it.
|
||||
keyedOnly := handing == nil && handed.keyed(r.Node, m, r.Needs)
|
||||
if _, ok := handed.plans[m.Module]; ok && (handing != nil || keyedOnly) {
|
||||
for _, to := range requirementsContributedTo(m) {
|
||||
own, err := r.ownNames(m, to, with.Settings[m.Module])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
handed.name(m.Module, to, own)
|
||||
contributed, err := r.contributedOf(m, to, with)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
handed.contribute(m.Module, to, contributed)
|
||||
}
|
||||
}
|
||||
// A handed collector is filled from items alone: each old form its collections would have held is said
|
||||
@@ -1103,6 +1105,9 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
}
|
||||
for _, unsettled := range resources {
|
||||
if pr, ok := unsettled[HandedProvisionField].(PlanProvision); ok {
|
||||
if handing == nil && m.Stubbed {
|
||||
continue // held: its credential file is what runs of it, left out (issue 1126)
|
||||
}
|
||||
if handing == nil {
|
||||
return nil, fmt.Errorf("%s: its credential of %s is handed to the provisioner and the module is "+
|
||||
"not: nothing would fill it", m.Module, pr.Provision)
|
||||
@@ -1127,6 +1132,9 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
continue
|
||||
}
|
||||
if unsettled[HandedSecretField] != nil {
|
||||
if handing == nil && m.Stubbed {
|
||||
continue // held: its own secret's file is what runs of it, left out (issue 1126)
|
||||
}
|
||||
if handing == nil {
|
||||
return nil, fmt.Errorf("%s: its own secret %v is handed to the provisioner and the module is "+
|
||||
"not: nothing would fill it", m.Module, unsettled[HandedSecretField])
|
||||
@@ -1362,8 +1370,10 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
owner[fmt.Sprint(process["id"])] = RuntimeModule
|
||||
out = append(out, process)
|
||||
if process != nil { // none for a held runtime's stand-in: what runs of it is carried
|
||||
owner[fmt.Sprint(process["id"])] = RuntimeModule
|
||||
out = append(out, process)
|
||||
}
|
||||
// And a runtime of its own for each module of its own account, after it (novox/hq ADR 0259 §8).
|
||||
owns, err := r.ownRuntimes(with)
|
||||
if err != nil {
|
||||
@@ -1388,7 +1398,7 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
words[m.Module] = w
|
||||
}
|
||||
// And a file a module's words name is one the runtime is restarted for when it changes.
|
||||
if named := givenTo(out, owner, words, r.Account); len(named) > 0 {
|
||||
if named := givenTo(out, owner, words, r.Account); len(named) > 0 && process != nil {
|
||||
restarts, _ := process["restart-on"].([]any)
|
||||
seen := map[string]bool{}
|
||||
for _, id := range restarts {
|
||||
@@ -1671,6 +1681,19 @@ func (r Resolution) provisionsAs(m Manifest) string {
|
||||
return m.SecretsOwner
|
||||
}
|
||||
|
||||
// ownSecretOwner is who one of a module's own secret files belongs to: the account the manifest names for it, else the
|
||||
// runtime's account for the runtime's own credential and for a tools bundle's bus account (ToolsBusSecret; novox/hq ADR
|
||||
// 0564 Limits 4), which the runtime runs that bundle as, else the module's secrets-owner (root when none).
|
||||
func (r Resolution) ownSecretOwner(m Manifest, name string) string {
|
||||
if o := m.OwnSecrets[name].Owner; o != "" {
|
||||
return o
|
||||
}
|
||||
if r.Account != "" && (m.Module == RuntimeModule || ToolsBusSecret(m, name)) {
|
||||
return r.Account
|
||||
}
|
||||
return m.SecretsOwner
|
||||
}
|
||||
|
||||
// grantPath is where one consumer's sealed credential lands on the providing machine.
|
||||
//
|
||||
// Suffixed, so the directory can also hold whatever the module writing it keeps there and so a
|
||||
@@ -1819,6 +1842,82 @@ func (r Resolution) contributions(settings SettingsBy, grants []Grant,
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// contributedOf is each contribution m makes to one requirement, composed as its provider receives it in a received
|
||||
// file (contributions: the one of `contributes` with what the module shares of its bucket, then each of several by
|
||||
// local name), with where m's machine is, encoded for PlanProvision.Contributed (issue 774): one composition, so a
|
||||
// plan and a file cannot say different things. Nil where m contributes nothing to it.
|
||||
//
|
||||
// **Its port is the one the machine published** (atAssignedPort): a plan is read on whatever machine its provider runs,
|
||||
// which cannot redirect the number as a received file on this machine is redirected (atMachinePort), so the plan
|
||||
// carries the machine port already. route-proxy dialled gitea's 3000 for git (published at 20000) and grafana's 3000
|
||||
// on another machine (published at 20012) until it did (2026-10-31).
|
||||
func (r Resolution) contributedOf(m Manifest, to string, with Rendering) (json.RawMessage, error) {
|
||||
layers := with.Settings[m.Module]
|
||||
form := ContributedForm{At: r.At}
|
||||
add := func(local string, values map[string]any) error {
|
||||
values, err := r.atAssignedPort(m, to, values, with)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := json.Marshal(values)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s contributing to %s: %w", m.Module, to, err)
|
||||
}
|
||||
form.Each = append(form.Each, ContributedOne{Local: local, Values: raw})
|
||||
return nil
|
||||
}
|
||||
if raw, ok := m.Contributes[to]; ok {
|
||||
values, err := r.composed(m, to, raw, layers, m.Module+" contributing to "+to)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if values, err = withShares(m, to, values); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := add("", values); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, local := range sortedKeys(m.ContributesMany[to]) {
|
||||
values, err := r.composed(m, to, m.ContributesMany[to][local], layers, m.Module+" contributing "+local+" to "+to)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := add(local, values); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if len(form.Each) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return EncodeContributed(form)
|
||||
}
|
||||
|
||||
// atAssignedPort is a contribution of m with its port where this machine publishes it for m: the number
|
||||
// `${port:<software port>}` gives m itself and its declaration publishes (machinePort, port_into.go), the placement's
|
||||
// assignment where it holds one and the software's own number where it holds none (a bare port not yet moved, a fixed
|
||||
// mapping, the host's network). What its provider dials, on this machine over loopback and from another at m's address.
|
||||
//
|
||||
// **Refused where a plan carries a port m does not listen on** (with.Planned): no machine port stands for it, so the
|
||||
// provider would dial a number nothing on the machine is known to publish. Elsewhere (a declaration, a composition
|
||||
// with no ports) such a port is left as it is, as machinePort leaves it.
|
||||
func (r Resolution) atAssignedPort(m Manifest, to string, values map[string]any, with Rendering) (map[string]any, error) {
|
||||
number, ok := asPort(values["port"])
|
||||
if !ok {
|
||||
return values, nil
|
||||
}
|
||||
_, given := with.Given[m.Module][number]
|
||||
_, placed := with.Ports[m.Module][number]
|
||||
if !given && !placed {
|
||||
if with.Planned {
|
||||
return nil, fmt.Errorf("%s contributing to %s: its port %d on %s is none of the ports it listens on, so no "+
|
||||
"machine port stands for it", m.Module, to, number, r.Node)
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
return atMachinePort(values, m.Module, map[string]map[int]int{m.Module: {number: with.machinePort(m.Module, number)}}), nil
|
||||
}
|
||||
|
||||
// composed is one contribution as its provider receives it: settled with this node's settings, its
|
||||
// endpoint's port filled in, and its names composed from the label.
|
||||
//
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Decode reads a manifest leniently, as every part but the judge reads one (novox/hq ADR 0578 §3): every field the
|
||||
// contract knows as it declares it, and a field it does not know set aside and named (SetAside), never refused. It
|
||||
// judges nothing: no name, seat, bus or catalogue rule is applied, since the indexer judged the manifest once and its
|
||||
// verdict is in the indexer's state (delivery.VerdictsBucket); a reader takes the verdict beside it
|
||||
// (delivery.TakeVerdict). An error is only bytes that are no manifest: not JSON, or a field of the wrong shape.
|
||||
func Decode(raw []byte) (Manifest, error) {
|
||||
var m Manifest
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return Manifest{}, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// SetAside names what Decode set aside of this manifest: each field it does not know, as the decoder words the first
|
||||
// and with the path of every one it read without, or "" when it knew every field. A reader says it in its own words
|
||||
// (its journal, its finding) and reads the rest; the part that needs the field takes the newer package (ADR 0578 §6).
|
||||
func (m Manifest) SetAside() string { return m.unknown }
|
||||
|
||||
// ReadNoFurther is whether Decode could not read past a field it does not know, so the manifest holds its name and
|
||||
// version alone: a reader holds such a module, as it holds a refused one, and names why (SetAside).
|
||||
func (m Manifest) ReadNoFurther() bool { return strings.Contains(m.unknown, readNoFurther) }
|
||||
|
||||
// readNoFurther marks, in what a manifest set aside, that it was not read past it.
|
||||
const readNoFurther = "(read no further: "
|
||||
|
||||
// Predefined is the list of predefined keys the manifest was read with (ReadTreeManifestWritten, or the judge's
|
||||
// SetPredefined), nil when none: data of the contract a judge reads, never a rule.
|
||||
func (m Manifest) Predefined() PredefinedList { return m.predefined }
|
||||
|
||||
// SetPredefined keeps the list of predefined keys a manifest is read with, for what reads it after (the judge).
|
||||
func (m *Manifest) SetPredefined(list PredefinedList) { m.predefined = list }
|
||||
|
||||
// FormsWritten is the fields the manifest's config was written with, in the config form (`config`, `takes-config`,
|
||||
// `${config:…}`) and in the settings form (novox/hq ADR 0348 §1): data a judge reads to say a manifest mixes them.
|
||||
func (m Manifest) FormsWritten() (config, settings []string) { return m.forms.config, m.forms.settings }
|
||||
@@ -0,0 +1,12 @@
|
||||
package catalogue
|
||||
|
||||
// decodeForTest builds a manifest for a test as every reader reads one (Decode); the judge's own tests are in
|
||||
// mesh-delivery's judge (novox/hq ADR 0578 §4).
|
||||
func decodeForTest(raw []byte) (Manifest, error) { return Decode(raw) }
|
||||
|
||||
// decodeWithForTest is decodeForTest keeping the list of predefined keys it was read with.
|
||||
func decodeWithForTest(raw []byte, list PredefinedList) (Manifest, error) {
|
||||
m, err := Decode(raw)
|
||||
m.SetPredefined(list)
|
||||
return m, err
|
||||
}
|
||||
@@ -193,32 +193,6 @@ func TestASettingComposesWithADerivedValue(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A fact or an alphabet the mesh does not have is refused where the definition is, not where a
|
||||
// consumer happens to be resolved — and the refusal says what may be said instead.
|
||||
func TestAServedValueNamingSomethingTheMeshDoesNotHaveIsRefused(t *testing.T) {
|
||||
for _, c := range []struct{ value, says string }{
|
||||
{"${consumer:node}", "as"},
|
||||
{"${consumer:as:punycode}", "dns"},
|
||||
} {
|
||||
m := store()
|
||||
m.Serves["s3-bucket"]["bucket"] = c.value
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = ParseManifest(raw)
|
||||
if err == nil {
|
||||
t.Fatalf("%s was accepted", c.value)
|
||||
}
|
||||
if !strings.Contains(err.Error(), c.value) {
|
||||
t.Errorf("the refusal of %s does not quote it: %v", c.value, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), c.says) {
|
||||
t.Errorf("the refusal of %s does not say what may be said (%q): %v", c.value, c.says, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// `dns` is checked against an identity the mesh actually mints, not an invented string.
|
||||
func TestTheDNSAlphabetIsTheMintedIdentityWithItsSeparatorRewritten(t *testing.T) {
|
||||
as := ConsumerIdentity("anchor", IdentitySource("ncloud", "nextcloud"))
|
||||
|
||||
@@ -106,14 +106,6 @@ func dirsFor(m Manifest, with Rendering) map[string]string {
|
||||
return dirs
|
||||
}
|
||||
|
||||
// placedOrAbsolute says a path is usable where the mesh needs one: absolute already, or
|
||||
// beginning with a placed reference — resolution makes it absolute before anything reads it.
|
||||
// (unknownDirRefs is what checks the reference names a real directory.)
|
||||
func placedOrAbsolute(path string) bool {
|
||||
return strings.HasPrefix(path, "/") ||
|
||||
(strings.HasPrefix(path, "${dir:") && dirRef.MatchString(path))
|
||||
}
|
||||
|
||||
// dirFill resolves every ${dir:…} in one string, or refuses a reference naming no directory.
|
||||
func dirFill(s string, dirs map[string]string, module string) (string, error) {
|
||||
var missing error
|
||||
@@ -260,109 +252,6 @@ func dirInto(resource map[string]any, dirs map[string]string, module string) err
|
||||
return nil
|
||||
}
|
||||
|
||||
// unknownDirRefs is every ${dir:…} in the definition that names no directory the definition
|
||||
// declares — refused where the author is, not at composition on some later day (the same
|
||||
// near-versus-far reasoning as the host's strict parse).
|
||||
func (m Manifest) unknownDirRefs() []string {
|
||||
declared := map[string]bool{}
|
||||
for _, r := range m.Resources {
|
||||
if fmt.Sprint(r["type"]) == "directory" {
|
||||
declared[fmt.Sprint(r["id"])] = true
|
||||
}
|
||||
}
|
||||
referenced := func(s string) []string {
|
||||
var ids []string
|
||||
for _, match := range dirRef.FindAllStringSubmatch(s, -1) {
|
||||
ids = append(ids, match[1])
|
||||
}
|
||||
return ids
|
||||
}
|
||||
var problems []string
|
||||
for _, r := range m.Resources {
|
||||
place, said := r["place"].(string)
|
||||
if !said {
|
||||
continue
|
||||
}
|
||||
if fmt.Sprint(r["type"]) != "directory" {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s says place on %v, which is not a directory — only a directory is placed",
|
||||
m.Module, r["id"]))
|
||||
continue
|
||||
}
|
||||
if path, stated := r["path"].(string); stated && path != "" {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s states both path and place on %v — a stated path IS the placement",
|
||||
m.Module, r["id"]))
|
||||
}
|
||||
if place != placeOwn && place != placeMesh {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s says place %q on %v, and the places are %q — the assignment's own root — and "+
|
||||
"%q — where the mesh keeps what it writes for the module",
|
||||
m.Module, place, r["id"], placeOwn, placeMesh))
|
||||
}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
refuse := func(id string, where any) {
|
||||
if declared[id] || seen[id] {
|
||||
return
|
||||
}
|
||||
seen[id] = true
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s says ${dir:%s} in %v, and declares no directory %q — a reference the mesh "+
|
||||
"cannot place would reach the machine as a literal path",
|
||||
m.Module, id, where, id))
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
for _, field := range []string{"path", "content"} {
|
||||
if s, ok := r[field].(string); ok {
|
||||
for _, id := range referenced(s) {
|
||||
refuse(id, r["id"])
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, field := range []string{"volumes", "env-file"} {
|
||||
if list, ok := r[field].([]any); ok {
|
||||
for _, v := range list {
|
||||
if s, ok := v.(string); ok {
|
||||
for _, id := range referenced(s) {
|
||||
refuse(id, r["id"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if env, ok := r["env"].(map[string]any); ok {
|
||||
for _, v := range env {
|
||||
if s, ok := v.(string); ok {
|
||||
for _, id := range referenced(s) {
|
||||
refuse(id, r["id"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
maps := map[string]map[string]string{
|
||||
"receives": m.Receives, "binds": m.Binds, "secrets": m.Secrets,
|
||||
"own-secrets": m.OwnSecrets.Paths(), "grants": m.Grants,
|
||||
}
|
||||
for field, entries := range maps {
|
||||
for _, value := range entries {
|
||||
for _, id := range referenced(value) {
|
||||
refuse(id, field)
|
||||
}
|
||||
}
|
||||
}
|
||||
for to, locals := range m.SecretsMany {
|
||||
for _, value := range locals {
|
||||
for _, id := range referenced(value) {
|
||||
refuse(id, "secrets."+to)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(problems)
|
||||
return problems
|
||||
}
|
||||
|
||||
func namesOfDirs(dirs map[string]string) []string {
|
||||
var names []string
|
||||
for id := range dirs {
|
||||
|
||||
@@ -110,18 +110,6 @@ func TestFillingForOneNodeLeaksIntoNoOther(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAReferenceToNoDirectoryRefusesAtTheManifest(t *testing.T) {
|
||||
m := placedModule()
|
||||
m.Resources[2]["volumes"] = []any{"${dir:date}:/data"} // a typo, the likely shape
|
||||
problems := m.unknownDirRefs()
|
||||
if len(problems) != 1 || !strings.Contains(problems[0], `${dir:date}`) {
|
||||
t.Fatalf("a reference naming no directory is a manifest problem; got %v", problems)
|
||||
}
|
||||
if got := placedModule().unknownDirRefs(); len(got) != 0 {
|
||||
t.Fatalf("a correct definition has none; got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAReferenceToNoDirectoryRefusesAtCompositionToo(t *testing.T) {
|
||||
m := placedModule()
|
||||
container := shallowCopy(m.Resources[2])
|
||||
@@ -188,39 +176,6 @@ func TestTheManifestsMapsArePlaced(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMapReferenceToNoDirectoryRefusesAtTheManifest(t *testing.T) {
|
||||
m := Manifest{
|
||||
Module: "photos",
|
||||
Resources: []map[string]any{{"id": "state", "type": "directory", "place": "."}},
|
||||
Binds: map[string]string{"route": "${dir:stat}/route.json"},
|
||||
}
|
||||
problems := m.unknownDirRefs()
|
||||
if len(problems) != 1 || !strings.Contains(problems[0], `${dir:stat}`) {
|
||||
t.Fatalf("a map naming no directory is a manifest problem; got %v", problems)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaceIsValidatedAtTheManifest(t *testing.T) {
|
||||
both := Manifest{Module: "x", Resources: []map[string]any{
|
||||
{"id": "d", "type": "directory", "place": ".", "path": "/somewhere"},
|
||||
}}
|
||||
if got := both.unknownDirRefs(); len(got) != 1 || !strings.Contains(got[0], "both path and place") {
|
||||
t.Fatalf("path beside place refuses; got %v", got)
|
||||
}
|
||||
elsewhere := Manifest{Module: "x", Resources: []map[string]any{
|
||||
{"id": "f", "type": "file", "place": ".", "path": "/somewhere", "content": ""},
|
||||
}}
|
||||
if got := elsewhere.unknownDirRefs(); len(got) != 1 || !strings.Contains(got[0], "not a directory") {
|
||||
t.Fatalf("place on a file refuses; got %v", got)
|
||||
}
|
||||
wrong := Manifest{Module: "x", Resources: []map[string]any{
|
||||
{"id": "d", "type": "directory", "place": "sub/dir"},
|
||||
}}
|
||||
if got := wrong.unknownDirRefs(); len(got) != 1 || !strings.Contains(got[0], `the places are "."`) {
|
||||
t.Fatalf("a place that is neither root refuses; got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheMeshsDirectoryForAModuleIsAPlace(t *testing.T) {
|
||||
// novox/hq issue 174. What the mesh writes for a module — its bus credential, its bindings —
|
||||
// is the mesh's plumbing under <root>/mesh/<module>, and the definition names it by id.
|
||||
@@ -234,9 +189,6 @@ func TestTheMeshsDirectoryForAModuleIsAPlace(t *testing.T) {
|
||||
OwnSecrets: OwnSecrets{"broker": {Path: "${dir:mesh-state}/broker"}},
|
||||
Binds: map[string]string{"route": "${dir:state}/route.json"},
|
||||
}
|
||||
if got := m.unknownDirRefs(); len(got) != 0 {
|
||||
t.Fatalf("place %q is a place; got %v", "mesh", got)
|
||||
}
|
||||
dirs := dirsFor(m, Rendering{})
|
||||
if dirs["mesh-state"] != "/var/lib/mesh/umami" || dirs["state"] != "/var/lib/umami" {
|
||||
t.Fatalf("the mesh's directory sits beside the module's, not in it; got %v", dirs)
|
||||
@@ -301,9 +253,6 @@ func TestADirectoryBeneathAPlacedOneIsPlacedWithIt(t *testing.T) {
|
||||
{"id": "server", "type": "container", "image": "x@sha256:aa",
|
||||
"volumes": []any{"${dir:runtime-state}:/data"}},
|
||||
}}
|
||||
if got := m.unknownDirRefs(); len(got) != 0 {
|
||||
t.Fatalf("a path beneath a placed directory is well formed; got %v", got)
|
||||
}
|
||||
dirs := dirsFor(m, Rendering{DataRoot: "/srv"})
|
||||
if dirs["runtime-state"] != "/srv/mesh/gitea/state" {
|
||||
t.Fatalf("the subdirectory follows the placed one; got %v", dirs)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -100,52 +99,6 @@ func TestTwoEndpointsOfDifferentShapesAreConfiguredSeparately(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A route naming an endpoint the module does not declare reaches nothing, and is refused where it is
|
||||
// written rather than resolving to no port and serving nothing.
|
||||
func TestARouteNamingAnEndpointTheModuleLacksIsRefused(t *testing.T) {
|
||||
m := aMediaServer()
|
||||
m.Contributes["route"][RouteEndpoint] = "absent"
|
||||
got := strings.Join(RouteProblems(m), "\n")
|
||||
if !strings.Contains(got, "does not declare") {
|
||||
t.Fatalf("a route naming an absent endpoint was accepted:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// **Two endpoints called the same would make an assignment configure whichever was read last.** The
|
||||
// point of a name is that it identifies one thing.
|
||||
func TestTwoEndpointsWithOneNameAreRefused(t *testing.T) {
|
||||
m := Manifest{Module: "twice", Listens: []Listening{
|
||||
{Name: "web", Port: 80, From: FromMesh},
|
||||
{Name: "web", Port: 8080, From: FromMesh},
|
||||
}}
|
||||
got := strings.Join(endpointNameProblems(m), "\n")
|
||||
if !strings.Contains(got, "could mean either") {
|
||||
t.Fatalf("two endpoints with one name were accepted:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A name that is not a name is refused where it is written: it ends up in something a person types.
|
||||
func TestAnEndpointNameIsHeldToItsShape(t *testing.T) {
|
||||
for _, wrong := range []string{"Web", "web port", "3000", "-web", "web_surface"} {
|
||||
m := Manifest{Module: "odd", Listens: []Listening{{Name: wrong, Port: 80, From: FromMesh}}}
|
||||
if got := strings.Join(endpointNameProblems(m), "\n"); !strings.Contains(got, "a name is lowercase") {
|
||||
t.Fatalf("%q was accepted as an endpoint name:\n%s", wrong, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// **Every endpoint in the catalogue is unnamed today, and must stay valid.** The word ships one
|
||||
// release before anything uses it.
|
||||
func TestAnUnnamedEndpointIsStillValid(t *testing.T) {
|
||||
m := Manifest{Module: "ordinary", Listens: []Listening{{Port: 443, From: FromEverywhere}}}
|
||||
if got := endpointNameProblems(m); len(got) != 0 {
|
||||
t.Fatalf("an unnamed endpoint was refused: %v", got)
|
||||
}
|
||||
if got := RouteProblems(m); len(got) != 0 {
|
||||
t.Fatalf("a module with no route was refused: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// **A route that names an endpoint still carries that endpoint's port.**
|
||||
//
|
||||
// Everything downstream reads the port: the provider is told where to reach the consumer, and the
|
||||
|
||||
@@ -1,71 +1 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/internal/beside"
|
||||
)
|
||||
|
||||
// The controller refuses a definition's resolved path by the node-engine's own rules, no more (novox/hq issue
|
||||
// 496): the same lists, read here from mesh-host's internal/apply/placement_guard.go — in a merge check the clone
|
||||
// beside it at the commit the mesh runs, elsewhere the copy captured in testdata/beside. When the engine's lists
|
||||
// move, this fails until the controller's move with them, so the gate never refuses what the engine applies nor
|
||||
// passes what it refuses by path alone.
|
||||
func TestTheResolvedPathRulesAreTheNodeEnginesOwn(t *testing.T) {
|
||||
file := filepath.Join(beside.Dir(t, "mesh-host"), "internal", "apply", "placement_guard.go")
|
||||
parsed, err := parser.ParseFile(token.NewFileSet(), file, nil, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("the node-engine's guard does not parse: %v", err)
|
||||
}
|
||||
lists := map[string][]string{}
|
||||
consts := map[string]string{}
|
||||
ast.Inspect(parsed, func(n ast.Node) bool {
|
||||
spec, ok := n.(*ast.ValueSpec)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
for i, name := range spec.Names {
|
||||
if i >= len(spec.Values) {
|
||||
continue
|
||||
}
|
||||
switch v := spec.Values[i].(type) {
|
||||
case *ast.CompositeLit:
|
||||
for _, elt := range v.Elts {
|
||||
if lit, ok := elt.(*ast.BasicLit); ok && lit.Kind == token.STRING {
|
||||
s, _ := strconv.Unquote(lit.Value)
|
||||
lists[name.Name] = append(lists[name.Name], s)
|
||||
}
|
||||
}
|
||||
case *ast.BasicLit:
|
||||
if v.Kind == token.STRING {
|
||||
consts[name.Name], _ = strconv.Unquote(v.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
for name, ours := range map[string][]string{
|
||||
"protectedRoots": protectedRoots, "forbiddenBelow": forbiddenBelow, "engineTrees": engineTrees,
|
||||
} {
|
||||
theirs := lists[name]
|
||||
if len(theirs) == 0 {
|
||||
t.Errorf("the node-engine's guard names no %s any more; read it and say where its rule went", name)
|
||||
continue
|
||||
}
|
||||
a, b := slices.Clone(ours), slices.Clone(theirs)
|
||||
slices.Sort(a)
|
||||
slices.Sort(b)
|
||||
if !slices.Equal(a, b) {
|
||||
t.Errorf("%s differs from the node-engine's:\n controller %v\n node-engine %v", name, a, b)
|
||||
}
|
||||
}
|
||||
if consts["engineModule"] != engineModule {
|
||||
t.Errorf("the node-engine's own module is %q there and %q here", consts["engineModule"], engineModule)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,32 @@ func EngineVerbs() []Verb {
|
||||
"each hand-over, access act and setuid search, open, done or refused, with who asked, the ask and the " +
|
||||
"operator's warrant. Read-only.",
|
||||
Input: schema(map[string]string{"limit": "at most this many, newest", "id": "one act, by its id"}, nil)},
|
||||
// The packages this node's compositions declare (novox/hq ADR 0565 §6, §15): optional until every engine serves it.
|
||||
{Name: "packages", Optional: true, Description: "The packages this machine's compositions declare, present or absent, " +
|
||||
"each with the modules that declare it, as the engine keeps them in its key of `packages`; until the controller " +
|
||||
"hands its lease away, beside the controller's mirror for this machine and their difference. With write, the " +
|
||||
"key written again from what the engine composed.",
|
||||
Input: schema(map[string]string{"write": "\"true\": write this machine's key again from what the engine composed"}, nil, "write")},
|
||||
// Read-only (issue 774): why a module waits for a yes, or what its next apply would change. Optional until every
|
||||
// engine serves it.
|
||||
{Name: "composition", Optional: true, Description: "One module's composition on this machine beside what runs of " +
|
||||
"it, read-only: the composition its want's build has in the bucket composed-<node> (the latest revision, or the " +
|
||||
"one whose digest is given, from the revisions the bucket keeps), the digest of each kept revision, what runs " +
|
||||
"(applied, its have, or the kept declaration), its want and have (phase running, failed, held or waiting-yes, " +
|
||||
"and the digest that waits for a yes), and the move's judgement with its words: why a record module waits for " +
|
||||
"a yes. With diff, every resource added, removed or changed, a provider's received file naming the consumers " +
|
||||
"that join or leave.",
|
||||
Input: schema(map[string]string{"module": "the module, as placed on this machine; node for the node's own",
|
||||
"digest": "a composition's digest (sha256:…), to read that revision rather than the latest",
|
||||
"diff": "\"true\": list the resources that differ from what runs"}, []string{"module"})},
|
||||
// The recovery of a composition that failed (issue 774, 2026-10-31: ace kept route-proxy failed for a vault that
|
||||
// answered again a minute later). Optional until every engine serves it.
|
||||
{Name: "compose", Optional: true, Description: "Compose this machine's modules now and say how it went, so a " +
|
||||
"composition that failed, or a module held, recovers without a restart: what the pass refused or held, each " +
|
||||
"with why, and when the engine composes again on its own after a seat that did not answer (a deadline, no " +
|
||||
"responders: retried with a growing wait; a refusal never). With module, that module's apply is also tried " +
|
||||
"again. Changes only what a pass the engine makes on its own would change.",
|
||||
Input: schema(map[string]string{"module": "a module placed on this machine whose apply is also tried again (optional)"}, nil)},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"})},
|
||||
}
|
||||
}
|
||||
@@ -114,64 +114,6 @@ var (
|
||||
ofContributed = regexp.MustCompile(`\$\{(shell|contribution|backup):([^}]*)\}`)
|
||||
)
|
||||
|
||||
// variableName is a POSIX shell variable name, which is also what environment.d accepts.
|
||||
var variableName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||
|
||||
// environmentProblems is what is wrong with this module's environment contribution, from the
|
||||
// manifest alone.
|
||||
func (m Manifest) environmentProblems() []string {
|
||||
if m.Environment == nil {
|
||||
return nil
|
||||
}
|
||||
var problems []string
|
||||
for _, n := range sortedKeys(m.Environment.Variables) {
|
||||
switch {
|
||||
case !variableName.MatchString(n):
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s sets the variable %q, which is not a name a shell accepts: a letter or an "+
|
||||
"underscore, then letters, digits and underscores", m.Module, n))
|
||||
continue
|
||||
case n == "PATH":
|
||||
// PATH is the one variable every module shares, so no module may set it whole: a second
|
||||
// setter would replace the first's entries, and the account's own PATH with them.
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s sets PATH as a variable; a module adds an entry under environment.path, at the "+
|
||||
"start or the end, and PATH is composed from every module's (novox/hq ADR 0203)", m.Module))
|
||||
continue
|
||||
}
|
||||
if why := literalProblem(m.Environment.Variables[n]); why != "" {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s sets %s to %q, which %s — %s", m.Module, n, m.Environment.Variables[n], why, literalRule))
|
||||
}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for i, p := range m.Environment.Path {
|
||||
switch {
|
||||
case p.Entry == "":
|
||||
problems = append(problems, fmt.Sprintf("%s's PATH entry %d names no directory", m.Module, i+1))
|
||||
case strings.Contains(ofMachine.ReplaceAllString(p.Entry, ""), ":"):
|
||||
// A colon is PATH's own separator, so an entry holding one is two entries, and the
|
||||
// check that it is already present would look for the wrong thing.
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s puts %q on PATH, which holds a colon, PATH's own separator", m.Module, p.Entry))
|
||||
case seen[p.Entry]:
|
||||
problems = append(problems, fmt.Sprintf("%s puts %q on PATH twice", m.Module, p.Entry))
|
||||
default:
|
||||
if why := literalProblem(p.Entry); why != "" {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s puts %q on PATH, which %s — %s", m.Module, p.Entry, why, literalRule))
|
||||
}
|
||||
}
|
||||
seen[p.Entry] = true
|
||||
if p.At != PathAtStart && p.At != PathAtEnd {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s puts %q on PATH at %q; an entry goes at %q or %q of the account's PATH",
|
||||
m.Module, p.Entry, p.At, PathAtStart, PathAtEnd))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// literalRule is why a value must be literal, said with every refusal of one.
|
||||
const literalRule = "a value is literal, so a POSIX shell and the service manager read it alike, and " +
|
||||
"names the machine only through the mesh's own ${machine:…} facts (novox/hq ADR 0203)"
|
||||
@@ -195,46 +137,6 @@ func literalProblem(v string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// shellProblems is what is wrong with this module's shell code, from the manifest alone. The code
|
||||
// itself is not judged: it is the shell's syntax, which the controller does not read.
|
||||
func (m Manifest) shellProblems() []string {
|
||||
var problems []string
|
||||
for i, c := range m.Shell {
|
||||
if !oneOf(contributionTargets(), c.For) {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s's shell code %d is for %q; the shells are %s, the session's files %s, and the power "+
|
||||
"moments %s", m.Module, i+1, c.For, strings.Join(knownShells, ", "),
|
||||
strings.Join(sessionFiles, ", "), strings.Join(powerMoments, ", ")))
|
||||
}
|
||||
if !oneOf(knownSlots, c.Slot) {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s's shell code %d goes in the slot %q; the slots are %s", m.Module, i+1, c.Slot,
|
||||
strings.Join(knownSlots, ", ")))
|
||||
}
|
||||
if strings.TrimSpace(c.Code) == "" {
|
||||
problems = append(problems, fmt.Sprintf("%s's shell code %d has no code", m.Module, i+1))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// contributionPlaceholderProblems is every place this module's resources name the environment or
|
||||
// the shell's code and may not — judged from the manifest, so the catalogue check refuses it before
|
||||
// a mesh does, and again at composition in the same words.
|
||||
func (m Manifest) contributionPlaceholderProblems() []string {
|
||||
var problems []string
|
||||
for _, r := range m.Resources {
|
||||
problems = append(problems, placeholderProblems(m, r)...)
|
||||
problems = append(problems, seatPlaceholderProblems(m, r)...)
|
||||
problems = append(problems, collectedPlaceholderProblems(m, r)...)
|
||||
problems = append(problems, consumersPlaceholderProblems(m, r)...)
|
||||
// And every placeholder no pass fills where it stands: a misspelt namespace, a key its
|
||||
// namespace cannot take, or a field its pass does not read (novox/hq issue 231).
|
||||
problems = append(problems, unconsumedPlaceholders(m.Module, r)...)
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// placeholderProblems is what is wrong with one resource's ${environment:…} and ${shell:…}.
|
||||
//
|
||||
// **The seat authorises it, not the placeholder** (novox/hq ADR 0203 §5, ADR 0204 §3), as the seat
|
||||
|
||||
@@ -276,102 +276,6 @@ func TestTheEnvironmentHolderPlacesBothRenderings(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0203 §5 and ADR 0204 §3: a placeholder outside the seat's holder is refused — by the parser,
|
||||
// which is what the catalogue check and registration run, and again at composition, in the same words.
|
||||
func TestAPlaceholderOutsideTheHolderIsRefused(t *testing.T) {
|
||||
for _, c := range []struct{ content, want string }{
|
||||
{"${environment:posix}", "toolchain's resource rc names ${environment:posix} and toolchain does not claim node-environment"},
|
||||
{"${shell:zsh:normal}", "toolchain's resource rc names ${shell:zsh:normal} and toolchain does not claim node-login-shell"},
|
||||
} {
|
||||
raw := `{"module":"toolchain","resources":[{"id":"rc","type":"file","path":"/etc/rc","content":"` + c.content + `"}]}`
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("the catalogue check accepted %s outside its holder: %v", c.content, err)
|
||||
}
|
||||
m := Manifest{Module: "toolchain", Resources: []map[string]any{
|
||||
{"id": "rc", "type": "file", "path": "/etc/rc", "content": c.content}}}
|
||||
r := Resolution{Node: "workstation", Account: "op", Modules: []Manifest{m}}
|
||||
if _, err := r.Declaration(Rendering{}); err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("composition accepted %s outside its holder: %v", c.content, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A key nobody renders is refused, not left in the file as a literal.
|
||||
func TestAnUnknownPlaceholderKeyIsRefused(t *testing.T) {
|
||||
for _, c := range []struct{ content, want string }{
|
||||
{"${environment:foo}", "names ${environment:foo}; the environment is ${environment:posix} or ${environment:systemd}"},
|
||||
{"${shell:zsh:middle}", "names ${shell:zsh:middle}; shell code is ${shell:<shell>:<slot>}"},
|
||||
{"${shell:tcsh:first}", "names ${shell:tcsh:first}; shell code is ${shell:<shell>:<slot>}"},
|
||||
{"${shell:zsh}", "names ${shell:zsh}; shell code is ${shell:<shell>:<slot>}"},
|
||||
} {
|
||||
raw := `{"module":"holder","claims":[{"name":"node-environment","scope":"node"},{"name":"node-login-shell","scope":"node"}],` +
|
||||
`"resources":[{"id":"rc","type":"file","path":"/etc/rc","content":"` + c.content + `"}]}`
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%s was accepted: %v", c.content, err)
|
||||
}
|
||||
}
|
||||
// And outside a file's content, where nothing could be placed.
|
||||
raw := `{"module":"holder","claims":[{"name":"node-environment","scope":"node"}],` +
|
||||
`"resources":[{"id":"rc","type":"file","path":"/etc/${environment:posix}","content":"x"}]}`
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil ||
|
||||
!strings.Contains(err.Error(), "names ${environment:posix} in its path; the environment and the shell's code are placed only in a file's content") {
|
||||
t.Errorf("a placeholder in a path was accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// What ADR 0203 §2 allows a contribution to say, refused at parse when it says anything else.
|
||||
func TestAMalformedEnvironmentIsRefusedAtParse(t *testing.T) {
|
||||
for _, c := range []struct{ environment, want string }{
|
||||
{`{"variables":{"1X":"a"}}`, `tool sets the variable "1X", which is not a name a shell accepts`},
|
||||
{`{"variables":{"MY-VAR":"a"}}`, `tool sets the variable "MY-VAR", which is not a name a shell accepts`},
|
||||
{`{"variables":{"PATH":"/bin"}}`, `tool sets PATH as a variable; a module adds an entry under environment.path`},
|
||||
{`{"variables":{"A":"$HOME/x"}}`, `tool sets A to "$HOME/x", which holds a $ that is not one of the machine's ${machine:…} facts`},
|
||||
{`{"variables":{"A":"${HOME}/x"}}`, `tool sets A to "${HOME}/x", which holds a $`},
|
||||
{`{"variables":{"A":"it's"}}`, `tool sets A to "it's", which holds a quote`},
|
||||
{`{"variables":{"A":"say \"hi\""}}`, `which holds a quote`},
|
||||
{`{"variables":{"A":"a\\b"}}`, `which holds a backslash`},
|
||||
{`{"variables":{"A":"a\nb"}}`, `which holds a line break`},
|
||||
{`{"variables":{"A":"a\u0000b"}}`, `which holds a NUL`},
|
||||
{`{"path":[{"entry":"","at":"start"}]}`, `tool's PATH entry 1 names no directory`},
|
||||
{`{"path":[{"entry":"/a:/b","at":"start"}]}`, `tool puts "/a:/b" on PATH, which holds a colon`},
|
||||
{`{"path":[{"entry":"$HOME/bin","at":"start"}]}`, `tool puts "$HOME/bin" on PATH, which holds a $`},
|
||||
{`{"path":[{"entry":"/a","at":"middle"}]}`, `tool puts "/a" on PATH at "middle"; an entry goes at "start" or "end"`},
|
||||
{`{"path":[{"entry":"/a"}]}`, `tool puts "/a" on PATH at ""`},
|
||||
{`{"path":[{"entry":"/a","at":"start"},{"entry":"/a","at":"end"}]}`, `tool puts "/a" on PATH twice`},
|
||||
{`{"variables":{"A":"x"},"paths":[]}`, `unknown field "paths"`},
|
||||
} {
|
||||
_, err := ParseManifest([]byte(`{"module":"tool","environment":` + c.environment + `}`))
|
||||
if err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%s: want %q, got %v", c.environment, c.want, err)
|
||||
}
|
||||
}
|
||||
// What is allowed: a literal, and the machine's own facts.
|
||||
if _, err := ParseManifest([]byte(`{"module":"tool","environment":{` +
|
||||
`"variables":{"GOPATH":"${machine:account-home}/go","DISABLE_X":"1","ANSWER":"a b+c=d"},` +
|
||||
`"path":[{"entry":"${machine:account-home}/go/bin","at":"start"},{"entry":"/opt/x","at":"end"}]}}`)); err != nil {
|
||||
t.Fatalf("a well-formed environment was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMalformedShellCodeIsRefusedAtParse(t *testing.T) {
|
||||
for _, c := range []struct{ shell, want string }{
|
||||
{`[{"for":"tcsh","slot":"normal","code":"x"}]`, `tool's shell code 1 is for "tcsh"; the shells are zsh, bash, fish`},
|
||||
{`[{"for":"zsh","slot":"middle","code":"x"}]`, `tool's shell code 1 goes in the slot "middle"; the slots are first, normal, last`},
|
||||
{`[{"for":"zsh","slot":"last","code":"x"},{"for":"zsh","slot":"last","code":" \n"}]`, `tool's shell code 2 has no code`},
|
||||
{`[{"for":"zsh","slot":"last","code":"x","order":1}]`, `unknown field "order"`},
|
||||
} {
|
||||
_, err := ParseManifest([]byte(`{"module":"tool","shell":` + c.shell + `}`))
|
||||
if err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%s: want %q, got %v", c.shell, c.want, err)
|
||||
}
|
||||
}
|
||||
// The code itself is never judged: a shell's own `${…}` is not the mesh's.
|
||||
if _, err := ParseManifest([]byte(`{"module":"tool","shell":[{"for":"zsh","slot":"first",` +
|
||||
`"code":"source \"${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh\""}]}`)); err != nil {
|
||||
t.Fatalf("shell code was judged as if it were the mesh's: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0203 §1 and ADR 0204 §1: both seats are the mesh's own, held once per machine; the login
|
||||
// shell's contract is `execute`, described and with a schema an agent can call.
|
||||
func TestTheSeatTableCarriesTheEnvironmentAndTheLoginShell(t *testing.T) {
|
||||
@@ -396,29 +300,3 @@ func TestTheSeatTableCarriesTheEnvironmentAndTheLoginShell(t *testing.T) {
|
||||
t.Fatalf("execute takes no timeout: %v", props)
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0204 §1: the login shell is the mesh's, so no module declares it — neither under the mesh's
|
||||
// name nor under the name a module gave it before.
|
||||
func TestNoModuleMayDeclareTheLoginShell(t *testing.T) {
|
||||
for _, n := range []string{"login-shell", "node-login-shell", "node-environment"} {
|
||||
raw := `{"module":"zsh","seats":[{"name":"` + n + `","scope":"node","serves":["execute"]}],"tools":["execute"]}`
|
||||
_, err := ParseManifest([]byte(raw))
|
||||
if err == nil {
|
||||
t.Errorf("a module declaring %q was accepted", n)
|
||||
}
|
||||
}
|
||||
got := strings.Join(declaredSeatProblems(Manifest{Module: "zsh",
|
||||
DefinesSeats: []SeatDeclaration{{Name: "login-shell", Scope: ScopeNode}}}), "; ")
|
||||
if !strings.Contains(got, `zsh declares a seat named "login-shell"; the login shell is the mesh's own seat node-login-shell`) {
|
||||
t.Fatalf("declaring login-shell was not refused by name: %q", got)
|
||||
}
|
||||
// And a shell module claiming the mesh's seat, serving execute, is what the seat is for.
|
||||
m, err := ParseManifest([]byte(`{"module":"zsh","tools":["execute"],` +
|
||||
`"claims":[{"name":"node-login-shell","scope":"node"}]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := CanHold(m, Seat{Name: LoginShellSeat, Scope: ScopeNode, Serves: loginShellVerbs()}); err != nil {
|
||||
t.Fatalf("a shell module claiming the seat cannot hold it: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,31 +129,7 @@ func TestTheEnvironmentOfTheCatalogueIsTheSameHandedOrNot(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEnvironmentItemIsTypedAndNamesWhatItNeeds(t *testing.T) {
|
||||
item := func(attrs map[string]string) []string {
|
||||
return environmentItemProblems("x", Item{Collection: "environment", Attributes: attrs})
|
||||
}
|
||||
for _, ok := range []map[string]string{
|
||||
{"kind": "variable", "name": "GTK_THEME"},
|
||||
{"kind": "path", "at": "end", "order": "10"},
|
||||
} {
|
||||
if p := item(ok); len(p) != 0 {
|
||||
t.Errorf("%v is refused: %v", ok, p)
|
||||
}
|
||||
}
|
||||
for _, bad := range []map[string]string{
|
||||
nil, {"kind": "other"}, {"kind": "variable"}, {"kind": "variable", "name": "PATH"},
|
||||
{"kind": "path"}, {"kind": "path", "at": "middle"}, {"kind": "path", "at": "start", "name": "X"},
|
||||
{"kind": "variable", "name": "A", "color": "red"},
|
||||
} {
|
||||
if p := item(bad); len(p) == 0 {
|
||||
t.Errorf("%v is taken", bad)
|
||||
}
|
||||
}
|
||||
if !attributeFits("environment", "name", "GTK_THEME") || attributeFits("shell:zsh:normal", "name", "GTK_THEME") ||
|
||||
attributeFits("environment", "kind", "Variable") {
|
||||
t.Error("an upper-case name is the environment's name alone")
|
||||
}
|
||||
func TestTwoModulesSettingOneVariableAreRefused(t *testing.T) {
|
||||
// A module that sets a variable twice, in two modules, is refused as the old form refused it.
|
||||
a := Manifest{Module: "a", Items: []Item{{Collection: "environment", Content: "1\n",
|
||||
Attributes: map[string]string{"kind": "variable", "name": "X"}}}}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// What a module may call an event, and what a consumer may ask for.
|
||||
@@ -36,131 +34,6 @@ const (
|
||||
TheRest = "**"
|
||||
)
|
||||
|
||||
// EventProblems is what is wrong with a manifest's events.
|
||||
//
|
||||
// Refused at registration, because the alternative is a module that installs, starts, connects and
|
||||
// reacts to nothing — and every log line says it is fine.
|
||||
func EventProblems(m Manifest) []string {
|
||||
var problems []string
|
||||
|
||||
for _, e := range m.Emits {
|
||||
if was, stale := staleEventForm(e, m.Module); stale {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s emits %q, which is the old bus's routing key. An event is named locally now, so "+
|
||||
"write %q — the mesh derives the subject (novox/hq design 29 §1)",
|
||||
m.Module, was, strings.TrimPrefix(was, "module."+m.Module+".")))
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(e, "module.") {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s emits %q: `module.` is reserved, because it is how the old bus spelled a "+
|
||||
"routing key and an event named that way derives into a namespace nobody owns",
|
||||
m.Module, e))
|
||||
continue
|
||||
}
|
||||
if err := localName(e); err != nil {
|
||||
problems = append(problems, fmt.Sprintf("%s emits %q: %v", m.Module, e, err))
|
||||
continue
|
||||
}
|
||||
// **Its own name, never another's.** The bus enforces that a namespace belongs to the module
|
||||
// it is named for, so an event named for somebody else cannot be published at all. If the
|
||||
// event is about a role rather than about this module, it belongs on the seat: a name that
|
||||
// is stable across whoever fills it (04-ISSUES/127).
|
||||
if first, _, split := strings.Cut(e, "."); split && isAModuleNameOtherThan(first, m.Module) {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s emits %q, which reads as another module's event. A module publishes under its "+
|
||||
"own name only. If this is about a role rather than about %s, declare it on that "+
|
||||
"seat, where the name survives the holder changing",
|
||||
m.Module, e, m.Module))
|
||||
}
|
||||
}
|
||||
|
||||
for _, c := range m.Consumes {
|
||||
if strings.HasPrefix(c, "module.") {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s consumes %q, which is the old bus's pattern. A consumed event names its emitter "+
|
||||
"and the event: write %q", m.Module, c, strings.TrimPrefix(c, "module.")))
|
||||
continue
|
||||
}
|
||||
if c == "#" {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s consumes %q, which is the old bus's wildcard for everything. Write %q",
|
||||
m.Module, c, TheRest))
|
||||
continue
|
||||
}
|
||||
if err := consumePattern(c); err != nil {
|
||||
problems = append(problems, fmt.Sprintf("%s consumes %q: %v", m.Module, c, err))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// staleEventForm says an emitted name is this module's own old routing key, and what it was.
|
||||
func staleEventForm(event, module string) (string, bool) {
|
||||
return event, module != "" && strings.HasPrefix(event, "module."+module+".")
|
||||
}
|
||||
|
||||
// isAModuleNameOtherThan says a first token names some module of this mesh that is not this one.
|
||||
//
|
||||
// Only the mesh's own seats and the catalogue could answer this properly, and neither is reachable
|
||||
// from a parser given one manifest. So this catches the case that actually happened — a name that
|
||||
// is a *provision* the mesh defines, which is where "another module's event" comes from in practice
|
||||
// — and the whole-catalogue check catches the rest.
|
||||
func isAModuleNameOtherThan(first, module string) bool {
|
||||
if first == module || first == "" {
|
||||
return false
|
||||
}
|
||||
if _, isASeat := SeatNamed(first); isASeat {
|
||||
return true
|
||||
}
|
||||
if _, isASeat := SeatDelivering(first); isASeat {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// localName checks one event name: dot-separated names, no wildcards, nothing else.
|
||||
func localName(event string) error {
|
||||
if event == "" {
|
||||
return fmt.Errorf("an event needs a name")
|
||||
}
|
||||
for _, part := range strings.Split(event, ".") {
|
||||
if part == OneName || part == TheRest {
|
||||
return fmt.Errorf("an emitted event names one event, so it carries no wildcard")
|
||||
}
|
||||
if !eventName.MatchString(part) {
|
||||
return fmt.Errorf("%q is not a usable name: lower-case letters, digits and dashes", part)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// consumePattern checks a consumed pattern: the emitter, then the event, with wildcards.
|
||||
func consumePattern(pattern string) error {
|
||||
if pattern == "" {
|
||||
return fmt.Errorf("a consumed event needs an emitter and an event")
|
||||
}
|
||||
parts := strings.Split(pattern, ".")
|
||||
for i, part := range parts {
|
||||
switch {
|
||||
case part == TheRest:
|
||||
if i != len(parts)-1 {
|
||||
return fmt.Errorf("%q stands for the rest of a name, so nothing may follow it", TheRest)
|
||||
}
|
||||
case part == OneName:
|
||||
case !eventName.MatchString(part):
|
||||
return fmt.Errorf("%q is not a usable name: lower-case letters, digits and dashes", part)
|
||||
}
|
||||
}
|
||||
// `**` alone is every event from every module, which the audit logger wants and says plainly.
|
||||
if len(parts) == 1 && parts[0] != TheRest {
|
||||
return fmt.Errorf(
|
||||
"%q names an emitter and no event. Write <emitter>.<event>, or %q for every event",
|
||||
pattern, TheRest)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// The events a provider says about its consumers (novox/hq ADR 0224): a consumer it has failed
|
||||
// without one success for minutes, and that consumer succeeding again or being withdrawn. The
|
||||
// controller follows them from every module and `status` names a consumer failing until it recovers.
|
||||
|
||||
@@ -98,16 +98,6 @@ func filesVerbs() []Verb {
|
||||
)
|
||||
}
|
||||
|
||||
// FilesHolderProblems is what a holder of node-files is held to (novox/hq ADR 0394 §2): it runs as the tool
|
||||
// runner's account, the node's operator account, so it declares no runs-as.
|
||||
func FilesHolderProblems(m Manifest) []string {
|
||||
if !m.ClaimsSeat(FilesSeat) || m.RunsAs == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{fmt.Sprintf("%s claims %s and declares runs-as %q: its holder runs as the tool runner's account, "+
|
||||
"the node's operator account (novox/hq ADR 0394 §2)", m.Module, FilesSeat, m.RunsAs)}
|
||||
}
|
||||
|
||||
// ownedPaths is every path a module owns on this node, as the holder is told (novox/hq ADR 0394 §5): the
|
||||
// paths every seat declares (ADR 0287) with the module holding the seat here, and the paths and files each
|
||||
// module's manifest places (declaredPaths). A seat's path wins where both name one.
|
||||
|
||||
@@ -46,20 +46,6 @@ func TestNodeFilesIsANodeSeatWithEveryVerbOfADR0394(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// issue 900, ADR 0394 §2: a holder of node-files runs as the tool runner's account, so it declares no runs-as.
|
||||
func TestAHolderOfNodeFilesDeclaringRunsAsIsRefused(t *testing.T) {
|
||||
p := FilesHolderProblems(Manifest{Module: "files", RunsAs: "mesh-files", Claims: []Claim{{Name: FilesSeat}}})
|
||||
if len(p) != 1 || !strings.Contains(p[0], "0394") || !strings.Contains(p[0], "runs-as") {
|
||||
t.Errorf("a holder declaring runs-as was not refused: %v", p)
|
||||
}
|
||||
if p := FilesHolderProblems(Manifest{Module: "files", Claims: []Claim{{Name: FilesSeat}}}); len(p) != 0 {
|
||||
t.Errorf("a holder declaring none was refused: %v", p)
|
||||
}
|
||||
if p := FilesHolderProblems(Manifest{Module: "other", RunsAs: "x"}); len(p) != 0 {
|
||||
t.Errorf("a non-holder was judged: %v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// issue 900, ADR 0394 §4: the holder is told the secret places and the paths modules own on its node.
|
||||
func TestTheHolderOfNodeFilesIsToldSecretPlacesAndOwnedPaths(t *testing.T) {
|
||||
files := Manifest{Module: "files", Version: "1", Claims: []Claim{{Name: FilesSeat}},
|
||||
|
||||
@@ -1000,32 +1000,6 @@ func Reaches(m Manifest, layers []Layer) (map[int]string, error) {
|
||||
// route that names the endpoint says what it means, and the mesh looks the port up.
|
||||
const RouteEndpoint = "endpoint"
|
||||
|
||||
// RouteProblems holds a module's route contributions to naming an endpoint it actually has.
|
||||
//
|
||||
// A route naming an endpoint the module does not declare reaches nothing, and is refused where it is
|
||||
// written rather than resolving to no port and serving nothing — the fault this repository names most
|
||||
// often, a declaration that reads as though it did something.
|
||||
func RouteProblems(m Manifest) []string {
|
||||
var problems []string
|
||||
check := func(where string, values map[string]any) {
|
||||
name, ok := values[RouteEndpoint].(string)
|
||||
if !ok || strings.TrimSpace(name) == "" {
|
||||
return
|
||||
}
|
||||
if _, found := EndpointPort(m, name); !found {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s routes %s to the endpoint %q, which it does not declare", m.Module, where, name))
|
||||
}
|
||||
}
|
||||
if values, ok := m.Contributes["route"]; ok {
|
||||
check("a name", values)
|
||||
}
|
||||
for local, values := range m.ContributesMany["route"] {
|
||||
check(local, values)
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// EndpointsSetting is the settings key that configures a module's endpoints by name, per node
|
||||
// (novox/hq ADR 0138):
|
||||
//
|
||||
|
||||
@@ -7,22 +7,9 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A manifest key nothing reads is worse than a key that is refused: five HAL manifests carried
|
||||
// `scope:`, which read as a restriction and restricted nothing (novox/hq 04-ISSUES/003). The host's
|
||||
// declaration parser has always refused unknown keys; manifests never did.
|
||||
func TestAManifestKeyNothingReadsIsRefused(t *testing.T) {
|
||||
_, err := ParseManifest([]byte(`{"module":"web","scope":"mesh"}`))
|
||||
if err == nil {
|
||||
t.Fatal("a manifest with a key nothing reads was accepted, which is how scope: survived")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "scope") {
|
||||
t.Fatalf("refused without naming the key, which leaves the author guessing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The same discipline must not refuse a manifest that is merely ordinary.
|
||||
func TestAnOrdinaryManifestIsStillAccepted(t *testing.T) {
|
||||
m, err := ParseManifest([]byte(
|
||||
m, err := decodeForTest([]byte(
|
||||
`{"module":"web","listens":[{"port":443,"from":"anywhere","why":"the site"}]}`))
|
||||
if err != nil {
|
||||
t.Fatalf("an ordinary manifest was refused: %v", err)
|
||||
@@ -32,24 +19,6 @@ func TestAnOrdinaryManifestIsStillAccepted(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A rule with no source is open, and must say so rather than appear to restrict something.
|
||||
func TestAPortWithNoSourceIsRefused(t *testing.T) {
|
||||
_, err := ParseManifest([]byte(`{"module":"web","listens":[{"port":443}]}`))
|
||||
if err == nil {
|
||||
t.Fatal("a port that never said who may reach it was accepted; it reads as a restriction")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "from where") {
|
||||
t.Fatalf("refused without saying what is missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASourceNobodyDefinedIsRefused(t *testing.T) {
|
||||
_, err := ParseManifest([]byte(`{"module":"web","listens":[{"port":443,"from":"lan"}]}`))
|
||||
if err == nil {
|
||||
t.Fatal("a source the mesh cannot render was accepted, and would be silently dropped")
|
||||
}
|
||||
}
|
||||
|
||||
// The derivation is over the whole node, not one module.
|
||||
func TestTheRuleSetIsEveryAssignedModulesPorts(t *testing.T) {
|
||||
r := Resolution{Modules: []Manifest{
|
||||
@@ -357,13 +326,6 @@ func TestTheModuleAskingForTheRuleSetGetsEveryModulesPorts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAskingForTheRuleSetWithNowhereToPutItIsRefused(t *testing.T) {
|
||||
_, err := ParseManifest([]byte(`{"module":"firewall","filtering":{"into":""}}`))
|
||||
if err == nil {
|
||||
t.Fatal("a module asked for the rule set and named no path; it would receive nothing")
|
||||
}
|
||||
}
|
||||
|
||||
// nftables matches the two address families separately, and one set holding both is a syntax
|
||||
// error — so the file fails to load, the service reports a fault, and the machine filters nothing.
|
||||
func TestAMeshOnBothAddressFamiliesRendersBoth(t *testing.T) {
|
||||
@@ -782,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)
|
||||
}
|
||||
|
||||
@@ -1,37 +1,6 @@
|
||||
package catalogue
|
||||
|
||||
import "testing"
|
||||
|
||||
// A bundle is the module's own directory compiled whole, and naming a source would be describing its
|
||||
// own build. That holds for an interpreted language and cannot hold for a compiled one: a repository
|
||||
// written in Go carries several commands — the host and its bootstrap live in one — and "the module's
|
||||
// own directory" is then not a package at all (novox/hq 04-ISSUES/142).
|
||||
|
||||
func TestAGoBundleMayNameItsCommand(t *testing.T) {
|
||||
b := &Build{Artifacts: []Artifact{{
|
||||
Name: "host-arch", Kind: ArtifactBundle, Language: "go", System: "arch",
|
||||
From: "cmd/mesh-host",
|
||||
}}}
|
||||
if p := b.problems("mesh-host"); len(p) != 0 {
|
||||
t.Fatalf("a go bundle naming its command was refused: %v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnInterpretedBundleStillMayNotNameASource(t *testing.T) {
|
||||
b := &Build{Artifacts: []Artifact{{
|
||||
Name: "tools", Kind: ArtifactBundle, Language: "typescript", From: "src",
|
||||
}}}
|
||||
p := b.problems("something")
|
||||
if len(p) == 0 {
|
||||
t.Fatal("an interpreted bundle naming what it is built from was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestACompiledBundleStillMustSayItsSystem(t *testing.T) {
|
||||
b := &Build{Artifacts: []Artifact{{
|
||||
Name: "host", Kind: ArtifactBundle, Language: "go", From: "cmd/mesh-host",
|
||||
}}}
|
||||
if len(b.problems("mesh-host")) == 0 {
|
||||
t.Fatal("a compiled bundle with no system was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -61,14 +61,6 @@ func TestTheGraphicalSessionsSeatsAreTheMeshsOwnWithTheirVerbs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// §2: a module may claim one of them, and may not declare it as its own.
|
||||
func TestNoModuleMayDeclareAGraphicalSessionSeat(t *testing.T) {
|
||||
raw := `{"module":"xorg","seats":[{"name":"node-display-server","scope":"node"}]}`
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "mesh's own namespace") {
|
||||
t.Fatalf("a module declared node-display-server as its own: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func displayServer(name, display string, claims ...string) Manifest {
|
||||
m := Manifest{Module: name, Provides: []Offer{{Name: display, Reach: ReachMachine}}}
|
||||
for _, c := range claims {
|
||||
@@ -126,29 +118,6 @@ func TestAMachineReachRequirementIsNotPulledInNorAnsweredFromAnotherNode(t *test
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheMachinesReachIsAProvisionsOnlyReachAndIsNodeScoped(t *testing.T) {
|
||||
for _, c := range []struct{ provides, want string }{
|
||||
{`{"name":"x11-display","reach":"internal"}`, `with reach "internal"; a provision's reach is "machine" or nothing`},
|
||||
{`{"name":"x11-display","scope":"mesh","reach":"machine"}`, `at scope "mesh" with the machine's reach`},
|
||||
} {
|
||||
_, err := ParseManifest([]byte(`{"module":"xorg","provides":[` + c.provides + `]}`))
|
||||
if err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%s: want %q, got %v", c.provides, c.want, err)
|
||||
}
|
||||
}
|
||||
m, err := ParseManifest([]byte(`{"module":"xorg","provides":[{"name":"x11-display","reach":"machine"}]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !m.Provides[0].MachineReach() {
|
||||
t.Fatal("the reach was not read")
|
||||
}
|
||||
back, _ := json.Marshal(m.Provides[0])
|
||||
if string(back) != `{"name":"x11-display","reach":"machine"}` {
|
||||
t.Errorf("written back as %s", back)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACatalogueDisagreeingAboutAProvisionsReachIsRefused(t *testing.T) {
|
||||
cat := shelf(windowManager(), displayServer("xorg", "x11-display"),
|
||||
Manifest{Module: "fake-x", Provides: Offers("x11-display")})
|
||||
@@ -157,57 +126,3 @@ func TestACatalogueDisagreeingAboutAProvisionsReachIsRefused(t *testing.T) {
|
||||
t.Fatalf("a provision with and without the machine's reach gave %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// §4: xinitrc and xresources slots, placed by the display server's holder alone.
|
||||
func TestTheSessionsFilesArePlacedByTheDisplayServersHolderAlone(t *testing.T) {
|
||||
xorg := Manifest{Module: "xorg", Claims: []Claim{{Name: DisplayServerSeat}},
|
||||
Shell: []ShellCode{{For: "xinitrc", Slot: "first", Code: "xset s off"}},
|
||||
Resources: []map[string]any{
|
||||
{"id": "xinitrc", "type": "file", "path": "/home/op/.xinitrc",
|
||||
"content": "${shell:xinitrc:first}${shell:xinitrc:normal}${shell:xinitrc:last}"},
|
||||
{"id": "xresources", "type": "file", "path": "/home/op/.Xresources",
|
||||
"content": "${shell:xresources:normal}"},
|
||||
}}
|
||||
i3 := Manifest{Module: "i3", Shell: []ShellCode{{For: "xinitrc", Slot: "last", Code: "exec i3"}}}
|
||||
theme := Manifest{Module: "theme", Shell: []ShellCode{
|
||||
{For: "xresources", Slot: "normal", Code: "Xft.dpi: 96"},
|
||||
{For: "zsh", Slot: "normal", Code: "not for the session"},
|
||||
}}
|
||||
r := Resolution{Node: "workstation", Account: "op", Modules: []Manifest{xorg, i3, theme}}
|
||||
out, err := r.Declaration(Rendering{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
by := map[string]any{}
|
||||
for _, res := range out {
|
||||
by[res["id"].(string)] = res["content"]
|
||||
}
|
||||
if got := by["xorg.xinitrc"]; got != "# xorg\nxset s off\n# i3\nexec i3\n" {
|
||||
t.Errorf("the .xinitrc is %q", got)
|
||||
}
|
||||
if got := by["xorg.xresources"]; got != "# theme\nXft.dpi: 96\n" {
|
||||
t.Errorf("the .Xresources is %q", got)
|
||||
}
|
||||
|
||||
// The contributions parse; the placeholders parse only in the holder.
|
||||
if _, err := ParseManifest([]byte(`{"module":"i3","shell":[{"for":"xinitrc","slot":"last","code":"exec i3"},` +
|
||||
`{"for":"xresources","slot":"normal","code":"i3.font: x"}]}`)); err != nil {
|
||||
t.Fatalf("a session contribution was refused: %v", err)
|
||||
}
|
||||
for _, c := range []struct{ claims, content, want string }{
|
||||
{``, "${shell:xinitrc:normal}", "does not claim node-display-server; every module's xinitrc is placed by the display server's holder alone"},
|
||||
{`{"name":"node-login-shell"}`, "${shell:xresources:normal}", "does not claim node-display-server"},
|
||||
{`{"name":"node-display-server"}`, "${shell:zsh:normal}", "does not claim node-login-shell"},
|
||||
{`{"name":"node-display-server"}`, "${shell:xsession:normal}", "the session's file one of xinitrc, xresources"},
|
||||
} {
|
||||
raw := `{"module":"holder","claims":[` + c.claims + `],"resources":[{"id":"rc","type":"file","path":"/etc/rc","content":"` +
|
||||
c.content + `"}]}`
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%s with claims [%s]: want %q, got %v", c.content, c.claims, c.want, err)
|
||||
}
|
||||
}
|
||||
if _, err := ParseManifest([]byte(`{"module":"xorg","claims":[{"name":"node-display-server"}],` +
|
||||
`"resources":[{"id":"rc","type":"file","path":"/home/op/.xinitrc","content":"${shell:xinitrc:last}"}]}`)); err != nil {
|
||||
t.Errorf("the display server's holder could not place the session's slots: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,26 +58,6 @@ func TestAGroupThatIsNotAWholeNumberAboveZeroIsRefused(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A manifest in the config form registers with the group a config value fills, and the value is placed (a container's
|
||||
// groups count as a place for it); a placeholder in any other shape is refused where the manifest is read.
|
||||
func TestAManifestWhoseContainerTakesItsGroupFromConfigRegisters(t *testing.T) {
|
||||
m := map[string]any{
|
||||
"module": "n8n", "version": "1",
|
||||
"config": map[string]any{"media-gid": map[string]any{"type": "number", "minimum": 1, "required": true,
|
||||
"description": "the group that owns the media library on this machine"}},
|
||||
"resources": []any{aContainerWithGroups("${config:media-gid}")},
|
||||
}
|
||||
if _, err := parseRaw(t, m); err != nil {
|
||||
t.Fatalf("refused: %v", err)
|
||||
}
|
||||
for _, shape := range []string{"x${config:media-gid}", "${config:media-gid}:1", "${config:other}"} {
|
||||
m["resources"] = []any{aContainerWithGroups(shape)}
|
||||
if _, err := parseRaw(t, m); err == nil {
|
||||
t.Errorf("groups %q registered", shape)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1636/AC3 composed for a machine, the way a push writes it: n8n's group is the number the machine's config value
|
||||
// says, written as a number, and the module is left out by name when the machine says none.
|
||||
func TestAComposedMachineGetsTheGroupTheConfigValueNames(t *testing.T) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user