Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2ec8b6b2f | ||
|
|
530d8d3007 | ||
|
|
16bc0dcf3e |
@@ -33,7 +33,7 @@ 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 (the indexer's judge, novox/hq ADR 0578 §1).
|
||||
// its buckets' names (catalogue.StateProblems).
|
||||
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)
|
||||
|
||||
+9
-11
@@ -37,6 +37,11 @@ type AskSteps struct {
|
||||
// Has says whether this node has a need a package's tests declare (delivery.AskNeeds, issue 890); a package needing
|
||||
// what it lacks is not tested here but named in the level's Unmet, for the asker to place. Nil has nothing.
|
||||
Has func(need string) bool
|
||||
// Narrate says an event on the module's own stream (stdio.Emit) and Module is the module's name: a failed test's whole
|
||||
// output is said on the module's log stream while the ask runs, and the step's result names where (narrate). Nil
|
||||
// says nothing, and the whole output is dropped from the result all the same.
|
||||
Narrate func(event string, body any) error
|
||||
Module string
|
||||
}
|
||||
|
||||
// RunAsk runs ask's job with steps and answers a delivery.BuildAnswer or a delivery.CheckAnswer.
|
||||
@@ -221,6 +226,7 @@ func (s AskSteps) level(ctx context.Context, job delivery.CheckJob, level string
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
r = s.stepLog(ctx).narrate(r, s.Logf)
|
||||
la.Tests = append(la.Tests, r)
|
||||
var t struct {
|
||||
Passed bool `json:"passed"`
|
||||
@@ -228,10 +234,8 @@ func (s AskSteps) level(ctx context.Context, job delivery.CheckJob, level string
|
||||
_ = json.Unmarshal(r, &t)
|
||||
return t.Passed, nil
|
||||
}
|
||||
// The packages (or Node directories) whose tests the change touches run first, and a failure among them ends the level
|
||||
// there, so a change's own failing test is answered without the rest of the suite (issue 1289; ADR 0444). Past them every
|
||||
// test step of the level runs, also after one that did not pass, so one check names every failing test of the rest of
|
||||
// the level and not only the first (issue 1921); the level passes only when each of its steps did.
|
||||
// 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.
|
||||
all := true
|
||||
if s.Digest == nil {
|
||||
// A module with no digest (Node) tests each module directory of the tree, those whose tests changed first.
|
||||
@@ -246,9 +250,6 @@ func (s AskSteps) level(ctx context.Context, job delivery.CheckJob, level string
|
||||
}
|
||||
all = all && passed
|
||||
}
|
||||
if !all {
|
||||
return false, nil // the change's own tests failed: the others are not run (issue 1289)
|
||||
}
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
@@ -284,10 +285,7 @@ func (s AskSteps) level(ctx context.Context, job delivery.CheckJob, level string
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("the %s level: %w", level, err)
|
||||
}
|
||||
all = all && passed // past the change's own packages the others still run (issue 1921)
|
||||
if priority && !all {
|
||||
return false, nil // the change's own tests failed: the others are not run (issue 1289)
|
||||
}
|
||||
all = all && passed // the packages left still run (issue 1921)
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
+98
-57
@@ -5,10 +5,12 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
@@ -113,7 +115,7 @@ func TestRunAskRefusesAnotherToolchain(t *testing.T) {
|
||||
}
|
||||
|
||||
// 1887/A3, A5a: a check ask runs the levels of one check after one fetch: it digests the unit level, takes the kept
|
||||
// verdicts, tests the packages whose tests changed first and, when one of them fails, no other (issue 1289), and runs no
|
||||
// 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).
|
||||
func TestRunAskChecksTheLevelsOfOneCheck(t *testing.T) {
|
||||
r := &recorder{
|
||||
@@ -129,7 +131,8 @@ func TestRunAskChecksTheLevelsOfOneCheck(t *testing.T) {
|
||||
got := strings.Join(r.calls, "\n")
|
||||
want := `fetch {"base":"` + runCommit + `","commit":"` + runCommit + `","repository":"novox/x"}
|
||||
digest {"level":"unit","workspace":"/w"}
|
||||
test {"level":"unit","packages":["x/pkg/b"],"workspace":"/w"}`
|
||||
test {"level":"unit","packages":["x/pkg/b"],"workspace":"/w"}
|
||||
test {"level":"unit","packages":["x/pkg/a"],"workspace":"/w"}`
|
||||
if got != want {
|
||||
t.Fatalf("calls:\n%s\nwant:\n%s", got, want)
|
||||
}
|
||||
@@ -138,7 +141,7 @@ test {"level":"unit","packages":["x/pkg/b"],"workspace":"/w"}`
|
||||
t.Fatalf("levels %+v, want the unit level alone", a.Levels)
|
||||
}
|
||||
u := a.Levels[0]
|
||||
if u.Level != delivery.LevelUnit || len(u.Kept) != 1 || u.Kept[0].Package != "x/pkg/k" || u.Kept[0].Tests != 7 || len(u.Tests) != 1 || u.Digest == nil {
|
||||
if u.Level != delivery.LevelUnit || len(u.Kept) != 1 || u.Kept[0].Package != "x/pkg/k" || u.Kept[0].Tests != 7 || len(u.Tests) != 2 || u.Digest == nil {
|
||||
t.Fatalf("the unit level %+v", u)
|
||||
}
|
||||
|
||||
@@ -253,64 +256,102 @@ func TestRunAskChecksNodeModulesAndConflicts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1289: a failure in the packages whose tests changed ends the level there, the others are not run, and no later level
|
||||
// runs. Issue 1921: past the change's own packages every package still runs, so two failing packages none of which the
|
||||
// change touched are two test results of the one level. A Node check does the same with its module directories.
|
||||
func TestRunAskStopsAtTheChangesOwnFailureAndRunsTheRestAfterOthers(t *testing.T) {
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
changed []string
|
||||
tests map[string]bool
|
||||
want []string // the packages tested, in order
|
||||
}{
|
||||
{"the change's own test fails: nothing else runs", []string{"pkg/b/b_test.go"}, map[string]bool{"x/pkg/a": false, "x/pkg/b": false}, []string{"x/pkg/b"}},
|
||||
{"the change's own test 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, " "))
|
||||
}
|
||||
}
|
||||
// the passing row goes on to the integration level, which tests the same packages again; the first level is what is judged
|
||||
if n := len(row.want); len(got) < n || fmt.Sprint(got[:n]) != fmt.Sprint(row.want) {
|
||||
t.Errorf("%s: tested %v, want %v first", row.name, got, row.want)
|
||||
}
|
||||
a := out.(delivery.CheckAnswer)
|
||||
failed := false
|
||||
for _, raw := range a.Levels[0].Tests {
|
||||
var res struct{ Passed bool }
|
||||
_ = json.Unmarshal(raw, &res)
|
||||
failed = failed || !res.Passed
|
||||
}
|
||||
if failed && (len(a.Levels) != 1 || len(got) != len(row.want)) {
|
||||
t.Errorf("%s: a failed level went on: levels %d, tests %v", row.name, len(a.Levels), got)
|
||||
}
|
||||
// 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},
|
||||
}
|
||||
|
||||
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(), 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)
|
||||
}
|
||||
if a := out.(delivery.CheckAnswer); len(r.calls) != 2 || len(a.Levels) != 1 || len(a.Levels[0].Tests) != 1 {
|
||||
t.Fatalf("calls %v, levels %+v: want the changed directory b alone and no integration level", r.calls, a.Levels)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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},
|
||||
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)
|
||||
}
|
||||
|
||||
// The operator, 2026-10-30: a failure's whole output is said on the module's log stream under the run and the step,
|
||||
// the result names where and holds no whole output, and the log reads back whole, in pages, its lines in order.
|
||||
var lines []string
|
||||
for i := range 3000 {
|
||||
lines = append(lines, fmt.Sprintf("line %04d %s", i, strings.Repeat("x", 40)))
|
||||
}
|
||||
var said []stdio.StreamEntry
|
||||
s = r.steps()
|
||||
s.Digest, s.Module = nil, "mesh-build-node"
|
||||
s.Narrate = func(event string, body any) error {
|
||||
b, _ := json.Marshal(body)
|
||||
said = append(said, stdio.StreamEntry{Sequence: uint64(len(said) + 1), Event: event, Body: b})
|
||||
return nil
|
||||
}
|
||||
s.Test = func(ctx context.Context, args map[string]any) (any, error) {
|
||||
return map[string]any{"passed": false, "failed": []Failure{{Package: "b", Test: "t", Cause: "boom", Output: "boom", Full: strings.Join(lines, "\n")}}}, nil
|
||||
}
|
||||
r.fetch["modules"] = []string{"b"}
|
||||
ctx := context.WithValue(context.Background(), phaseKey{}, &phases{step: delivery.Step{ID: "s1", Node: "ace"}, run: "r1"})
|
||||
out, err = RunAsk(ctx, s, ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit}, Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var res struct{ Failed []Failure }
|
||||
_ = json.Unmarshal(out.(delivery.CheckAnswer).Levels[0].Tests[0], &res)
|
||||
if f := res.Failed[0]; f.Full != "" || f.Step != "s1" || f.Module != "mesh-build-node" || f.Machine != "ace" || f.Run != "r1" || len(said) < 2 || said[0].Event != "log.r1.s1" {
|
||||
t.Fatalf("failure %+v, %d events (first %+v): want its address, no whole output, and its log in several events", f, len(said), said[0].Event)
|
||||
}
|
||||
read := func(events string, from uint64, limit int) (*stdio.StreamPage, error) {
|
||||
p := &stdio.StreamPage{Last: uint64(len(said))}
|
||||
for _, e := range said {
|
||||
if e.Sequence >= max(from, 1) && e.Event == events && len(p.Entries) < limit {
|
||||
p.Entries = append(p.Entries, e)
|
||||
}
|
||||
}
|
||||
if n := len(p.Entries); n > 0 && p.Entries[n-1].Sequence < p.Last {
|
||||
p.Next = p.Entries[n-1].Sequence + 1
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
var got []string
|
||||
pages := 0
|
||||
for from := uint64(0); ; pages++ {
|
||||
page, err := ReadStepLog(read, "r1", "s1", from)
|
||||
if err != nil || page.Gap || page.Bytes > LogPageBytes {
|
||||
t.Fatalf("page from %d: %+v, %v", from, page.Next, err)
|
||||
}
|
||||
got = append(got, page.Lines...)
|
||||
if from = page.Next; from == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
// Each page opens with the failure it reads: "=== <package> <test>".
|
||||
own := slices.DeleteFunc(slices.Clone(got), func(l string) bool { return l == "=== b t" })
|
||||
if pages < 2 || got[0] != "=== b t" || strings.Join(own, "\n") != strings.Join(lines, "\n") {
|
||||
t.Errorf("%d pages, %d lines read back, want %d in several pages, headed by the failure", pages+1, len(own), len(lines))
|
||||
}
|
||||
if _, err := ReadStepLog(read, "r1", "s1.>", 0); err == nil {
|
||||
t.Error("a step id that is no token was read")
|
||||
}
|
||||
}
|
||||
|
||||
+24
-1
@@ -29,6 +29,16 @@ type Failure struct {
|
||||
// Output is the failing test's own output, its last lines up to OutputBytes, Cause first: an older checker reads
|
||||
// only its first line.
|
||||
Output string `json:"output,omitempty"`
|
||||
// Full is the failing test's whole output, its last FullBytes, passing lines kept: it crosses from a shut-off level
|
||||
// process to the step, which says it on the build module's own log stream and empties it, so it never reaches the
|
||||
// step's result (narrate).
|
||||
Full string `json:"full_output,omitempty"`
|
||||
// Step, Module, Machine and Run say which build step said the whole output on its log, of which build module on which
|
||||
// machine, under which run (delivery.StepLogEvent); set when it was said, absent when it was not.
|
||||
Step string `json:"step,omitempty"`
|
||||
Module string `json:"module,omitempty"`
|
||||
Machine string `json:"machine,omitempty"`
|
||||
Run string `json:"run,omitempty"`
|
||||
}
|
||||
|
||||
// OutputBytes is the most a failure keeps of its output, and LineBytes the most of one line: four kilobytes holds a long
|
||||
@@ -109,6 +119,19 @@ func Clip(lines []string, cause string) string {
|
||||
return strings.Join(kept, "\n")
|
||||
}
|
||||
|
||||
// FullOf joins a failure's whole output, its last FullBytes when longer, starting on a whole line.
|
||||
func FullOf(lines []string) string {
|
||||
s := strings.Join(lines, "\n")
|
||||
if len(s) <= FullBytes {
|
||||
return s
|
||||
}
|
||||
tail := s[len(s)-FullBytes:]
|
||||
if i := strings.IndexByte(tail, '\n'); i >= 0 {
|
||||
tail = tail[i+1:]
|
||||
}
|
||||
return fmt.Sprintf("... (%d earlier bytes)\n", len(s)-len(tail)) + strings.ToValidUTF8(tail, "")
|
||||
}
|
||||
|
||||
// CutLine cuts a line to LineBytes, on a character boundary.
|
||||
func CutLine(l string) string {
|
||||
if len(l) <= LineBytes {
|
||||
@@ -143,5 +166,5 @@ func ConsoleFailure(pkg, test, out string) Failure {
|
||||
cause = test + " failed and wrote nothing that says why"
|
||||
}
|
||||
cause = CutLine(cause)
|
||||
return Failure{Package: pkg, Test: test, Cause: cause, Output: Clip(lines, cause)}
|
||||
return Failure{Package: pkg, Test: test, Cause: cause, Output: Clip(lines, cause), Full: FullOf(lines)}
|
||||
}
|
||||
|
||||
@@ -211,7 +211,7 @@ func (r *goReader) subtestTakesTheCause(pkg, test string) bool {
|
||||
took := false
|
||||
for i, f := range r.res.Failed {
|
||||
if f.Package == pkg && strings.HasPrefix(f.Test, test+"/") && f.Cause == noMessage {
|
||||
r.res.Failed[i].Cause, r.res.Failed[i].Output = cause, Clip(r.testOut[key], cause)
|
||||
r.res.Failed[i].Cause, r.res.Failed[i].Output, r.res.Failed[i].Full = cause, Clip(r.testOut[key], cause), FullOf(r.testOut[key])
|
||||
took = true
|
||||
}
|
||||
}
|
||||
@@ -231,7 +231,7 @@ func (r *goReader) testFailure(pkg, test string) Failure {
|
||||
if cause == "" {
|
||||
cause = noMessage
|
||||
}
|
||||
return Failure{Package: pkg, Test: test, Output: Clip(r.testOut[key], cause), Cause: cause}
|
||||
return Failure{Package: pkg, Test: test, Output: Clip(r.testOut[key], cause), Cause: cause, Full: FullOf(r.testOut[key])}
|
||||
}
|
||||
|
||||
// goSummaryLine says a line is go test's summary of a package, in go's exact forms only: "PASS" or "FAIL" alone,
|
||||
@@ -380,7 +380,7 @@ func (r *goReader) packageFailure(name string) Failure {
|
||||
if cause == "" {
|
||||
cause = "the package failed with no test to name and no panic, signal or build error in its output"
|
||||
}
|
||||
return Failure{Package: name, Test: goTestNames(running), Output: Clip(lines, cause), Cause: cause}
|
||||
return Failure{Package: name, Test: goTestNames(running), Output: Clip(lines, cause), Cause: cause, Full: FullOf(lines)}
|
||||
}
|
||||
|
||||
var goFileAndLine = regexp.MustCompile(`^\S+\.go:\d+: `)
|
||||
|
||||
@@ -34,6 +34,9 @@ type phases struct {
|
||||
ended bool
|
||||
logf func(format string, args ...any)
|
||||
prefix string
|
||||
// run is the ask's run (delivery.StepAsk.Run), and log the step's log (steplog.go), made at the first line said.
|
||||
run string
|
||||
log *stepLog
|
||||
}
|
||||
|
||||
func (r *phases) enter(phase string) {
|
||||
|
||||
@@ -619,7 +619,7 @@ func (p *Puller) run(ctx context.Context, msg *Pulled, ask delivery.StepAsk, key
|
||||
}
|
||||
}
|
||||
}()
|
||||
ph := &phases{put: p.Put, key: key, step: step, logf: p.Logf, prefix: fmt.Sprintf("the %s ask %s on %s", ask.Kind, ask.ID, p.Node)}
|
||||
ph := &phases{put: p.Put, key: key, step: step, run: ask.Run, logf: p.Logf, prefix: fmt.Sprintf("the %s ask %s on %s", ask.Kind, ask.ID, p.Node)}
|
||||
result, err := protect(ctx, func(ctx context.Context) (any, error) {
|
||||
return p.Run(context.WithValue(ctx, phaseKey{}, ph), ask)
|
||||
})
|
||||
|
||||
@@ -86,7 +86,7 @@ func finish(put Put, key string, step delivery.Step, result any, err error, logf
|
||||
if merr != nil {
|
||||
step.State, step.Reason = delivery.StepFailed, "the result is not JSON: "+merr.Error()
|
||||
} else {
|
||||
step.Result = raw
|
||||
step.Result = (*stepLog)(nil).narrate(raw, nil) // a step run by hand keeps no whole output in its result
|
||||
}
|
||||
}
|
||||
case errors.As(err, &ref):
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -86,6 +87,21 @@ func TestStepEndsPassedFailedOrRefusedOnItsKey(t *testing.T) {
|
||||
result string
|
||||
}{
|
||||
{"result", func(context.Context) (any, error) { return map[string]string{"sha": "abc"}, nil }, delivery.StepPassed, "", `{"sha":"abc"}`},
|
||||
// A step run by hand says no log: a failure's whole output never reaches its result.
|
||||
{"no whole output", func(context.Context) (any, error) {
|
||||
return map[string]any{"failed": []map[string]string{{"package": "p", "full_output": "all of it"}}}, nil
|
||||
}, delivery.StepPassed, "", `{"failed":[{"package":"p"}]}`},
|
||||
// A log the bus does not take is tried once in a step, then left: the step passes, its failures name no step.
|
||||
{"a dead log", func(context.Context) (any, error) {
|
||||
tries := 0
|
||||
l := &stepLog{say: func(string, any) error { tries++; return errors.New("timeout") }, id: "s1", run: "r1"}
|
||||
raw := l.narrate(json.RawMessage(`{"failed":[{"package":"p","full_output":"a"},{"package":"q","full_output":"b"}]}`), nil)
|
||||
raw = l.narrate(raw, nil)
|
||||
if tries != 1 {
|
||||
return nil, fmt.Errorf("%d publishes, want one", tries)
|
||||
}
|
||||
return raw, nil
|
||||
}, delivery.StepPassed, "", `{"failed":[{"package":"p"},{"package":"q"}]}`},
|
||||
{"error", func(context.Context) (any, error) { return nil, errors.New("exit 2") }, delivery.StepFailed, "exit 2", ""},
|
||||
{"refusal", func(context.Context) (any, error) {
|
||||
return nil, &Refusal{Text: "go 1.27.1 installed, the recipe builds with go 1.27.0", Want: "1.27.0", Have: "1.27.1"}
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
package buildstep
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// A build step's log on its build module's own stream (the operator, 2026-10-30: a verdict cut a failure to its last four
|
||||
// kilobytes and named no place that held the rest, so a check's cause was guessed; novox/hq ADR 0157 for the shape: a
|
||||
// build says what it does on the bus, under its id, in sequenced lines, read back from the stream). The step's result
|
||||
// keeps each failure's Output clipped to OutputBytes, the summary; the failure's whole output, up to FullBytes, is said
|
||||
// as delivery.StepLog events on delivery.StepLogEvent(run, step), at most LogEventBytes each, on the stream the module
|
||||
// declares for `log.**` (max age and size in its manifest, dropping its oldest). ReadStepLog reads it back in pages.
|
||||
|
||||
const (
|
||||
// FullBytes is the most of one failure's output that is said whole (its last bytes).
|
||||
FullBytes = 1 << 20
|
||||
// LogEventBytes is the most lines one log event holds, and LogPageBytes the most one page of a read answers.
|
||||
LogEventBytes = 64 << 10
|
||||
LogPageBytes = 64 << 10
|
||||
)
|
||||
|
||||
// stepLog says one step's log: its node, id and run, the build module, how to say an event, and the next line's number.
|
||||
type stepLog struct {
|
||||
say func(event string, body any) error
|
||||
module, node string
|
||||
id, run string
|
||||
seq int
|
||||
// dead says a publish failed: the step says nothing more on its log, so a bus that does not take its lines costs the
|
||||
// step at most one publish's timeout, and never fails it (the 2026-10-30 review).
|
||||
dead bool
|
||||
}
|
||||
|
||||
// stepLog is the log of the step whose ask runs under ctx, or nil when no ask runs (a tool called by hand) or the module
|
||||
// says no log.
|
||||
func (s AskSteps) stepLog(ctx context.Context) *stepLog {
|
||||
r, ok := ctx.Value(phaseKey{}).(*phases)
|
||||
if !ok || r == nil || s.Narrate == nil {
|
||||
return nil
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.log == nil {
|
||||
r.log = &stepLog{say: s.Narrate, module: s.Module, node: r.step.Node, id: r.step.ID, run: r.run}
|
||||
}
|
||||
return r.log
|
||||
}
|
||||
|
||||
// narrate says every failure's Full of a step answer's "failed" on the step's log and empties it, setting the failure's
|
||||
// Step, Module, Machine and Run; a failure whose lines could not all be said keeps no address, that is said once through
|
||||
// logf, and the step says nothing more on its log (dead). With l nil, Full is only emptied: the step's result never holds it.
|
||||
func (l *stepLog) narrate(raw json.RawMessage, logf func(string, ...any)) json.RawMessage {
|
||||
if !strings.Contains(string(raw), `"full_output"`) {
|
||||
return raw
|
||||
}
|
||||
var answer map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &answer); err != nil || answer["failed"] == nil {
|
||||
return raw
|
||||
}
|
||||
var failed []map[string]json.RawMessage
|
||||
if err := json.Unmarshal(answer["failed"], &failed); err != nil {
|
||||
return raw
|
||||
}
|
||||
for i, f := range failed {
|
||||
var full string
|
||||
_ = json.Unmarshal(f["full_output"], &full)
|
||||
delete(failed[i], "full_output")
|
||||
if full == "" || l == nil || l.dead {
|
||||
continue
|
||||
}
|
||||
var name struct {
|
||||
Package string `json:"package"`
|
||||
Test string `json:"test"`
|
||||
}
|
||||
b, _ := json.Marshal(f)
|
||||
_ = json.Unmarshal(b, &name)
|
||||
if err := l.say1(name.Package, name.Test, strings.Split(full, "\n")); err != nil {
|
||||
l.dead = true
|
||||
if logf != nil {
|
||||
logf("the step %s says no more on its log, from the whole output of %s %s on: %v", l.id, name.Package, name.Test, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
for field, v := range map[string]string{"step": l.id, "module": l.module, "machine": l.node, "run": l.run} {
|
||||
failed[i][field], _ = json.Marshal(v)
|
||||
}
|
||||
}
|
||||
answer["failed"], _ = json.Marshal(failed)
|
||||
out, err := json.Marshal(answer)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// say1 says one failure's lines, in events of at most LogEventBytes, numbered on from the step's last line.
|
||||
func (l *stepLog) say1(pkg, test string, lines []string) error {
|
||||
event := delivery.StepLogEvent(l.run, l.id)
|
||||
for len(lines) > 0 {
|
||||
n, size := 0, 0
|
||||
for n < len(lines) && (n == 0 || size+len(lines[n])+1 <= LogEventBytes) {
|
||||
if len(lines[n]) > LogEventBytes {
|
||||
lines[n] = strings.ToValidUTF8(lines[n][:LogEventBytes], "") // one line longer than an event is cut
|
||||
}
|
||||
size += len(lines[n]) + 1
|
||||
n++
|
||||
}
|
||||
if err := l.say(event, delivery.StepLog{Seq: l.seq + 1, Package: pkg, Test: test, Lines: lines[:n]}); err != nil {
|
||||
return err
|
||||
}
|
||||
l.seq += n
|
||||
lines = lines[n:]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// StreamRead reads a module's own stream: stdio.Stream(name).Read's shape.
|
||||
type StreamRead func(events string, from uint64, limit int) (*stdio.StreamPage, error)
|
||||
|
||||
// StepLogPage is one page of a step's log: the lines from the stream sequence From on, headed "=== <package> <test>"
|
||||
// where a failure's lines start on the page, Next the sequence to read on from (0 when nothing follows yet; a step still running may
|
||||
// say more), Last the stream's last sequence, and Gap when a line number was skipped (the stream dropped it with age).
|
||||
type StepLogPage struct {
|
||||
Run string `json:"run"`
|
||||
Step string `json:"step"`
|
||||
Event string `json:"event"`
|
||||
From uint64 `json:"from"`
|
||||
Lines []string `json:"lines"`
|
||||
Bytes int `json:"bytes"`
|
||||
Next uint64 `json:"next"`
|
||||
Last uint64 `json:"last"`
|
||||
Gap bool `json:"gap,omitempty"`
|
||||
}
|
||||
|
||||
// ReadStepLog reads one page of about LogPageBytes of the log of the step id under run, from the stream sequence from on.
|
||||
func ReadStepLog(read StreamRead, run, id string, from uint64) (StepLogPage, error) {
|
||||
if !stepToken(run) || !stepToken(id) {
|
||||
return StepLogPage{}, fmt.Errorf("a step's log is named by its run and its step id, each one token of letters, digits and -: %q, %q", run, id)
|
||||
}
|
||||
event := delivery.StepLogEvent(run, id)
|
||||
page := StepLogPage{Run: run, Step: id, Event: event, From: from, Lines: []string{}}
|
||||
seq, test := 0, "\x00"
|
||||
for {
|
||||
got, err := read(event, from, 16)
|
||||
if err != nil {
|
||||
return page, fmt.Errorf("the log %s cannot be read: %w", event, err)
|
||||
}
|
||||
page.Last = got.Last
|
||||
for _, e := range got.Entries {
|
||||
var l delivery.StepLog
|
||||
if err := json.Unmarshal(e.Body, &l); err != nil {
|
||||
return page, fmt.Errorf("the log %s holds at %d what is no log event: %v", event, e.Sequence, err)
|
||||
}
|
||||
size := 0
|
||||
for _, line := range l.Lines {
|
||||
size += len(line) + 1
|
||||
}
|
||||
if page.Bytes > 0 && page.Bytes+size > LogPageBytes {
|
||||
page.Next = e.Sequence
|
||||
return page, nil
|
||||
}
|
||||
if seq > 0 && l.Seq != seq {
|
||||
page.Gap = true
|
||||
}
|
||||
if name := l.Package + " " + l.Test; name != test {
|
||||
page.Lines = append(page.Lines, "=== "+strings.TrimSpace(name))
|
||||
test = name
|
||||
}
|
||||
page.Lines = append(page.Lines, l.Lines...)
|
||||
page.Bytes += size
|
||||
seq = l.Seq + len(l.Lines)
|
||||
}
|
||||
if got.Next == 0 {
|
||||
return page, nil
|
||||
}
|
||||
from = got.Next
|
||||
}
|
||||
}
|
||||
|
||||
// stepToken is one subject token of a run or a step id.
|
||||
func stepToken(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// LogStreamOf is the local name of a build module's own log stream, <language>-build-logs (a stream's bus name is
|
||||
// mesh-wide): LogStreamOf names it.
|
||||
func LogStreamOf(language string) string { return language + "-build-logs" }
|
||||
|
||||
// LogTool is a build module's tool that reads one step's log from its own stream, {run, step, from}: a failed test's
|
||||
// whole output, which the step's result and the checker's verdict keep cut. The stream is the mesh's, so any machine's
|
||||
// instance answers for a step of any machine.
|
||||
func LogTool(name string, read StreamRead) stdio.Tool {
|
||||
return stdio.Tool{Name: name,
|
||||
Description: "The full build log of one build step: the whole output of every test that failed in it, which a check's " +
|
||||
"verdict and the step's result keep cut to their last lines (\"... (N earlier lines)\"). One page of about 64 KB; " +
|
||||
"read on with from = the answer's next until it is 0 (a step still running may say more: read again from next). " +
|
||||
"run is the check (its heads hash) or the delivery plan the step ran for, \"none\" when the ask named none; step " +
|
||||
"is the step id; both are in a failed verdict (checker_failure, checker_pull). Kept 14 days. Read-only.",
|
||||
Input: map[string]any{"type": "object", "required": []string{"run", "step"}, "properties": map[string]any{
|
||||
"run": map[string]any{"type": "string", "description": "the check or delivery plan the step ran for, or none"},
|
||||
"step": map[string]any{"type": "string", "description": "the step id (the ask's id)"},
|
||||
"from": map[string]any{"type": "integer", "description": "the stream sequence to read on from: the last answer's next; 0 is the start"}}},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
run, _ := args["run"].(string)
|
||||
step, _ := args["step"].(string)
|
||||
from, _ := args["from"].(float64)
|
||||
if from < 0 {
|
||||
return nil, fmt.Errorf("from is a stream sequence, 0 or more")
|
||||
}
|
||||
return ReadStepLog(read, run, step, uint64(from))
|
||||
}}
|
||||
}
|
||||
+1
-1
@@ -107,7 +107,7 @@ func ReadTAP(pkg, script, dir, report, console string) []Failure {
|
||||
if dir != "" {
|
||||
file = strings.TrimPrefix(file, strings.TrimSuffix(dir, "/")+"/")
|
||||
}
|
||||
f := Failure{Package: pkg, Test: name, File: file, Cause: cause, Output: Clip(block, cause)}
|
||||
f := Failure{Package: pkg, Test: name, File: file, Cause: cause, Output: Clip(block, cause), Full: FullOf(block)}
|
||||
if fields["failureType"] == "cancelledByParent" {
|
||||
cancelled = append(cancelled, f)
|
||||
continue
|
||||
|
||||
@@ -75,12 +75,11 @@ 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 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.
|
||||
// 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.
|
||||
func narrow(ctx context.Context, run forgekey.Runner, env []string, dir, commit, mdir string, raw []byte, mt *ModuleTree) error {
|
||||
m, err := catalogue.Decode(raw)
|
||||
m, err := catalogue.ParseManifestForInputs(raw)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -75,17 +75,20 @@ func TestModuleTreesFailsNamingANamedPathAbsentAtTheCommit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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).
|
||||
// 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).
|
||||
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.Decode([]byte(raw)); err != nil {
|
||||
t.Fatalf("the preview's read refuses the manifest: %v", err)
|
||||
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)
|
||||
}
|
||||
writeTreeFile(t, root, "module.json", raw)
|
||||
run := func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
|
||||
|
||||
+12
-17
@@ -187,17 +187,12 @@ func HeldCredential(w WantedAccount, password string) ([]byte, error) {
|
||||
Claims: w.Claims})
|
||||
}
|
||||
|
||||
// **The lease write** (novox/hq ADR 0561 §3, §6). The controller's lease is the one key `holder` of its bucket
|
||||
// (LeaseBucket). The operator's credential hands it away by writing a value that names its new holder in `handed-to`;
|
||||
// while the key holds that, the grants part creates the durable consumers an account needs, the assigner reads the grants
|
||||
// part's grants (delivery.GrantSource), and the vault retires and rotates accounts. A controller's value, a key deleted or
|
||||
// expired, or one that cannot be read or parsed, turn those off. No clock and no setting turns it.
|
||||
//
|
||||
// **Minting is no longer on the lease** (issue 774, research 072's gap "vault mints after the lease"): the vault is the
|
||||
// one issuer of every account the grants part wants, whoever holds the lease, and the controller mints none once a vault
|
||||
// is installed (it forwards `module issue` to the vault's issue-account). One issuer at every moment holds by who mints,
|
||||
// not by the lease: an account whose value the vault holds is never minted anew, and a value the controller hands or
|
||||
// moves never replaces one the vault minted.
|
||||
// **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.
|
||||
@@ -226,24 +221,24 @@ type LeaseHolder struct {
|
||||
func LeaseHandedAway(raw []byte, found bool, readErr error) (bool, string) {
|
||||
switch {
|
||||
case readErr != nil:
|
||||
return false, "the controller's lease cannot be read (" + readErr.Error() + "), so it is not handed away"
|
||||
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 it is not handed away"
|
||||
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 it is not handed away"
|
||||
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 + "), so it is not handed away (ADR 0561 §3)"
|
||||
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 it is not handed away"
|
||||
h.HandedTo + "), so nothing is minted"
|
||||
}
|
||||
return true, "the lease is handed to " + h.HandedTo + " (ADR 0561 §3)"
|
||||
return true, "the lease is handed to " + h.HandedTo + ": the vault mints every new account (ADR 0561 §1, §3)"
|
||||
}
|
||||
|
||||
@@ -42,12 +42,16 @@ func ControllerAnnouncement(node string) announce.States {
|
||||
return s
|
||||
}
|
||||
|
||||
// ReadsAnnouncedBy splits one reader module's `reads` (`<owner>.<name>`) by what each owner announced: granted when the
|
||||
// owner announced the state readable (by this reader, where it named its readers: announce.State.Readers), when the
|
||||
// composer names the reader among the state's readers (NamedReaders, ADR 0547 clause 9), or when the owner announced
|
||||
// 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.
|
||||
// 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.
|
||||
func ReadsAnnouncedBy(reader string, reads []string, announced map[string]announce.States) (granted, refused []string) {
|
||||
for _, r := range reads {
|
||||
owner, name, ok := strings.Cut(r, ".")
|
||||
|
||||
@@ -13,10 +13,7 @@ import (
|
||||
"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"
|
||||
"git.novox.be/novox/mesh-sdk/go/membership"
|
||||
"git.novox.be/novox/mesh-sdk/go/provisions"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
@@ -36,17 +33,8 @@ var createdAdvisory = regexp.MustCompile(`"\$JS\.EVENT\.ADVISORY\.STREAM\.CREATE
|
||||
// grants part's list is the one the bus loads; a hand-back loads the controller's, which lacks these reads, and the nodes
|
||||
// keep applying without composing their own until the next take-over. Each principal's hearing of its buckets' creation
|
||||
// (issue 1790) is the other row beyond, matched by createdAdvisory.
|
||||
//
|
||||
// And the watchman's dead letters and watchdogs (novox/hq ADR 0564 clauses 4, 5, issue 1952 S5): its own consumer on
|
||||
// DEAD_LETTER_NOTICES, a read of DEAD_LETTERS, and the words its watchdogs hear, the controller's jobs until the lease
|
||||
// write.
|
||||
func beyondTheControllers(user string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
if strings.HasSuffix(user, "."+WatchmanModule) {
|
||||
for _, s := range append(WatchmanDeadLetterGrants(nil), WatchmanHears()...) {
|
||||
out[s] = true
|
||||
}
|
||||
}
|
||||
node, ok := strings.CutPrefix(user, "node.")
|
||||
if !ok {
|
||||
return out
|
||||
@@ -60,22 +48,6 @@ func beyondTheControllers(user string) map[string]bool {
|
||||
// mayBeyondTheControllers is what the copy may grant beyond the controller's where the records allow it, and need not
|
||||
// where they do not: a node's ask of the vault's `own` while the vault serves it (issue 1126, ADR 0550 §5).
|
||||
func mayBeyondTheControllers(user string) map[string]bool {
|
||||
// A runtime's read of its membership from the grants part's state (novox/hq ADR 0571 clause 7): its own key, or the
|
||||
// tool runner's node's.
|
||||
if parts := strings.Split(user, "."); len(parts) == 2 && parts[0] != "node" {
|
||||
key := user
|
||||
if parts[1] == RuntimeModule {
|
||||
key = parts[0] + ".*"
|
||||
}
|
||||
out := map[string]bool{}
|
||||
if membership.GrantsBucket != GrantsMembershipsBucket || membership.GrantsKey("a", "b") != MembershipKey("a", "b") {
|
||||
panic("the membership package's grants bucket does not agree with buscompose's") // FollowMembership reads it
|
||||
}
|
||||
for _, s := range readGrants(GrantsMembershipsBucket, key) {
|
||||
out[s] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
if _, ok := strings.CutPrefix(user, "node."); !ok {
|
||||
return nil
|
||||
}
|
||||
@@ -93,15 +65,7 @@ func engineBeyond(user, g string) bool {
|
||||
}
|
||||
return strings.HasPrefix(g, "mesh.seat."+EngineSeat+".") && strings.Contains(g, "."+node) ||
|
||||
strings.HasPrefix(g, "$SRV.") || strings.Contains(g, ".node-engine-"+node) ||
|
||||
strings.Contains(g, "KV_"+EngineActsBucket) || strings.HasPrefix(g, "$KV."+EngineActsBucket+"."+node+".") ||
|
||||
// And its node's declared packages (novox/hq ADR 0565 clause 6).
|
||||
strings.Contains(g, "KV_"+EnginePackagesBucket) || g == "$KV."+EnginePackagesBucket+"."+node ||
|
||||
// And its own health report (issue 774).
|
||||
strings.Contains(g, "KV_"+delivery.NodeHealthBucket) || g == "$KV."+delivery.NodeHealthBucket+"."+node ||
|
||||
// And the provisions of its node's modules, read (novox/hq ADR 0570 clause 6).
|
||||
strings.Contains(g, "KV_"+provisions.ProvisionsBucket) ||
|
||||
// And its node's placed modules' data items, its own node's keys (novox/hq ADR 0577 clause 1).
|
||||
strings.Contains(g, "KV_"+EngineDataBucket) || strings.HasPrefix(g, "$KV."+EngineDataBucket+"."+node+".")
|
||||
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
|
||||
@@ -157,16 +121,11 @@ func grantsAsTheControllers(t *testing.T, got, want string) {
|
||||
}
|
||||
intended := beyondTheControllers(user)
|
||||
for g := range was {
|
||||
// A runtime's direct get of its membership from the assignments stream went with the stream (research 072
|
||||
// row 33): it reads the grants part's state memberships instead (ADR 0571 clause 7).
|
||||
if !now[g] && !strings.HasPrefix(g, "$JS.API.DIRECT.GET."+AssignmentsStream+".mesh.assignment."+strings.SplitN(user, ".", 2)[0]+".") {
|
||||
if !now[g] {
|
||||
t.Errorf("%s: the copy does not grant %s, which the controller's does", user, g)
|
||||
}
|
||||
}
|
||||
for g := range now {
|
||||
if strings.HasPrefix(g, "$JS.API.DIRECT.GET."+AssignmentsStream+".") && !strings.HasSuffix(user, "."+GrantsModule) {
|
||||
t.Errorf("%s: the copy grants %s, a read of the assignments stream, which goes at the lease write", user, g)
|
||||
}
|
||||
if !was[g] && !intended[g] && !mayBeyondTheControllers(user)[g] && !createdAdvisory.MatchString(`"`+g+`", `) &&
|
||||
!createsAnAccountsConsumer(user, g) && !engineBeyond(user, g) {
|
||||
t.Errorf("%s: the copy grants %s beyond the controller's, and no decision names it", user, g)
|
||||
@@ -253,7 +212,6 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
|
||||
if err := json.Unmarshal(want[i].Memberships, &wasM); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
heldEventsBeyond(gotM, wasM)
|
||||
if !reflect.DeepEqual(gotM, wasM) {
|
||||
t.Errorf("the memberships differ from the controller's:\n%s", raw)
|
||||
}
|
||||
@@ -332,21 +290,6 @@ 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())
|
||||
@@ -579,34 +522,6 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
|
||||
t.Errorf("%s reads the lease: %v, want %v", reader, granted, want)
|
||||
}
|
||||
}
|
||||
// Issue 774 (sweep item 2): Users judges every read for its reader again, whatever split the caller made: the
|
||||
// controller's own reader-less copy refused the vault the lease and the grants part's accounts it is named for.
|
||||
announced["mesh-grants"] = announce.States{Module: "mesh-grants", States: []announce.State{{Name: "accounts", Readers: []string{VaultSeat}}}}
|
||||
announced["disk-load"] = announce.States{Module: "disk-load", States: []announce.State{{Name: "readings"}}}
|
||||
judged, err := Users(Records{Nodes: []string{"a"}, Announced: announced, Assigned: map[string][]Declared{"a": {
|
||||
{Module: VaultSeat, ReadsRefused: []string{ControllerLease, GrantsAccounts}},
|
||||
{Module: "mesh-config", ReadsRefused: []string{ControllerLease}},
|
||||
{Module: "restic", ReadsRefused: []string{"disk-load.readings"}},
|
||||
{Module: "messenger", Reads: []string{ControllerLease, 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, ",")
|
||||
}
|
||||
}
|
||||
if reads[VaultSeat] != ControllerLease+","+GrantsAccounts || reads["mesh-config"] != ControllerLease || 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)
|
||||
if cfg.Durable != "a_m" || cfg.AckPolicy != jetstream.AckExplicitPolicy || cfg.AckWait != 30*time.Second ||
|
||||
@@ -633,8 +548,6 @@ 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 {
|
||||
@@ -642,328 +555,3 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// healthChecks are the health holders' credential cases (ADR 0564 clauses 2, 5, 8; Limits 2, 4, 8; issue 1952 S5).
|
||||
func healthChecks(t *testing.T) {
|
||||
t.Helper()
|
||||
announced := map[string]announce.States{ControllerSeat: ControllerAnnouncement("novox"),
|
||||
"mesh-healers": {Module: "mesh-healers", States: []announce.State{{Name: "attempts", Readable: true}, {Name: "would", Readable: true}}}}
|
||||
for _, c := range []struct {
|
||||
reader, read string
|
||||
may bool
|
||||
}{
|
||||
{"mesh-conditions", ControllerLease, true}, {"mesh-healers", ControllerLease, true}, {WatchmanModule, ControllerLease, true},
|
||||
{"mesh-self-check", ControllerLease, false}, {"messenger", ControllerLease, false}, {ArtifactStoreTools, ControllerLease, true},
|
||||
{"mesh-conditions", ControllerConditions, true}, {"mesh-healers", ControllerConditions, false}, {"messenger", ControllerConditions, false},
|
||||
{"mesh-healers", ControllerHandActs, true}, {"mesh-conditions", ControllerHandActs, false},
|
||||
// ADR 0565 clauses 10, 11 (issue 1953 L5-S4): mesh-hand-acts reads the controller's log beside its own.
|
||||
{"mesh-hand-acts", ControllerHandActs, true},
|
||||
{"mesh-conditions", "mesh-healers.attempts", true}, {"mesh-conditions", HealersWould, false}, {"mesh-delivery-view", HealersWould, false},
|
||||
// ADR 0577 clause 1: the node-engine's data items are disk-load's alone to read.
|
||||
{"disk-load", delivery.EngineDataRead, true}, {"restic", delivery.EngineDataRead, false},
|
||||
} {
|
||||
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", ControllerConditions}},
|
||||
{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.RemovesControllerDeadLetterConsumer, catalogue.RemovesEngineActs, catalogue.RemovesAssignmentsStream}}, {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"},
|
||||
}
|
||||
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"},
|
||||
"a.nats.other": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$KV.mesh-host_acts.g14.a1"},
|
||||
"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)
|
||||
}
|
||||
if u.Kind == KindNode && (!MayPublish(perms, "$KV.mesh-host_packages.a") || MayPublish(perms, "$KV.mesh-host_packages.b")) {
|
||||
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())
|
||||
}
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// heldEventsBeyond takes out of the copy's memberships the one row they list beyond the controller's on purpose (issue
|
||||
// 1952): a held seat's own events in its holder's seat traffic, so a module the tool runner carries may say them. The
|
||||
// controller's composer is frozen; any other difference is still a fault.
|
||||
func heldEventsBeyond(got, was any) {
|
||||
gotAll, _ := got.(map[string]any)
|
||||
wasAll, _ := was.(map[string]any)
|
||||
for key, m := range gotAll {
|
||||
gm, _ := m.(map[string]any)
|
||||
traffic, _ := gm["seat-traffic"].(map[string]any)
|
||||
if traffic == nil {
|
||||
continue
|
||||
}
|
||||
listedBefore := map[string]bool{}
|
||||
if wm, _ := wasAll[key].(map[string]any); wm != nil {
|
||||
if wt, _ := wm["seat-traffic"].(map[string]any); wt != nil {
|
||||
for _, p := range asStrings(wt["publish"]) {
|
||||
listedBefore[p] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
var kept []any
|
||||
for _, p := range asStrings(traffic["publish"]) {
|
||||
if parts := strings.Split(p, "."); !listedBefore[p] && len(parts) == 5 && parts[0] == "mesh" && parts[1] == "seat" && parts[3] == "event" {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, p)
|
||||
}
|
||||
if kept == nil {
|
||||
delete(traffic, "publish")
|
||||
} else {
|
||||
traffic["publish"] = kept
|
||||
}
|
||||
if len(traffic) == 0 {
|
||||
delete(gm, "seat-traffic")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func asStrings(v any) []string {
|
||||
list, _ := v.([]any)
|
||||
out := make([]string, 0, len(list))
|
||||
for _, x := range list {
|
||||
if s, ok := x.(string); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package buscompose
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -13,6 +14,9 @@ func TestDeclaredStreamsAreCreatedAsTheControllerAssertsThem(t *testing.T) {
|
||||
keyed := BucketStreamConfig(Bucket{Module: "m", Name: "presence", History: 5, TTLSeconds: 60, KeyTTL: true})
|
||||
queue := ModuleStreamConfig(ModuleStream{Module: "b", Name: "ASKS", Events: []string{"ask"}, MaxAgeDays: 7, MaxMiB: 32,
|
||||
WorkQueue: true, Duplicates: 1})
|
||||
logs := ModuleStream{Module: "mesh-build-go", Name: "GO_BUILD_LOGS", Events: []string{"log.>"}, MaxAgeDays: 14, MaxMiB: 2048}
|
||||
lcfg := ModuleStreamConfig(logs)
|
||||
grants := strings.Join(ownStreamGrants("mesh-build-go", []ModuleStream{logs, {Module: "b", Name: "ASKS", Events: []string{"ask"}, WorkQueue: true}}), " ")
|
||||
for _, row := range []struct {
|
||||
what string
|
||||
got, want any
|
||||
@@ -37,6 +41,13 @@ func TestDeclaredStreamsAreCreatedAsTheControllerAssertsThem(t *testing.T) {
|
||||
{"queue cap", queue.MaxBytes, int64(32 << 20)},
|
||||
{"queue discards new", queue.Discard, jetstream.DiscardNew},
|
||||
{"queue duplicates", queue.Duplicates, time.Hour},
|
||||
{"queue is read by its consumer, never directly", queue.AllowDirect, false},
|
||||
{"a module's stream is read back directly", lcfg.AllowDirect, true},
|
||||
{"a module's stream drops its oldest", lcfg.Discard, jetstream.DiscardOld},
|
||||
{"a module's stream cap", lcfg.MaxBytes, int64(2048 << 20)},
|
||||
{"a module's stream age", lcfg.MaxAge, 14 * 24 * time.Hour},
|
||||
{"its owner publishes and reads it, and no other's", grants,
|
||||
"mesh.stream.mesh-build-go.event.log.> $JS.API.STREAM.INFO.GO_BUILD_LOGS $JS.API.DIRECT.GET.GO_BUILD_LOGS"},
|
||||
{"queue metadata", queue.Metadata[WorkQueueMetadata] + queue.Metadata[RestrictedMetadata], "truetrue"},
|
||||
} {
|
||||
if row.got != row.want {
|
||||
|
||||
@@ -22,10 +22,6 @@ 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}}},
|
||||
@@ -36,9 +32,6 @@ 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 {
|
||||
@@ -71,8 +64,6 @@ 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.
|
||||
@@ -84,11 +75,6 @@ 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},
|
||||
|
||||
+6
-43
@@ -3,8 +3,6 @@ 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
|
||||
@@ -24,34 +22,18 @@ 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) {
|
||||
sub = append(sub, "mesh.seat."+EngineSeat+".tool.*."+p.Node)
|
||||
pub = append(pub, "mesh.seat."+EngineSeat+".event.*."+p.Node)
|
||||
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, announcing(EngineSeat)...)
|
||||
pub = append(pub, discovering()...)
|
||||
tp, ts := seatTrafficGrants(SeatTrafficOf(asks.EngineAsker(p.Node), nil, p.Uses, nil))
|
||||
@@ -59,24 +41,5 @@ 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...)
|
||||
}
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
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).
|
||||
//
|
||||
// - The condition keeper reads the controller's open conditions until the lease write, for what clauses 8 and 9 carry
|
||||
// from them, and mesh-healers the controller's hand-act log, for S15 (Limits 2): both buckets the controller
|
||||
// announces readable by nobody, so each is read by its named reader alone. The keeper, mesh-healers and the
|
||||
// watchman read the controller's lease (Limits 8). Nobody reads mesh-healers' `would` (clause 8): what the healers
|
||||
// would have done reaches no condition. Every observer's `findings`, the healers' `attempts`, the wants and the
|
||||
// haves are read through `reads` as any announced state is; and no module writes another's state, so an observer
|
||||
// can never write the keeper's buckets (clause 2).
|
||||
// - The watchman makes its own consumer on DEAD_LETTER_NOTICES, by its own name, pulled, and reads whether the
|
||||
// controller's consumer `controller` still stands; it may neither delete nor change that consumer (clause 5). It
|
||||
// lists and shows DEAD_LETTERS, which is all it does before the lease write.
|
||||
// - The bus module's tools run on an account of their own, declared by their bundle's `bus` with `removes`
|
||||
// (catalogue.BusAccounts), which may remove only the consumer `controller` on DEAD_LETTER_NOTICES and keys of the
|
||||
// node-engine's acts bucket (Limits 4), never the backup's account `broker`, which only reads (ADR 0235).
|
||||
|
||||
// The states the composer names its readers for, beside the lease (NamedReaders).
|
||||
var (
|
||||
// ControllerConditions is the controller's open conditions, read by the keeper alone (ADR 0564 clauses 8, 9).
|
||||
ControllerConditions = ControllerSeat + ".conditions"
|
||||
// ControllerHandActs is the controller's hand-act log, read by mesh-healers alone of the four health holders (ADR
|
||||
// 0564 Limits 2).
|
||||
ControllerHandActs = ControllerSeat + ".hand-acts"
|
||||
// HealersWould is what mesh-healers would have done while the lease names a controller: read by nobody (clause 8).
|
||||
HealersWould = health.HealersModule + "." + health.WouldState
|
||||
)
|
||||
|
||||
// BusRemovalGrants are the whole authority of an account whose bus declares removals (ADR 0564 Limits 4, issue 1952
|
||||
// notes 6 and 7), each removal its grant: the controller's consumer on DEAD_LETTER_NOTICES read and deleted (the lease
|
||||
// write's step, clause 5); the node-engine's acts bucket read (STREAM.INFO with a subject filter lists what a removal
|
||||
// would take, so the tools read before they remove) and its keys removed, which nothing else may empty when it fills; the ASSIGNMENTS stream read and deleted whole (the lease
|
||||
// write, research 072 row 33). A
|
||||
// removal outside the set is refused, as the indexer's judge refuses it in a manifest (novox/hq ADR 0578 §1).
|
||||
func BusRemovalGrants(removes []string) ([]string, error) {
|
||||
var out []string
|
||||
for _, r := range removes {
|
||||
switch r {
|
||||
case catalogue.RemovesControllerDeadLetterConsumer:
|
||||
out = append(out, "$JS.API.CONSUMER.INFO."+DeadLetterNoticesStream+"."+ControllerName,
|
||||
"$JS.API.CONSUMER.DELETE."+DeadLetterNoticesStream+"."+ControllerName)
|
||||
case catalogue.RemovesEngineActs:
|
||||
out = append(out, "$JS.API.STREAM.INFO.KV_"+EngineActsBucket, "$KV."+EngineActsBucket+".>")
|
||||
case catalogue.RemovesAssignmentsStream:
|
||||
out = append(out, "$JS.API.STREAM.INFO."+AssignmentsStream, "$JS.API.STREAM.DELETE."+AssignmentsStream)
|
||||
default:
|
||||
return nil, fmt.Errorf("a bus account removes %q, which is not one of %s (novox/hq ADR 0564 Limits 4)", r,
|
||||
strings.Join([]string{catalogue.RemovesControllerDeadLetterConsumer, catalogue.RemovesEngineActs,
|
||||
catalogue.RemovesAssignmentsStream}, ", "))
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// removesAssignmentsStream is an account declared to remove the stream ASSIGNMENTS: the writers table's one exemption from
|
||||
// stream definitions being the controller's, for deleting that one stream at the lease write (research 072 row 33).
|
||||
func removesAssignmentsStream(p Principal, pattern string) bool {
|
||||
return p.Kind == KindProcess && slices.Contains(p.Removes, catalogue.RemovesAssignmentsStream) &&
|
||||
pattern == "$JS.API.STREAM.DELETE."+AssignmentsStream
|
||||
}
|
||||
|
||||
// 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), and the dead letters kept. 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 isController(p, pattern) ||
|
||||
(p.Kind == KindModule && p.Module == WatchmanModule && pattern == deadLetterPrefix+">")
|
||||
}
|
||||
+4
-62
@@ -4,7 +4,6 @@ import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
@@ -15,10 +14,8 @@ 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. 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.
|
||||
// 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.
|
||||
//
|
||||
// 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.
|
||||
@@ -40,8 +37,6 @@ 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.
|
||||
@@ -79,7 +74,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 unless the log says one (ADR 0565 §10) and no cap per key, whose message is an entry's value and 4 KiB of
|
||||
// limits, with no maximum age 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.
|
||||
@@ -90,7 +85,7 @@ func (l Log) Config() jetstream.StreamConfig {
|
||||
Subjects: []string{l.Subject() + ".>"},
|
||||
Storage: jetstream.FileStorage,
|
||||
Retention: jetstream.LimitsPolicy,
|
||||
MaxAge: time.Duration(l.MaxAgeDays) * 24 * time.Hour,
|
||||
MaxAge: 0,
|
||||
MaxMsgs: -1,
|
||||
MaxMsgsPerSubject: -1,
|
||||
MaxBytes: l.MaxBytes(),
|
||||
@@ -140,59 +135,6 @@ 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,9 +24,6 @@ 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
|
||||
@@ -61,7 +58,7 @@ func MembershipFor(node string, d Declared, where Placements) Membership {
|
||||
}
|
||||
}
|
||||
m.State = stateIssuedFor(d, node)
|
||||
m.Logs = append(logsIssuedFor(d), readLogsIssuedFor(withReadLogs(d, where.Logs))...)
|
||||
m.Logs = logsIssuedFor(d)
|
||||
m.Streams = streamsIssuedFor(d)
|
||||
m.Hears = HeardIssuedFor(node, d)
|
||||
m.Waits = waitsIssuedFor(d)
|
||||
@@ -106,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, Logs: DeclaredLogs(r)}
|
||||
p := Placements{Nodes: map[string][]string{}, Interchangeable: interchangeable}
|
||||
for node, declared := range r.Assigned {
|
||||
for _, d := range declared {
|
||||
p.Nodes[d.Module] = append(p.Nodes[d.Module], node)
|
||||
|
||||
@@ -89,6 +89,8 @@ func (s ModuleStream) Stream() Stream {
|
||||
Why: why,
|
||||
// Said on the bus, so a dead letter kept from it ages out with it (novox/hq ADR 0376 §3).
|
||||
Restricted: s.Restricted,
|
||||
// Its owner reads it back by direct gets, with no consumer (its runtime's stream.read, as a log's read).
|
||||
Direct: !s.WorkQueue,
|
||||
}
|
||||
if s.WorkQueue {
|
||||
out.Retention = RetentionWorkQueue
|
||||
@@ -108,7 +110,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 (the indexer's judge, novox/hq ADR 0578 §1).
|
||||
// to be the only one (catalogue.WorkQueueStreamProblems).
|
||||
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).
|
||||
@@ -248,7 +250,9 @@ func heardGrants(p Principal, hears []Heard) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// ownStreamGrants are the subjects a module publishes its own streams' events on, and no other's.
|
||||
// ownStreamGrants are the subjects a module publishes its own streams' events on, and no other's, and the reading of
|
||||
// each back by direct gets (its runtime's stream.read: the stream's info and the batch direct get, with no consumer), as
|
||||
// a log is read. A work-queue stream is its one consumer's to read, never by direct get.
|
||||
func ownStreamGrants(module string, streams []ModuleStream) []string {
|
||||
var out []string
|
||||
for _, s := range streams {
|
||||
@@ -256,6 +260,9 @@ func ownStreamGrants(module string, streams []ModuleStream) []string {
|
||||
continue
|
||||
}
|
||||
out = append(out, s.Subjects()...)
|
||||
if !s.WorkQueue && safeSubject.MatchString(s.Name) {
|
||||
out = append(out, "$JS.API.STREAM.INFO."+s.Name, "$JS.API.DIRECT.GET."+s.Name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
+17
-69
@@ -181,8 +181,6 @@ 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
|
||||
@@ -198,12 +196,6 @@ 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
|
||||
@@ -663,19 +655,6 @@ 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)
|
||||
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:
|
||||
@@ -1009,10 +988,6 @@ 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
|
||||
@@ -1061,11 +1036,10 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
}
|
||||
}
|
||||
sub = append(sub, announcing(announced...)...)
|
||||
// Its own membership (ADR 0160): the one subject a runtime derives for itself, heard live. Nothing else's.
|
||||
// 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))
|
||||
// And from the grants part's state memberships, its own key (novox/hq ADR 0571 clause 7): its one read of it,
|
||||
// since the assignments stream's direct get went with the stream (research 072 row 33).
|
||||
pub = append(pub, readGrants(GrantsMembershipsBucket, MembershipKey(p.Node, p.Module))...)
|
||||
pub = append(pub, "$JS.API.DIRECT.GET."+AssignmentsStream+"."+MembershipSubject(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
|
||||
@@ -1183,7 +1157,6 @@ 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).
|
||||
@@ -1226,11 +1199,11 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Every assigned module's membership on this node (ADR 0160): one per module, heard live and read from the
|
||||
// grants part's state memberships (ADR 0571 clause 7). This node's and no other's — the one token
|
||||
// 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, readGrants(GrantsMembershipsBucket, p.Node+".*")...)
|
||||
pub = append(pub, "$JS.API.DIRECT.GET."+AssignmentsStream+".mesh.assignment."+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")
|
||||
@@ -1287,7 +1260,6 @@ 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
|
||||
@@ -1677,7 +1649,17 @@ 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())
|
||||
}
|
||||
b.WriteString(AccountsEntry(p.Username(), p.PasswordHash, perms))
|
||||
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(" ]\n }\n")
|
||||
b.WriteString(systemAccount)
|
||||
@@ -1685,25 +1667,6 @@ 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.
|
||||
@@ -1742,21 +1705,6 @@ 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
|
||||
|
||||
@@ -3,10 +3,8 @@ package buscompose
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/provisions"
|
||||
"strings"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
@@ -36,19 +34,15 @@ 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; 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
|
||||
// mesh-config's resolved values. **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. **The seat's plans are read whole for the same
|
||||
// reason** (2026-10-30 recovery, issue 774): the client V3 watches every machine's plans to bind a provider's
|
||||
// consumers, and granted only its own machine's keys it read none. ADR 0570 clause 3 has providers act on the seat's
|
||||
// provision keys instead; once the client does, plans go back to its own machine's keys.
|
||||
var ProvisionerReadsWhole = []string{AssignmentsBucket, provisions.PlansBucket}
|
||||
// and plans, never a secret's value; the client writes none of it.
|
||||
var ProvisionerReadsWhole = []string{AssignmentsBucket}
|
||||
|
||||
// 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
|
||||
@@ -74,13 +68,8 @@ 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.
|
||||
//
|
||||
// **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.
|
||||
// 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.
|
||||
var ProvisionerWrites = []string{ProvisionerNeedsBucket, ProvisionerAppliedBucket, ProvisionerCollectBucket}
|
||||
|
||||
// VerbsTheProvisionerAsksTheVault are the vault's verbs a provisioner client asks in its own name (ADR 0349
|
||||
|
||||
@@ -58,20 +58,6 @@ 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.
|
||||
if servedOnlyByTheController(s) {
|
||||
continue
|
||||
}
|
||||
for _, e := range s.Emits {
|
||||
if s.Scope == "node" && perMachineEvents[e] {
|
||||
continue
|
||||
}
|
||||
t.Publish = append(t.Publish, seatSubject(s, "event", e))
|
||||
}
|
||||
continue
|
||||
}
|
||||
kind := ""
|
||||
|
||||
@@ -156,12 +156,6 @@ 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, "")
|
||||
}
|
||||
}
|
||||
@@ -173,11 +167,6 @@ 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 {
|
||||
|
||||
@@ -171,9 +171,14 @@ func MeshStreams() []Stream {
|
||||
Why: "one declaration per node, always the newest; a node that sees sequence n refuses " +
|
||||
"n-1 by construction (issue 107)",
|
||||
},
|
||||
// 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: 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",
|
||||
},
|
||||
{
|
||||
Name: EventsStream,
|
||||
// A seat's own events ride here too: they are 1:many like any event, and the
|
||||
|
||||
@@ -3,12 +3,8 @@ 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/handacts"
|
||||
"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
|
||||
@@ -81,30 +77,12 @@ 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).
|
||||
//
|
||||
// The health holders (novox/hq ADR 0564 Limits 2 and 8, clauses 8 and 9): the condition keeper, mesh-healers and the
|
||||
// watchman read the lease; the keeper alone the controller's conditions; mesh-healers and mesh-hand-acts its hand-act log
|
||||
// (ADR 0565 clauses 10, 11: every reader of acts reads both logs); and nobody the healers' `would` (health_grants.go).
|
||||
var NamedReaders = map[string][]string{VaultBusAccounts: {GrantsModule}, GrantsAccounts: {VaultSeat},
|
||||
ControllerLease: {VaultSeat, GrantsModule, delivery.AssignerModule, health.KeeperModule, health.HealersModule, WatchmanModule,
|
||||
// ADR 0571 clause 2: the artifact store's seat sweeps only once the lease names no controller.
|
||||
ArtifactStoreTools,
|
||||
// ADR 0561 §3: mesh-config takes over the config only once the lease is handed away (its takeover.go leaseAway).
|
||||
ConfigModule},
|
||||
ControllerConditions: {health.KeeperModule}, ControllerHandActs: {health.HealersModule, handacts.Module}, HealersWould: {},
|
||||
// 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}}
|
||||
ControllerLease: {VaultSeat, GrantsModule, delivery.AssignerModule}}
|
||||
|
||||
// ReaderMay says whether module may read the state read (`<owner>.<name>`) by the named readers: always, for a state
|
||||
// that names none.
|
||||
@@ -125,23 +103,6 @@ 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)
|
||||
@@ -152,30 +113,3 @@ 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 }
|
||||
|
||||
+7
-34
@@ -64,9 +64,6 @@ 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
|
||||
@@ -108,9 +105,6 @@ 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
|
||||
@@ -160,7 +154,6 @@ func Users(r Records) ([]Principal, error) {
|
||||
AsksTheConfig: servedOf(r, VerbsTheControllerAsksTheConfig), FilesFor: sortedCopy(r.Nodes)}}
|
||||
|
||||
served := servedReadsOf(r)
|
||||
logs := DeclaredLogs(r)
|
||||
grants := -1
|
||||
for _, node := range sortedCopy(r.Nodes) {
|
||||
witness := false
|
||||
@@ -200,14 +193,9 @@ func Users(r Records) ([]Principal, error) {
|
||||
}
|
||||
}
|
||||
for _, d := range r.Assigned[node] {
|
||||
// **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 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)
|
||||
// **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
|
||||
@@ -216,7 +204,7 @@ 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, Removes: pr.Removes,
|
||||
Invokes: pr.Invokes,
|
||||
})
|
||||
}
|
||||
if runtimeHere && d.Module == RuntimeModule {
|
||||
@@ -235,15 +223,12 @@ 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, ReadLogs: d.ReadLogs, SnapshotsTheBus: d.SnapshotsTheBus,
|
||||
State: stateNames(d.State), Reads: d.Reads, 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
|
||||
@@ -258,8 +243,8 @@ func Users(r Records) ([]Principal, error) {
|
||||
if runtimeHere {
|
||||
var carried []Declared
|
||||
for _, d := range r.Assigned[node] {
|
||||
if CarriedByRunner(d) {
|
||||
d = withReadLogs(withAnnouncedReads(d, r.Announced), logs)
|
||||
if d.RunsAs == "" && runnerServes(d) {
|
||||
d = withNamedReaders(d)
|
||||
// Never a restricted stream for what the tool runner carries (novox/hq ADR 0376 §1).
|
||||
d.Hears = ComposedHears(d)
|
||||
carried = append(carried, d)
|
||||
@@ -455,18 +440,6 @@ 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
|
||||
|
||||
@@ -43,9 +43,6 @@ func createsDeclared(p Principal, pattern string) bool {
|
||||
if isController(p, pattern) {
|
||||
return true
|
||||
}
|
||||
if removesAssignmentsStream(p, pattern) {
|
||||
return true
|
||||
}
|
||||
name, ok := strings.CutPrefix(pattern, "$JS.API.STREAM.CREATE.")
|
||||
if ok && p.Kind == KindModule && p.Module == GrantsModule && name == GrantsUserListObjectStream {
|
||||
return true
|
||||
@@ -188,7 +185,7 @@ var WritersTable = []WriterRow{
|
||||
Subjects: []string{"$JS.API.STREAM.CREATE.>", "$JS.API.STREAM.UPDATE.>", "$JS.API.STREAM.DELETE.>",
|
||||
"$JS.API.CONSUMER.DURABLE.CREATE.>"},
|
||||
Writes: createsDeclared,
|
||||
Shared: "the grants part creates a stream a manifest declares, by its name, and never updates or deletes one (novox/hq ADR 0547 §5; the operator, 2026-10-26 08:54); the bus module's tools account deletes the stream ASSIGNMENTS at the lease write, and no other (research 072 row 33)"},
|
||||
Shared: "the grants part creates a stream a manifest declares, by its name, and never updates or deletes one (novox/hq ADR 0547 §5; the operator, 2026-10-26 08:54)"},
|
||||
{State: "builds and their outcomes", Writer: "the build seat's holder", KeptIn: "its own state",
|
||||
Others: "the controller asks",
|
||||
Subjects: []string{"mesh.seat.node-build-agent.event.built", "mesh.seat.mesh-build-machine.event.built"},
|
||||
@@ -223,8 +220,7 @@ var WritersTable = []WriterRow{
|
||||
// and delivered again or dropped only through its verb, with why.
|
||||
{State: "a message a consumer gave up on", Writer: "controller", KeptIn: "the bus, the stream " + DeadLettersStream,
|
||||
Others: "read, delivered again or dropped through the controller's dead-letters verb",
|
||||
Subjects: []string{deadLetterPrefix + ">", againPrefix + ">"}, Writes: keepsDeadLetters,
|
||||
Shared: "the watchman keeps the copies from the controller's lease write, the controller before it; delivering again stays the controller's (novox/hq ADR 0564 clause 5)"},
|
||||
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).
|
||||
@@ -251,25 +247,9 @@ var WritersTable = []WriterRow{
|
||||
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: 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)"},
|
||||
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 " +
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -30,7 +31,7 @@ func aServiceModule(t *testing.T) Manifest {
|
||||
"build": {"artifacts": [{"name": "code", "kind": "bundle", "language": "go", "system": "arch",
|
||||
"from": "cmd/svc", "binary": "svc"}]}
|
||||
}`
|
||||
m, err := decodeForTest([]byte(raw))
|
||||
m, err := ParseManifest([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("the service's manifest is refused: %v", err)
|
||||
}
|
||||
@@ -144,3 +145,22 @@ 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,3 +76,33 @@ 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,6 +258,15 @@ 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 := decodeForTest([]byte(agentModule))
|
||||
m, err := ParseManifest([]byte(agentModule))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -83,3 +83,37 @@ 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,6 +14,22 @@ 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) {
|
||||
|
||||
@@ -5,16 +5,28 @@ 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 := decodeForTest([]byte(`{"module":"pg","version":"1",
|
||||
pg, err := ParseManifest([]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 := decodeForTest([]byte(`{"module":"mail","version":"1",
|
||||
mail, err := ParseManifest([]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,12 +1,34 @@
|
||||
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 := decodeForTest([]byte(`{"module":"registry","version":"1",` +
|
||||
_, err := ParseManifest([]byte(`{"module":"registry","version":"1",` +
|
||||
`"provides":[{"name":"artifact-store","scope":"mesh"}],` +
|
||||
`"resources":[{"id":"store","type":"container","name":"mesh-registry",` +
|
||||
`"image":"registry@sha256:` +
|
||||
@@ -19,7 +41,7 @@ func TestAnArtifactStoreThatNamesItsImageIsAccepted(t *testing.T) {
|
||||
|
||||
// And an ordinary module still builds whatever it likes.
|
||||
func TestAModuleThatDoesNotProvideTheStoreStillBuilds(t *testing.T) {
|
||||
_, err := decodeForTest([]byte(`{"module":"forge","version":"1",` +
|
||||
_, err := ParseManifest([]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 := decodeForTest([]byte(`{"module":"zsh","version":"1","provides":["shell"]}`))
|
||||
m, err := ParseManifest([]byte(`{"module":"zsh","version":"1","provides":["shell"]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -183,6 +183,19 @@ 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 {
|
||||
@@ -295,3 +308,25 @@ 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 := decodeForTest([]byte(`{"module":"files","version":"1","requires":[` + entry + `]}`))
|
||||
_, err := ParseManifest([]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 := decodeForTest([]byte(`{"module":"files","version":"1","requires":["s3-bucket"],
|
||||
_, err := ParseManifest([]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 := decodeForTest([]byte(`{"module":"files","version":"1",
|
||||
_, err := ParseManifest([]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") {
|
||||
|
||||
+365
-27
@@ -2,7 +2,10 @@ package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"path"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
@@ -123,7 +126,23 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) {
|
||||
continue
|
||||
}
|
||||
made := by[a.Name]
|
||||
loads := BundleLoads(m, a, run)
|
||||
// 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}
|
||||
}
|
||||
}
|
||||
// **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
|
||||
@@ -133,7 +152,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), Bus: a.Bus,
|
||||
Binary: BinaryOf(a),
|
||||
})
|
||||
}
|
||||
sort.Slice(out.Bundles, func(i, j int) bool { return out.Bundles[i].Name < out.Bundles[j].Name })
|
||||
@@ -234,6 +253,152 @@ 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.
|
||||
@@ -261,6 +426,122 @@ 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 {
|
||||
@@ -270,6 +551,30 @@ 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.
|
||||
//
|
||||
@@ -310,6 +615,42 @@ 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
|
||||
@@ -350,30 +691,27 @@ func runByAResource(m Manifest) map[string]bool {
|
||||
return named
|
||||
}
|
||||
|
||||
// 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}
|
||||
}
|
||||
// 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
|
||||
}
|
||||
return loads
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// 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,8 +1,82 @@
|
||||
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,8 +108,68 @@ 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 := decodeForTest([]byte(`{"module":"a","version":"1","slug":"a",
|
||||
m, err := ParseManifest([]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 {
|
||||
@@ -120,3 +180,24 @@ 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,6 +9,40 @@ 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.
|
||||
@@ -17,7 +51,7 @@ func TestTheCataloguesBusIsBackedUpBySnapshot(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, err := decodeForTest(raw)
|
||||
m, err := ParseManifest(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -80,6 +80,34 @@ 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,6 +2,7 @@ package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/validate"
|
||||
@@ -31,8 +32,35 @@ 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 := decodeForTest(busyManifest(t, "agent", map[string]any{"command": "mesh-builder busy"}))
|
||||
m, err := ParseManifest(busyManifest(t, "agent", map[string]any{"command": "mesh-builder busy"}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package catalogue
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/internal/beside"
|
||||
@@ -44,3 +45,78 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+210
-1
@@ -113,7 +113,12 @@ 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:([^}]*)\}`)
|
||||
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-]*$`)
|
||||
attributeKey = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
|
||||
attributeValue = regexp.MustCompile(`^[a-z0-9][a-z0-9_.-]*$`)
|
||||
)
|
||||
@@ -237,6 +242,190 @@ 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()
|
||||
@@ -248,6 +437,26 @@ 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 := decodeForTest([]byte(raw))
|
||||
m, err := ParseManifest([]byte(raw))
|
||||
if err == nil {
|
||||
_, err = (Resolution{Node: "n", Modules: []Manifest{m}}).Declaration(Rendering{})
|
||||
}
|
||||
@@ -82,6 +82,33 @@ 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 := decodeForTest([]byte(oldFormAliases))
|
||||
aliases, err := ParseManifest([]byte(oldFormAliases))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -141,3 +141,39 @@ 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,6 +104,19 @@ 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,6 +5,7 @@ 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).
|
||||
@@ -122,6 +123,26 @@ 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 {
|
||||
@@ -150,6 +171,166 @@ 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{}
|
||||
@@ -236,3 +417,88 @@ 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,7 +143,43 @@ 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 := decodeForTest(raw); err != nil {
|
||||
if _, err := ParseManifest(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")
|
||||
}
|
||||
|
||||
+234
-3
@@ -63,7 +63,7 @@ func parseRaw(t *testing.T, m map[string]any) (Manifest, error) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return decodeForTest(raw)
|
||||
return ParseManifest(raw)
|
||||
}
|
||||
|
||||
// refused is that the manifest is refused with every one of these words.
|
||||
@@ -97,6 +97,66 @@ 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()
|
||||
@@ -104,6 +164,127 @@ 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) {
|
||||
@@ -125,7 +306,7 @@ func TestAConfigManifestIsWrittenBackInItsOwnForm(t *testing.T) {
|
||||
t.Fatalf("written back with %s: %s", unsaid, raw)
|
||||
}
|
||||
}
|
||||
again, err := decodeForTest(raw)
|
||||
again, err := ParseManifest(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -184,7 +365,7 @@ const configDunst = `{"module": "dunst", "version": "1",
|
||||
func TestAModuleRewrittenFromSettingsToConfigComposesByteIdentically(t *testing.T) {
|
||||
composed := func(manifest string, layers []Layer) []byte {
|
||||
t.Helper()
|
||||
m, err := decodeForTest([]byte(manifest))
|
||||
m, err := ParseManifest([]byte(manifest))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -223,6 +404,14 @@ 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) {
|
||||
@@ -277,6 +466,36 @@ 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{
|
||||
@@ -290,3 +509,15 @@ 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,6 +124,55 @@ 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 := decodeForTest([]byte(raw))
|
||||
_, err := ParseManifest([]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 := decodeForTest([]byte(`{"module":"audit","version":"1","consumes":["shop.order.placed"]}`)); err != nil {
|
||||
if _, err := ParseManifest([]byte(`{"module":"audit","version":"1","consumes":["shop.order.placed"]}`)); err != nil {
|
||||
t.Errorf("a plain consumes entry was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,6 +218,30 @@ 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,3 +113,24 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ const provisionerClient = `{"module":"mesh-provisioner-client","version":"1",
|
||||
|
||||
func clientOn(t *testing.T) Resolution {
|
||||
t.Helper()
|
||||
m, err := decodeForTest([]byte(provisionerClient))
|
||||
m, err := ParseManifest([]byte(provisionerClient))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -13,3 +13,80 @@ 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,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -304,6 +305,182 @@ 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.
|
||||
//
|
||||
@@ -419,9 +596,196 @@ 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
|
||||
}
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
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
|
||||
}
|
||||
+146
-11
@@ -1,7 +1,9 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -23,13 +25,136 @@ const declaredStore = `{"module":"pg","version":"1",
|
||||
|
||||
func mustParse(t *testing.T, raw string) Manifest {
|
||||
t.Helper()
|
||||
m, err := decodeForTest([]byte(raw))
|
||||
m, err := ParseManifest([]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")
|
||||
}
|
||||
// Issue 774's new words first: an offer's credential form and an artifact's bus are accepted and change nothing: the
|
||||
// manifest that says them composes byte-equal to the one that does not, and no form still means a pair.
|
||||
plain := strings.Replace(declaredStore, `"resources":[`, `"build":{"artifacts":[{"name":"tools","kind":"bundle","language":"go","system":"arch","from":"cmd/x","binary":"x","loads":["x"]}]},"resources":[`, 1)
|
||||
for _, form := range []string{"pair", "none"} {
|
||||
worded := strings.Replace(plain, `"scope":"mesh"}]`, `"scope":"mesh","credential":{"form":"`+form+`"}}]`, 1)
|
||||
worded = strings.Replace(worded, `"loads":["x"]}`, `"loads":["x"],"bus":{"state":["acts"],"removes":["engine-acts"]}}`, 1)
|
||||
a, b := mustParse(t, plain), mustParse(t, worded)
|
||||
if b.Provides[0].Credential != nil || b.CredentialFormOf("postgres-database") != a.CredentialFormOf("postgres-database") ||
|
||||
a.CredentialFormOf("postgres-database") != CredentialFormPair || len(b.Build.Artifacts[0].Bus) == 0 {
|
||||
t.Fatalf("form %s: offer %+v, form %q against %q", form, b.Provides[0], b.CredentialFormOf("postgres-database"),
|
||||
a.CredentialFormOf("postgres-database"))
|
||||
}
|
||||
b.Build.Artifacts[0].Bus = nil
|
||||
ra, erra := (Resolution{Node: "anchor", Modules: []Manifest{a}}).Compose(Rendering{})
|
||||
rb, errb := (Resolution{Node: "anchor", Modules: []Manifest{b}}).Compose(Rendering{})
|
||||
ja, _ := json.Marshal(ra.Resources)
|
||||
jb, _ := json.Marshal(rb.Resources)
|
||||
if erra != nil || errb != nil || string(ja) != string(jb) || len(ra.Resources) == 0 {
|
||||
t.Fatalf("form %s composes %s against %s (%v, %v)", form, jb, ja, errb, erra)
|
||||
}
|
||||
}
|
||||
if _, err := ParseManifest([]byte(strings.Replace(declaredStore, `"scope":"mesh"}]`, `"scope":"mesh","credential":{"form":"shared"}}]`, 1))); err == nil ||
|
||||
!strings.Contains(err.Error(), `the form "shared", not "pair" or "none"`) {
|
||||
t.Fatalf("a form the release does not know: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -93,16 +218,6 @@ 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")
|
||||
}
|
||||
@@ -116,3 +231,23 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
+28
-116
@@ -221,11 +221,6 @@ 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.
|
||||
@@ -282,10 +277,6 @@ 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
|
||||
@@ -709,7 +700,13 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
handsSecrets := with.Handing(m)
|
||||
for _, name := range sortedKeys(m.OwnSecrets.Plain()) {
|
||||
if handsSecrets {
|
||||
owner := r.ownSecretOwner(m, name)
|
||||
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{
|
||||
"id": NeedID(name), "type": "file", "path": m.OwnSecrets[name].Path, HandedSecretField: name,
|
||||
}))
|
||||
@@ -721,7 +718,13 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
if string(box) == string(ForeseenVaultBox) {
|
||||
*foreseen = append(*foreseen, m.Module+"/"+name)
|
||||
}
|
||||
owner := r.ownSecretOwner(m, name)
|
||||
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{
|
||||
"id": NeedID(name), "type": "file", "path": m.OwnSecrets[name].Path, VaultSealedField: box,
|
||||
}))
|
||||
@@ -751,7 +754,14 @@ 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.
|
||||
first = append(first, ownedBy(r.ownSecretOwner(m, name), map[string]any{
|
||||
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{
|
||||
"id": NeedID(name), "type": "file", "path": m.OwnSecrets[name].Path, "sealed": sealed,
|
||||
}))
|
||||
}
|
||||
@@ -1074,22 +1084,15 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// **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) {
|
||||
// **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 {
|
||||
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
|
||||
@@ -1365,10 +1368,8 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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)
|
||||
}
|
||||
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 {
|
||||
@@ -1393,7 +1394,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 && process != nil {
|
||||
if named := givenTo(out, owner, words, r.Account); len(named) > 0 {
|
||||
restarts, _ := process["restart-on"].([]any)
|
||||
seen := map[string]bool{}
|
||||
for _, id := range restarts {
|
||||
@@ -1676,19 +1677,6 @@ 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
|
||||
@@ -1837,82 +1825,6 @@ 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.
|
||||
//
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
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 }
|
||||
@@ -1,12 +0,0 @@
|
||||
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,6 +193,32 @@ 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,6 +106,14 @@ 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
|
||||
@@ -252,6 +260,109 @@ 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,6 +110,18 @@ 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])
|
||||
@@ -176,6 +188,39 @@ 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.
|
||||
@@ -189,6 +234,9 @@ 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)
|
||||
@@ -253,6 +301,9 @@ 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,6 +1,7 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -99,6 +100,52 @@ 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 +1,71 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,26 +49,6 @@ func EngineVerbs() []Verb {
|
||||
"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)},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -114,6 +114,64 @@ 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)"
|
||||
@@ -137,6 +195,46 @@ 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,6 +276,102 @@ 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) {
|
||||
@@ -300,3 +396,29 @@ 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,7 +129,31 @@ func TestTheEnvironmentOfTheCatalogueIsTheSameHandedOrNot(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTwoModulesSettingOneVariableAreRefused(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")
|
||||
}
|
||||
// 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,8 +1,10 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// What a module may call an event, and what a consumer may ask for.
|
||||
@@ -34,6 +36,131 @@ 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,6 +98,16 @@ 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,6 +46,20 @@ 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,6 +1000,32 @@ 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,9 +7,22 @@ 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 := decodeForTest([]byte(
|
||||
m, err := ParseManifest([]byte(
|
||||
`{"module":"web","listens":[{"port":443,"from":"anywhere","why":"the site"}]}`))
|
||||
if err != nil {
|
||||
t.Fatalf("an ordinary manifest was refused: %v", err)
|
||||
@@ -19,6 +32,24 @@ 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{
|
||||
@@ -326,6 +357,13 @@ 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) {
|
||||
|
||||
@@ -1,6 +1,37 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,14 @@ 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 {
|
||||
@@ -118,6 +126,29 @@ 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")})
|
||||
@@ -126,3 +157,57 @@ 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,6 +58,26 @@ 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) {
|
||||
|
||||
+43
-179
@@ -1,7 +1,6 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
@@ -67,16 +66,6 @@ type Plan struct {
|
||||
Provisions []PlanProvision `json:"provisions,omitempty"`
|
||||
}
|
||||
|
||||
// Hands is whether the plan hands its module to the provisioner: it names something the node's provisioner client fills
|
||||
// (a template archive, a file, an own secret or an item), and the node composes none of it. A plan naming provisions alone
|
||||
// hands nothing (catalogue.Keyed, issue 774): the seat keys what a provider's helper answers of a module not handed, and
|
||||
// that module's files and own secrets stay its node's composition. The one rule every reader of a plan asks; whether a
|
||||
// plan exists is never it (on 2026-10-30 the vault read every route-only plan as handed and answered twenty modules'
|
||||
// own secrets to nobody).
|
||||
func (p Plan) Hands() bool {
|
||||
return p.Templates.Source != "" || len(p.Files) > 0 || len(p.Secrets) > 0 || len(p.Items) > 0
|
||||
}
|
||||
|
||||
// PlanItem is one item the module contributes: to which collection, and its template inside the archive.
|
||||
//
|
||||
// A named item has its Name and Attributes (design 57 L3, issue 777): the client records them with the item and
|
||||
@@ -135,91 +124,16 @@ type PlanProvision struct {
|
||||
// alone knows the node's domains, and carried unchanged by the provider's helper into the consumer's entry. Nil
|
||||
// where the consumer contributes nothing to the provision, and the entry is then as it was.
|
||||
Names map[string]string `json:"names,omitempty"`
|
||||
// Contributed is what the consumer gives its provider through this provision, opaque here (issue 774, research
|
||||
// 072 row 34): the bytes of a ContributedForm, each contribution composed as its provider receives it and where
|
||||
// the consumer's machine is. What a contribution's values mean is the provider's own contract, published by the
|
||||
// provider's repository (route-proxy's for a route), which decodes them; the seat, its plans, its client and the
|
||||
// helper carry them unread. On the provision's first entry alone; nil where the consumer contributes nothing to it.
|
||||
Contributed json.RawMessage `json:"contributed,omitempty"`
|
||||
}
|
||||
|
||||
// ContributedVersion is the version of ContributedForm this SDK writes and reads.
|
||||
const ContributedVersion = 1
|
||||
|
||||
// ContributedForm is the generic form of PlanProvision.Contributed: every contribution one consumer makes to one
|
||||
// requirement, composed (settled with its node's settings, its endpoint's port filled in, its names joined to the
|
||||
// node's domains: Resolution.composed, the one composition a received file was written from), and At, where the
|
||||
// consumer's machine is on the private network, empty when it is on none. The provisioner knows no field of any
|
||||
// contribution: Values are the bytes of an object in the provider's contract.
|
||||
type ContributedForm struct {
|
||||
V int `json:"v"`
|
||||
At string `json:"at,omitempty"`
|
||||
Each []ContributedOne `json:"each"`
|
||||
}
|
||||
|
||||
// ContributedOne is one contribution: Local its local name among several (`contributes` naming several), empty for a
|
||||
// requirement's one, and Values the contribution's object.
|
||||
type ContributedOne struct {
|
||||
Local string `json:"local,omitempty"`
|
||||
Values json.RawMessage `json:"values"`
|
||||
}
|
||||
|
||||
// EncodeContributed is f as the bytes PlanProvision.Contributed carries, refused where DecodeContributed would refuse
|
||||
// them, so a plan never carries what its reader cannot take.
|
||||
func EncodeContributed(f ContributedForm) (json.RawMessage, error) {
|
||||
f.V = ContributedVersion
|
||||
raw, err := json.Marshal(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := DecodeContributed(raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// DecodeContributed reads PlanProvision.Contributed strictly: this version, no field it does not know, at least one
|
||||
// contribution, each local name once, each value a JSON object. What a value says is not judged here.
|
||||
func DecodeContributed(raw json.RawMessage) (ContributedForm, error) {
|
||||
var f ContributedForm
|
||||
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&f); err != nil {
|
||||
return ContributedForm{}, fmt.Errorf("what a consumer contributed is not readable: %w", err)
|
||||
}
|
||||
if dec.More() {
|
||||
return ContributedForm{}, errors.New("what a consumer contributed carries more than one document")
|
||||
}
|
||||
if f.V != ContributedVersion {
|
||||
return ContributedForm{}, fmt.Errorf("what a consumer contributed is of version %d, this SDK reads %d", f.V,
|
||||
ContributedVersion)
|
||||
}
|
||||
if len(f.Each) == 0 {
|
||||
return ContributedForm{}, errors.New("what a consumer contributed holds no contribution")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, one := range f.Each {
|
||||
if seen[one.Local] {
|
||||
return ContributedForm{}, fmt.Errorf("what a consumer contributed names %q twice", one.Local)
|
||||
}
|
||||
seen[one.Local] = true
|
||||
var object map[string]json.RawMessage
|
||||
if err := json.Unmarshal(one.Values, &object); err != nil || object == nil {
|
||||
return ContributedForm{}, fmt.Errorf("the contribution %q is not a JSON object", one.Local)
|
||||
}
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// provisionsOf is, from a resolution's needs, each provision module requires and its provider, by provision then
|
||||
// local name. A need answered by a record (a licence) or by no module names no provider on a machine and is left
|
||||
// out: the vault has no provider's value to make for it.
|
||||
func provisionsOf(needs []Needed, module string, handed bool, as string, contributes map[string]map[string]any,
|
||||
func provisionsOf(needs []Needed, module, as string, contributes map[string]map[string]any,
|
||||
named map[string]bool) []PlanProvision {
|
||||
var out []PlanProvision
|
||||
for _, n := range needs {
|
||||
// **The one rule** (Keyed, issue 774): what the seat keys and the want waits for.
|
||||
if n.For != module || !Keyed(handed, n) {
|
||||
if n.For != module || n.ByRecord || n.Module == "" || n.From == "" {
|
||||
continue
|
||||
}
|
||||
p := PlanProvision{Provision: n.Name, Local: n.Local, Provider: n.From, ProviderModule: n.Module,
|
||||
@@ -305,6 +219,43 @@ func takesOf(m Manifest, id string) string {
|
||||
return takes
|
||||
}
|
||||
|
||||
// movedProblems is what is wrong with a module's mark: a moved module must name a template (as written) or carry
|
||||
// what its build kept (as held), and Handed is the build's word, never a repository's.
|
||||
func movedProblems(m Manifest) []string {
|
||||
switch {
|
||||
case m.Handed != nil && !m.Moved:
|
||||
return []string{fmt.Sprintf("%s carries what a moved module's build keeps (handed) and is not marked moved",
|
||||
m.Module)}
|
||||
case m.Handed != nil && m.Build != nil:
|
||||
return []string{fmt.Sprintf("%s says handed in its repository: that is what its build keeps, never "+
|
||||
"written by hand (novox/hq ADR 0349 §2)", m.Module)}
|
||||
case m.Moved && m.Handed == nil && !m.Templated():
|
||||
return []string{fmt.Sprintf("%s is marked moved and names no template: a moved module's files are filled "+
|
||||
"from template files of its own, `\"template\": \"%s/<name>\"` (novox/hq ADR 0349 §2)", m.Module,
|
||||
m.TemplatesDir())}
|
||||
}
|
||||
if !m.Moved {
|
||||
return nil
|
||||
}
|
||||
// **A moved module's secrets are the vault's, handed to its machine's client** (design 57 V4): a secret the
|
||||
// mesh makes, by name. A family's members and a value issued outside the mesh are given by a person and may be
|
||||
// absent, which the client cannot tell from one the vault does not hold yet; they move with a package of their
|
||||
// own.
|
||||
var problems []string
|
||||
if fams := m.OwnSecrets.Families(); len(fams) > 0 {
|
||||
problems = append(problems, fmt.Sprintf("%s is marked moved and keeps a secret family (%s): a moved module's "+
|
||||
"secrets are handed by the vault by name, and a family's members are not yet (design 57 V4)", m.Module,
|
||||
strings.Join(fams, ", ")))
|
||||
}
|
||||
for _, name := range sortedKeys(m.OwnSecrets.Plain()) {
|
||||
if m.OwnSecrets[name].IssuedBy == IssuedOutside {
|
||||
problems = append(problems, fmt.Sprintf("%s is marked moved and its secret %s is issued outside the mesh: "+
|
||||
"a value that may not be given is not yet handed by the vault (design 57 V4)", m.Module, name))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// handedBy is what a moved module's build keeps, from its templates archive as built, or nil for a module not
|
||||
// moved.
|
||||
func handedBy(m Manifest, archive Built) *Handed {
|
||||
@@ -332,43 +283,16 @@ func handedBy(m Manifest, archive Built) *Handed {
|
||||
return h
|
||||
}
|
||||
|
||||
// handing is whether this composition hands the module's templated files to the provisioner: handed (Handing), with
|
||||
// the templates its build kept. A module the vault judged handed whose build kept none is refused where its secrets
|
||||
// are placed ("nothing would fill it"), never composed with values the vault does not answer.
|
||||
// handing is whether this composition hands the module's templated files to the provisioner.
|
||||
func (with Rendering) handing(m Manifest) bool {
|
||||
return with.Handing(m) && m.Handed != nil
|
||||
return m.Moved && m.Handed != nil && with.Hands && with.Provisioner != ""
|
||||
}
|
||||
|
||||
// Handing is whether a machine composed with this rendering hands the module to the provisioner, its files and
|
||||
// its own secrets (design 57 P6, V4): a moved module there, assigned or only pulled in by another (issue 765), on a
|
||||
// machine that can take it. The controller makes and seals none of such a module's secrets for that machine: its
|
||||
// client asks the vault.
|
||||
//
|
||||
// **Where the vault judged the module, its word is the rule** (Rendering.Assigned, issue 774): the vault leaves a
|
||||
// module's own secrets out of `own` exactly when the plan in its assignment hands it (AssignmentHands), so a node
|
||||
// composing itself hands exactly those, whatever its own reading of the manifest and the machine says. On 2026-10-31
|
||||
// postgres on novox was held: its plan handed its secrets, the vault answered none of them, and the node, judging by
|
||||
// the manifest and the machine, composed them itself and refused for want of `reader`. Two readers, one rule.
|
||||
func (with Rendering) Handing(m Manifest) bool {
|
||||
if hands, judged := with.Assigned[m.Module]; judged {
|
||||
return hands
|
||||
}
|
||||
return m.Moved && m.Handed != nil && with.Hands && with.Provisioner != ""
|
||||
}
|
||||
|
||||
// AssignmentHands is whether a module's value in the seat mesh-provisioner's plans (the assignments mirror's shape,
|
||||
// `{"provisioning": <Plan>}`) hands its module to the provisioner: Plan.Hands of the plan it carries, false with no
|
||||
// plan. A value that does not read is an error, never "not handed". The one rule the vault judges `own` by and a node
|
||||
// composing itself follows (Rendering.Assigned, issue 774).
|
||||
func AssignmentHands(raw []byte) (bool, error) {
|
||||
var a struct {
|
||||
Provisioning *Plan `json:"provisioning"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &a); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return a.Provisioning != nil && a.Provisioning.Hands(), nil
|
||||
}
|
||||
func (with Rendering) Handing(m Manifest) bool { return with.handing(m) }
|
||||
|
||||
// requiredByWords begins why a module only pulled in by another is on a machine (Resolution.Because): `required by
|
||||
// <module>`.
|
||||
@@ -665,56 +589,6 @@ type handedFiles struct {
|
||||
required map[string]map[string]bool
|
||||
// names is the names composed for each handed module through each requirement it contributes to (issue 792).
|
||||
names map[string]map[string]map[string]string
|
||||
// keyedOnly is each module with a plan only for what Keyed keys of a consumer not handed: a provision its
|
||||
// provider's helper answers (issue 774), such as a route.
|
||||
keyedOnly map[string]bool
|
||||
// contributed is what each handed module gives each requirement's provider, encoded (PlanProvision.Contributed).
|
||||
contributed map[string]map[string]json.RawMessage
|
||||
}
|
||||
|
||||
// contribute records what a handed module gives one requirement's provider, encoded; nothing for none.
|
||||
func (h *handedFiles) contribute(module, provision string, raw json.RawMessage) {
|
||||
if len(raw) == 0 {
|
||||
return
|
||||
}
|
||||
if h.contributed == nil {
|
||||
h.contributed = map[string]map[string]json.RawMessage{}
|
||||
}
|
||||
if h.contributed[module] == nil {
|
||||
h.contributed[module] = map[string]json.RawMessage{}
|
||||
}
|
||||
h.contributed[module][provision] = raw
|
||||
}
|
||||
|
||||
// keyed starts the plan of a module begin started none for, when the composition keys any of its needs as a consumer
|
||||
// not handed (Keyed, issue 774): one whose provider's helper answers a provision of it (grafana's route). A module
|
||||
// handed that keeps no template or item (a held one's stub, issue 1126) is not handed here either: what it keeps
|
||||
// stays the controller's but for what a helper answers. Its plan names provisions alone: no templates, files or
|
||||
// secrets. Answers whether it started one.
|
||||
func (h *handedFiles) keyed(node string, m Manifest, needs []Needed) bool {
|
||||
if _, started := h.plans[m.Module]; started {
|
||||
return false
|
||||
}
|
||||
if !slices.ContainsFunc(needs, func(n Needed) bool { return n.For == m.Module && Keyed(false, n) }) {
|
||||
return false
|
||||
}
|
||||
if h.identities == nil {
|
||||
h.identities = map[string]string{}
|
||||
h.required = map[string]map[string]bool{}
|
||||
}
|
||||
h.identities[m.Module] = ConsumerIdentity(node, IdentitySource(m.Slug, m.Module))
|
||||
h.plans[m.Module] = Plan{}
|
||||
if h.keyedOnly == nil {
|
||||
h.keyedOnly = map[string]bool{}
|
||||
}
|
||||
h.keyedOnly[m.Module] = true
|
||||
if len(m.Contributes) > 0 {
|
||||
if h.contributes == nil {
|
||||
h.contributes = map[string]map[string]map[string]any{}
|
||||
}
|
||||
h.contributes[m.Module] = m.Contributes
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// name records the names composed for a handed module through one requirement, as its binding file carries them.
|
||||
@@ -894,8 +768,7 @@ func (h *handedFiles) finished(needs []Needed) map[string]Plan {
|
||||
for module, plan := range h.plans {
|
||||
// **Every provision it requires, with its provider** (issue 760); a credential the vault keeps for it alone
|
||||
// is that provision's entry, with its name and file (issue 757).
|
||||
provisions := provisionsOf(needs, module, !h.keyedOnly[module], h.identities[module], h.contributes[module],
|
||||
h.required[module])
|
||||
provisions := provisionsOf(needs, module, h.identities[module], h.contributes[module], h.required[module])
|
||||
for _, credential := range h.provisions[module] {
|
||||
i := slices.IndexFunc(provisions, func(p PlanProvision) bool {
|
||||
return p.Provision == credential.Provision && p.Local == credential.Local
|
||||
@@ -911,15 +784,6 @@ func (h *handedFiles) finished(needs []Needed) map[string]Plan {
|
||||
provisions[i].Names = maps.Clone(h.names[module][provisions[i].Provision])
|
||||
}
|
||||
sortProvisions(provisions)
|
||||
// **What the consumer contributes, on the provision's first entry** (issue 774): a provider serving from the
|
||||
// plans alone reads each contribution once, however many local names the requirement has.
|
||||
gave := map[string]bool{}
|
||||
for i := range provisions {
|
||||
if raw := h.contributed[module][provisions[i].Provision]; len(raw) > 0 && !gave[provisions[i].Provision] {
|
||||
gave[provisions[i].Provision] = true
|
||||
provisions[i].Contributed = slices.Clone(raw)
|
||||
}
|
||||
}
|
||||
plan.Provisions = provisions
|
||||
h.plans[module] = plan
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -48,7 +50,7 @@ func TestWhatAConsumersPlaceholderMayNotBeIsRefused(t *testing.T) {
|
||||
"a file the program keeps": {strings.Replace(handedProvider, `"mode":"0600","template"`,
|
||||
`"mode":"0600","create-once":true,"template"`, 1), consumersText, "owned"},
|
||||
} {
|
||||
m, err := decodeForTest([]byte(c.raw))
|
||||
m, err := ParseManifest([]byte(c.raw))
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", why, err)
|
||||
}
|
||||
@@ -101,3 +103,32 @@ func TestAProvidersConsumersFileIsLeftOutWhereItIsNotHanded(t *testing.T) {
|
||||
t.Fatalf("handed: said %q, filled %v", c.HandedOnly, c.Filled)
|
||||
}
|
||||
}
|
||||
|
||||
// What `module check` and the merge gate judge: the provider read from its tree, its text inline. Judged by the same
|
||||
// rules, a provider naming a provision it does not provide is refused there too, and one that does composes.
|
||||
func TestAProviderReadFromItsTreeIsJudgedByTheSameRules(t *testing.T) {
|
||||
read := func(text string) (Manifest, error) {
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "files"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "files", "consumers.json"), []byte(text), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, err := ParseManifest([]byte(handedProvider))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return WithTemplatesFrom(m, dir)
|
||||
}
|
||||
filled, err := read(consumersText)
|
||||
if err != nil {
|
||||
t.Fatalf("a provider read from its tree is refused: %v", err)
|
||||
}
|
||||
if _, err := notifierOn(t, filled).Compose(handingTo("store:5000")); err != nil {
|
||||
t.Fatalf("a provider read from its tree does not compose where it would be handed: %v", err)
|
||||
}
|
||||
if _, err := read("${consumers:cache}"); err == nil || !strings.Contains(err.Error(), "does not provide") {
|
||||
t.Fatalf("a provider read from its tree naming a provision it does not provide: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,10 +42,9 @@ func consumerOn(t *testing.T, m Manifest, w World) Resolution {
|
||||
node := workstation()
|
||||
node.At = "workstation.internal"
|
||||
stores := shelf(m,
|
||||
Manifest{Module: "store", Version: "1", Provides: []Offer{{Name: "postgres-database", Scope: ScopeMesh,
|
||||
Credential: &OfferCredential{Form: CredentialFormPair}}},
|
||||
Manifest{Module: "store", Version: "1", Provides: []Offer{{Name: "postgres-database", Scope: ScopeMesh}},
|
||||
Serves: map[string]map[string]any{"postgres-database": {"port": 5432}}},
|
||||
Manifest{Module: "store-two", Version: "1", Provides: []Offer{{Name: "postgres-database", Scope: ScopeMesh, Credential: &OfferCredential{Form: CredentialFormPair}}},
|
||||
Manifest{Module: "store-two", Version: "1", Provides: []Offer{{Name: "postgres-database", Scope: ScopeMesh}},
|
||||
Serves: map[string]map[string]any{"postgres-database": {"port": 5434}}})
|
||||
r, err := Resolve(stores, []string{m.Module}, node, w)
|
||||
if err != nil {
|
||||
@@ -116,6 +115,46 @@ func TestAHandedModulesIssuedCredentialIsItsProvisionsOneEntry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Each entry names the credential form the provider's offer declares (issue 768), from the provider's manifest, so
|
||||
// the vault makes a first value in that form for a provider it holds no row of: one of its own secrets shared by
|
||||
// every consumer, a login per consumer when the offer says no credential, issued by its software, or given by a
|
||||
// person (the offer's own secret issued outside).
|
||||
func TestAHandedModulesPlanNamesTheCredentialFormOfItsProvidersOffer(t *testing.T) {
|
||||
storeOffering := func(credential string) Manifest {
|
||||
raw := `{"module":"store","version":"1","provides":[{"name":"postgres-database","scope":"mesh"` +
|
||||
credential + `}],"serves":{"postgres-database":{"port":5432}}`
|
||||
if strings.Contains(credential, `"own"`) {
|
||||
raw += `,"own-secrets":{"admin":{"path":"/var/lib/store/admin","taken":"at-start"},` +
|
||||
`"token":{"path":"/var/lib/store/token","taken":"at-start","issued-by":"outside"}}`
|
||||
}
|
||||
return mustParse(t, raw+`}`)
|
||||
}
|
||||
for credential, want := range map[string]string{
|
||||
``: CredentialFormPair,
|
||||
`,"credential":{"own":"admin"}`: CredentialFormShared,
|
||||
`,"credential":{"own":"token"}`: CredentialFormGiven,
|
||||
`,"credential":{"issued-by":"outside"}`: CredentialFormGiven,
|
||||
`,"credential":{"issued-by":"software","tool":"store_issued_token"}`: CredentialFormIssued,
|
||||
} {
|
||||
node := workstation()
|
||||
node.At = "workstation.internal"
|
||||
consumer := notifierConsumer(t, true, false)
|
||||
r, err := Resolve(shelf(consumer, storeOffering(credential)), []string{consumer.Module}, node, twoStores("anchor"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Account = "op"
|
||||
c, _ := composeWith(t, r, handingTo("store:5000"))
|
||||
got := c.Plans["notify"].Provisions
|
||||
if len(got) != 1 || got[0].CredentialForm != want {
|
||||
t.Errorf("an offer with %q: the plan names %+v, want the form %q", credential, got, want)
|
||||
}
|
||||
}
|
||||
if got := (Manifest{Module: "x"}).CredentialFormOf("postgres-database"); got != "" {
|
||||
t.Errorf("a module offering nothing has the credential form %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A consumer's plan entry carries what its manifest contributes to that provision (issue 776), so the provider's
|
||||
// helper learns a database's name from the bus; a consumer contributing nothing has the entry it had.
|
||||
func TestAHandedModulesPlanEntryCarriesWhatItContributesToItsProvider(t *testing.T) {
|
||||
@@ -125,9 +164,7 @@ func TestAHandedModulesPlanEntryCarriesWhatItContributesToItsProvider(t *testing
|
||||
want := []PlanProvision{{Provision: "postgres-database", Provider: "anchor", ProviderModule: "store",
|
||||
CredentialForm: CredentialFormPair, Contributes: map[string]any{"name": "nextcloud"}, At: "anchor.internal", From: "anchor", As: ConsumerIdentity("workstation", "notify"),
|
||||
// What the binding file says as `${bound:postgres-database:name}` (issue 792).
|
||||
Names: map[string]string{"name": "nextcloud"},
|
||||
// And what a received file carried of it, composed, with where its machine is: opaque bytes (issue 774).
|
||||
Contributed: json.RawMessage(`{"v":1,"at":"workstation.internal","each":[{"values":{"name":"nextcloud"}}]}`)}}
|
||||
Names: map[string]string{"name": "nextcloud"}}}
|
||||
if got := c.Plans["notify"].Provisions; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("the plan names %+v, want %+v", got, want)
|
||||
}
|
||||
@@ -196,7 +233,7 @@ func TestAnOfferAnsweredByTheHelperIsReadAndHandsNoFile(t *testing.T) {
|
||||
if err != nil || !strings.Contains(string(raw), `"answered-by":"helper"`) {
|
||||
t.Fatalf("written back as %s: %v", raw, err)
|
||||
}
|
||||
if _, err := decodeForTest([]byte(strings.Replace(with, `"helper"`, `"harness"`, 1))); err == nil {
|
||||
if _, err := ParseManifest([]byte(strings.Replace(with, `"helper"`, `"harness"`, 1))); err == nil {
|
||||
t.Fatal("an unknown answerer was accepted")
|
||||
}
|
||||
c1, before := composeWith(t, notifierOn(t, held(t, without, builtNotifier())), handingTo("store:5000"))
|
||||
|
||||
@@ -22,7 +22,7 @@ func providing(raw string) string {
|
||||
// onWorkstation resolves m on the workstation either assigned beside desk, or pulled in by desk alone.
|
||||
func onWorkstation(t *testing.T, m Manifest, assigned bool) Resolution {
|
||||
t.Helper()
|
||||
desk, err := decodeForTest([]byte(pulledInBy))
|
||||
desk, err := ParseManifest([]byte(pulledInBy))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -65,15 +65,6 @@ func TestAHandedModulesSecretsAreNamedAndNoValueIsSealed(t *testing.T) {
|
||||
if want := []PlanSecret{{Name: "token", Path: path}}; !reflect.DeepEqual(c.Plans["bot"].Secrets, want) {
|
||||
t.Fatalf("the plan names secrets %+v, want %+v", c.Plans["bot"].Secrets, want)
|
||||
}
|
||||
// Issue 774 (postgres on novox, 2026-10-31): where the vault judged it handed, its secrets are the vault's even on a
|
||||
// machine the node reads as not taking a filled file, and nothing is refused for a value the vault never answers.
|
||||
judged, err := r.Compose(Rendering{ArtifactStore: "store:5000", Assigned: map[string]bool{"bot": true}})
|
||||
if err != nil {
|
||||
t.Fatalf("a module the vault judged handed is refused: %v", err)
|
||||
}
|
||||
if want := []PlanSecret{{Name: "token", Path: path}}; !reflect.DeepEqual(judged.Plans["bot"].Secrets, want) {
|
||||
t.Errorf("judged handed by the vault, the plan names secrets %+v, want %+v", judged.Plans["bot"].Secrets, want)
|
||||
}
|
||||
// A process naming the token's file still restarts when its client writes it.
|
||||
for _, res := range c.Resources {
|
||||
if res["id"] == "bot.daemon" && !strings.Contains(strings.Join(stringsIn(res["restart-on"]), " "), "bot.needs-token") {
|
||||
@@ -108,9 +99,6 @@ func TestAMovedModulesSecretsAreSealedAsBeforeWhereItIsNotHanded(t *testing.T) {
|
||||
for name, with := range map[string]Rendering{
|
||||
"an engine that does not take a filled file alone": {Provisioner: "mesh-provisioner-client", ArtifactStore: "store:5000"},
|
||||
"no provisioner client on the machine": {Hands: true, ArtifactStore: "store:5000"},
|
||||
// Issue 774: where the vault judged the module not handed, the node composes it so, whatever else it reads.
|
||||
"the vault judged it not handed": {Hands: true, Provisioner: "mesh-provisioner-client", ArtifactStore: "store:5000",
|
||||
Assigned: map[string]bool{"bot": false}},
|
||||
} {
|
||||
c, got := composeWith(t, notifierOn(t, held(t, botMoved(), builtBot(botText))), sealedForBot(with))
|
||||
if got != before {
|
||||
@@ -123,6 +111,37 @@ func TestAMovedModulesSecretsAreSealedAsBeforeWhereItIsNotHanded(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhatAHandedModuleCannotHaveFromTheVaultIsRefused(t *testing.T) {
|
||||
// A template naming a credential the module requires: a binding's, which moves with its provider.
|
||||
requires := strings.Replace(botMoved(), `"own-secrets":`, `"requires":["database"],"secrets":{"database":"/etc/bot/db"},"own-secrets":`, 1)
|
||||
r, err := Resolve(shelf(held(t, requires, builtBot("db = ${secret:database}\n")),
|
||||
Manifest{Module: "db", Version: "1", Provides: []Offer{{Name: "database", Scope: ScopeNode}}}),
|
||||
[]string{"bot"}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Account = "op"
|
||||
if _, err := r.Compose(handingTo("store:5000")); err == nil || !strings.Contains(err.Error(), "moves with its provider") {
|
||||
t.Errorf("a template naming a required credential: %v", err)
|
||||
}
|
||||
// A file with its text inline naming a secret, in a handed module.
|
||||
inline := strings.Replace(botMoved(), `{"id":"daemon"`,
|
||||
`{"id":"legacy","type":"file","path":"/etc/bot/legacy","content":"t = ${secret:token}\n"},{"id":"daemon"`, 1)
|
||||
r = notifierOn(t, held(t, inline, builtBot(botText)))
|
||||
if _, err := r.Compose(handingTo("store:5000")); err == nil || !strings.Contains(err.Error(), "keep it in a template") {
|
||||
t.Errorf("an inline file naming a secret in a handed module: %v", err)
|
||||
}
|
||||
// A family and a secret issued outside the mesh are not handed by name yet: the mark is refused.
|
||||
for why, raw := range map[string]string{
|
||||
"a family": strings.Replace(botMoved(), `"own-secrets":{"token":"${dir:state}/token"}`, `"own-secrets":{"token":"${dir:state}/token","key-*":"${dir:state}/keys"}`, 1),
|
||||
"issued outside": strings.Replace(botMoved(), `"own-secrets":{"token":"${dir:state}/token"}`, `"own-secrets":{"token":{"path":"${dir:state}/token","issued-by":"outside"}}`, 1),
|
||||
} {
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "design 57 V4") {
|
||||
t.Errorf("a moved module with %s: %v", why, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A credential of what a handed module requires that the vault keeps for it alone (issue 757): the forge's read
|
||||
// token for one agent, issued by the forge's software per holder. Where the module is handed, its file is declared
|
||||
// placed and without a value, and the plan names it with its provision and provider; anywhere else, and for a
|
||||
|
||||
@@ -141,6 +141,24 @@ func TestAMovedModulesTemplatedFileIsHandedToTheProvisioner(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMovedModulesMarkIsJudged(t *testing.T) {
|
||||
for name, raw := range map[string]string{
|
||||
"marked moved and naming no template": `{"module":"bare","version":"1","moved":true,
|
||||
"resources":[{"id":"conf","type":"file","path":"/etc/bare","content":"x"}]}`,
|
||||
"handed written in a repository": `{"module":"bare","version":"1","moved":true,
|
||||
"handed":{"source":"s","digest":"d","templates":{}},
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
|
||||
"resources":[{"id":"conf","type":"file","path":"/etc/bare","template":"files/bare"}]}`,
|
||||
"handed and not moved": `{"module":"bare","version":"1",
|
||||
"handed":{"source":"s","digest":"d","templates":{}},
|
||||
"resources":[{"id":"conf","type":"file","path":"/etc/bare","content":"x"}]}`,
|
||||
} {
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil {
|
||||
t.Errorf("%s: accepted", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// What `module check` and the merge gate judge: a moved module read from its tree, its text inline. It is still
|
||||
// marked moved, and composes as before, since nothing of its build's archive is there to hand.
|
||||
func TestAMovedModuleReadFromItsTreeIsJudgedAsItComposesUnhanded(t *testing.T) {
|
||||
@@ -151,7 +169,7 @@ func TestAMovedModuleReadFromItsTreeIsJudgedAsItComposesUnhanded(t *testing.T) {
|
||||
if err := os.WriteFile(filepath.Join(dir, "files", "rc"), []byte(notifierText), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, err := decodeForTest([]byte(notifierMoved()))
|
||||
m, err := ParseManifest([]byte(notifierMoved()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -174,6 +174,207 @@ func ReadHealth(r map[string]any) (Health, bool, []string) {
|
||||
return h, true, problems
|
||||
}
|
||||
|
||||
// healthProblems is everything wrong with a manifest's `health` fields, in the manifest's words (to-be
|
||||
// 48 §8): a field on something that does not stay up, a kind its resource cannot have, an endpoint the
|
||||
// module does not declare, a port or an address, a timing outside its bounds, a tool the module does not
|
||||
// serve, and a tool check with no check of another kind beside it on the module.
|
||||
func healthProblems(m Manifest) []string {
|
||||
var problems []string
|
||||
endpoints := map[string]Listening{}
|
||||
for _, l := range m.Listens {
|
||||
if n := strings.TrimSpace(l.Name); n != "" {
|
||||
endpoints[n] = l
|
||||
}
|
||||
}
|
||||
tools := map[string]bool{}
|
||||
for _, t := range m.Tools {
|
||||
tools[t] = true
|
||||
}
|
||||
wants := map[string]bool{}
|
||||
for _, w := range m.Wants() {
|
||||
wants[w] = true
|
||||
}
|
||||
var toolChecks []string
|
||||
otherKinds := 0
|
||||
resources := m.Resources
|
||||
// A module's own runtime is judged as the process the controller writes for it (novox/hq ADR 0366 §1),
|
||||
// and only a module that runs as its own account has one.
|
||||
own, hasOwn := OwnRuntimeHealth(m)
|
||||
if hasOwn {
|
||||
if m.RunsAs == "" {
|
||||
problems = append(problems, fmt.Sprintf("%s declares runtime-health and runs as no account of its own "+
|
||||
"(runs-as): only a module served by a runtime of its own has that runtime's health to declare "+
|
||||
"(novox/hq ADR 0366 §1)", m.Module))
|
||||
hasOwn = false
|
||||
} else {
|
||||
resources = append(append([]map[string]any(nil), m.Resources...), own)
|
||||
}
|
||||
}
|
||||
for i, r := range resources {
|
||||
h, has, read := ReadHealth(r)
|
||||
if !has {
|
||||
continue
|
||||
}
|
||||
ownRuntime := hasOwn && i == len(resources)-1
|
||||
id, typ := fmt.Sprint(r["id"]), fmt.Sprint(r["type"])
|
||||
where := m.Module + ": " + id
|
||||
if ownRuntime {
|
||||
where = m.Module + ": its runtime-health"
|
||||
}
|
||||
for _, p := range read {
|
||||
problems = append(problems, m.Module+": "+p)
|
||||
}
|
||||
if !LongRunning(r) {
|
||||
problems = append(problems, fmt.Sprintf("%s declares health and does not stay up: a step, anything on a "+
|
||||
"schedule and a service not stated running are judged by their step and their schedule (ADR 0240 rule 1)", where))
|
||||
continue
|
||||
}
|
||||
switch h.Kind {
|
||||
case HealthRuntime, HealthExec:
|
||||
if typ != "container" {
|
||||
problems = append(problems, fmt.Sprintf("%s is a %s and its health is %q, which only a container has: "+
|
||||
"the runtime runs it inside the container", where, typ, h.Kind))
|
||||
}
|
||||
case HealthUnit:
|
||||
if typ == "container" {
|
||||
problems = append(problems, fmt.Sprintf("%s is a container and its health is %q, which is a service's "+
|
||||
"or a process's own readiness", where, h.Kind))
|
||||
}
|
||||
case HealthHTTP, HealthTCP, HealthTool:
|
||||
case "":
|
||||
problems = append(problems, fmt.Sprintf("%s declares health with no kind: %s", where, healthKindsWords()))
|
||||
default:
|
||||
problems = append(problems, fmt.Sprintf("%s declares health of kind %q: %s", where, h.Kind, healthKindsWords()))
|
||||
}
|
||||
switch h.Kind {
|
||||
case HealthHTTP, HealthTCP:
|
||||
l, declared := endpoints[h.Endpoint]
|
||||
switch {
|
||||
case h.Endpoint == "":
|
||||
problems = append(problems, fmt.Sprintf("%s's %s check names no endpoint: it names one the module "+
|
||||
"declares under listens, by its name", where, h.Kind))
|
||||
case !declared:
|
||||
problems = append(problems, fmt.Sprintf("%s's %s check names the endpoint %q, which %s does not declare "+
|
||||
"under listens (%s)", where, h.Kind, h.Endpoint, m.Module, namedEndpointsWords(endpoints)))
|
||||
case l.At() != "tcp":
|
||||
problems = append(problems, fmt.Sprintf("%s's %s check names %q, which is %s: a check connects over tcp",
|
||||
where, h.Kind, h.Endpoint, l.At()))
|
||||
}
|
||||
default:
|
||||
if h.Endpoint != "" {
|
||||
problems = append(problems, fmt.Sprintf("%s's %s check names an endpoint, which only an http or tcp check "+
|
||||
"looks at", where, h.Kind))
|
||||
}
|
||||
}
|
||||
if h.Kind != HealthHTTP && (h.Path != "" || h.Status != 0 || h.Body != "" || h.Scheme != "") {
|
||||
problems = append(problems, fmt.Sprintf("%s's %s check says a path, a status, a body or a scheme, which "+
|
||||
"only an http check has", where, h.Kind))
|
||||
}
|
||||
if h.Kind == HealthHTTP {
|
||||
if h.Path != "" && !strings.HasPrefix(h.Path, "/") {
|
||||
problems = append(problems, fmt.Sprintf("%s's http check asks %q; a path starts with /", where, h.Path))
|
||||
}
|
||||
if h.Status != 0 && (h.Status < 100 || h.Status > 599) {
|
||||
problems = append(problems, fmt.Sprintf("%s's http check expects status %d, which is not one", where, h.Status))
|
||||
}
|
||||
if h.Scheme != "" && h.Scheme != "http" && h.Scheme != "https" {
|
||||
problems = append(problems, fmt.Sprintf("%s's http check is over %q; it is http or https", where, h.Scheme))
|
||||
}
|
||||
}
|
||||
if (h.Command != "") != (h.Kind == HealthExec) {
|
||||
if h.Kind == HealthExec {
|
||||
problems = append(problems, fmt.Sprintf("%s's exec check says no command", where))
|
||||
} else {
|
||||
problems = append(problems, fmt.Sprintf("%s's %s check says a command, which only an exec check runs",
|
||||
where, h.Kind))
|
||||
}
|
||||
}
|
||||
if h.Kind == HealthTool {
|
||||
switch {
|
||||
case h.Tool == "":
|
||||
problems = append(problems, fmt.Sprintf("%s's tool check names no tool", where))
|
||||
case !tools[h.Tool]:
|
||||
problems = append(problems, fmt.Sprintf("%s's tool check asks %q, which %s does not serve (its tools: %s)",
|
||||
where, h.Tool, m.Module, orNoneWords(m.Tools)))
|
||||
}
|
||||
// Its own runtime being alive, judged by the node-engine, is the check the module does not run
|
||||
// itself that this tool check stands beside (novox/hq ADR 0366 §2).
|
||||
if !ownRuntime {
|
||||
toolChecks = append(toolChecks, id)
|
||||
}
|
||||
} else {
|
||||
if h.Tool != "" {
|
||||
problems = append(problems, fmt.Sprintf("%s's %s check names a tool, which only a tool check asks", where, h.Kind))
|
||||
}
|
||||
if h.Kind != "" {
|
||||
otherKinds++
|
||||
}
|
||||
}
|
||||
if h.Needs != "" && !wants[h.Needs] {
|
||||
problems = append(problems, fmt.Sprintf("%s's check needs %q, which %s does not require: a check names "+
|
||||
"the provision it exercises, among those the module requires", where, h.Needs, m.Module))
|
||||
}
|
||||
problems = append(problems, healthTimingProblems(where, h)...)
|
||||
}
|
||||
if len(toolChecks) > 0 && otherKinds == 0 {
|
||||
problems = append(problems, fmt.Sprintf("%s judges itself only by its own tool (%s): a tool check is for "+
|
||||
"function no endpoint shows, and only beside a check of another kind the module does not run itself "+
|
||||
"(ADR 0227 rule 8, ADR 0240 rule 2)", m.Module, strings.Join(toolChecks, ", ")))
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// healthTimingProblems holds a check's timing to its bounds.
|
||||
func healthTimingProblems(where string, h Health) []string {
|
||||
var problems []string
|
||||
if h.Interval < HealthIntervalFloor {
|
||||
problems = append(problems, fmt.Sprintf("%s looks every %s; a check looks no more often than every %s — "+
|
||||
"the mesh is a guest on the machine (ADR 0240 rule 2)", where, h.Interval, HealthIntervalFloor))
|
||||
}
|
||||
if h.Timeout <= 0 || h.Timeout >= h.Interval {
|
||||
problems = append(problems, fmt.Sprintf("%s gives a look %s, which must be more than nothing and under its "+
|
||||
"interval of %s", where, h.Timeout, h.Interval))
|
||||
}
|
||||
if h.Looks < HealthLooksFloor {
|
||||
problems = append(problems, fmt.Sprintf("%s is unhealthy after %d failing look(s); it is at least %d — one "+
|
||||
"look can be wrong (issue 277)", where, h.Looks, HealthLooksFloor))
|
||||
}
|
||||
if h.Grace < 0 {
|
||||
problems = append(problems, fmt.Sprintf("%s has a grace of %s", where, h.Grace))
|
||||
}
|
||||
if h.Looks >= HealthLooksFloor && h.Interval >= HealthIntervalFloor {
|
||||
if took := h.Grace + time.Duration(h.Looks)*h.Interval; took > HealthWithin {
|
||||
problems = append(problems, fmt.Sprintf("%s is said unhealthy at the earliest %s after it starts (a grace "+
|
||||
"of %s and %d looks every %s); it is at most %s, so a resource broken from its start is said within "+
|
||||
"the gate's bound", where, took, h.Grace, h.Looks, h.Interval, HealthWithin))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
func healthKindsWords() string {
|
||||
return "a check is runtime (the image's own, adopted by name), http, tcp, exec, unit or tool"
|
||||
}
|
||||
|
||||
func namedEndpointsWords(endpoints map[string]Listening) string {
|
||||
if len(endpoints) == 0 {
|
||||
return "it names none"
|
||||
}
|
||||
names := make([]string, 0, len(endpoints))
|
||||
for n := range endpoints {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return "it names " + strings.Join(names, ", ")
|
||||
}
|
||||
|
||||
func orNoneWords(names []string) string {
|
||||
if len(names) == 0 {
|
||||
return "none"
|
||||
}
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
|
||||
// Undeclared is every long-running resource of the manifest without `health`, by id (ADR 0240 rule 8):
|
||||
// what the catalogue's count counts.
|
||||
func Undeclared(m Manifest) []string {
|
||||
|
||||
@@ -53,24 +53,78 @@ func TestAWellFormedHealthIsAccepted(t *testing.T) {
|
||||
{"kind": "runtime"},
|
||||
{"kind": "exec", "command": "pg_isready -q", "grace": "4m", "looks": 2, "interval": "30s"},
|
||||
} {
|
||||
if _, err := decodeForTest(healthManifest(t, "server", h)); err != nil {
|
||||
if _, err := ParseManifest(healthManifest(t, "server", h)); err != nil {
|
||||
t.Errorf("%v was refused: %v", h, err)
|
||||
}
|
||||
}
|
||||
if _, err := decodeForTest(healthManifest(t, "daemon", map[string]any{"kind": "unit"})); err != nil {
|
||||
if _, err := ParseManifest(healthManifest(t, "daemon", map[string]any{"kind": "unit"})); err != nil {
|
||||
t.Errorf("a service's own readiness was refused: %v", err)
|
||||
}
|
||||
// A tool beside a check of another kind on the module.
|
||||
raw := healthManifest(t, "server", map[string]any{"kind": "http", "endpoint": "web"},
|
||||
map[string]any{"id": "admin", "type": "service", "unit": "app-admin.service", "state": "running",
|
||||
"health": map[string]any{"kind": "tool", "tool": "app_status"}})
|
||||
if _, err := decodeForTest(raw); err != nil {
|
||||
if _, err := ParseManifest(raw); err != nil {
|
||||
t.Errorf("a tool check beside an http check was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryOutOfBoundsHealthIsRefusedByName(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
on string
|
||||
health map[string]any
|
||||
says string
|
||||
}{
|
||||
{"no kind", "server", map[string]any{"endpoint": "web"}, "with no kind"},
|
||||
{"an unknown kind", "server", map[string]any{"kind": "ping"}, `of kind "ping"`},
|
||||
{"a port", "server", map[string]any{"kind": "tcp", "port": 8080}, "names a port"},
|
||||
{"an address", "server", map[string]any{"kind": "http", "endpoint": "web", "address": "127.0.0.1"}, "names a address"},
|
||||
{"a url", "server", map[string]any{"kind": "http", "url": "http://localhost:8080/"}, "names a url"},
|
||||
{"an unknown key", "server", map[string]any{"kind": "tcp", "endpoint": "web", "retries": 3}, `"retries"`},
|
||||
{"no endpoint", "server", map[string]any{"kind": "http"}, "names no endpoint"},
|
||||
{"an undeclared endpoint", "server", map[string]any{"kind": "tcp", "endpoint": "admin"}, "does not declare"},
|
||||
{"a udp endpoint", "server", map[string]any{"kind": "tcp", "endpoint": "beacon"}, "a check connects over tcp"},
|
||||
{"an interval under the floor", "server", map[string]any{"kind": "tcp", "endpoint": "web", "interval": "5s", "timeout": "1s"}, "no more often than every 10s"},
|
||||
{"a timeout of the interval", "server", map[string]any{"kind": "tcp", "endpoint": "web", "interval": "10s", "timeout": "10s"}, "under its interval"},
|
||||
{"one failing look", "server", map[string]any{"kind": "tcp", "endpoint": "web", "looks": 1}, "at least 2"},
|
||||
{"a grace and looks past five minutes", "server", map[string]any{"kind": "tcp", "endpoint": "web", "grace": "4m", "looks": 3, "interval": "30s"}, "at most 5m0s"},
|
||||
{"a duration that is not one", "server", map[string]any{"kind": "tcp", "endpoint": "web", "interval": "often"}, "is a duration"},
|
||||
{"looks that are not a number", "server", map[string]any{"kind": "tcp", "endpoint": "web", "looks": "three"}, "whole number"},
|
||||
{"a path without a slash", "server", map[string]any{"kind": "http", "endpoint": "web", "path": "health"}, "starts with /"},
|
||||
{"a status that is not one", "server", map[string]any{"kind": "http", "endpoint": "web", "status": 700}, "is not one"},
|
||||
{"a scheme that is not one", "server", map[string]any{"kind": "http", "endpoint": "web", "scheme": "ftp"}, "http or https"},
|
||||
{"a status on a tcp check", "server", map[string]any{"kind": "tcp", "endpoint": "web", "status": 200}, "only an http check has"},
|
||||
{"an exec with no command", "server", map[string]any{"kind": "exec"}, "says no command"},
|
||||
{"a command on an http check", "server", map[string]any{"kind": "http", "endpoint": "web", "command": "true"}, "only an exec check runs"},
|
||||
{"a runtime check on a service", "daemon", map[string]any{"kind": "runtime"}, "only a container has"},
|
||||
{"a unit check on a container", "server", map[string]any{"kind": "unit"}, "a service's or a process's"},
|
||||
{"a tool the module does not serve", "server", map[string]any{"kind": "tool", "tool": "app_admin"}, "does not serve"},
|
||||
{"a tool check alone", "server", map[string]any{"kind": "tool", "tool": "app_status"}, "judges itself only by its own tool"},
|
||||
{"needs not required", "server", map[string]any{"kind": "tcp", "endpoint": "web", "needs": "redis"}, "does not require"},
|
||||
{"health on a step", "seed", map[string]any{"kind": "runtime"}, "does not stay up"},
|
||||
{"health on a schedule", "sweep", map[string]any{"kind": "runtime"}, "does not stay up"},
|
||||
{"health that is not an object", "server", nil, "is an object"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
raw := healthManifest(t, c.on, c.health)
|
||||
if c.health == nil {
|
||||
raw = []byte(strings.Replace(string(raw), `"name":"app-server"`, `"name":"app-server","health":"tcp"`, 1))
|
||||
}
|
||||
_, err := ParseManifest(raw)
|
||||
if err == nil {
|
||||
t.Fatalf("%s was accepted", c.name)
|
||||
}
|
||||
if !strings.Contains(err.Error(), c.says) {
|
||||
t.Errorf("refused, but not for saying %q: %v", c.says, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthIsComposedAsThePortThisMachineGaveTheEndpoint(t *testing.T) {
|
||||
m, err := decodeForTest(healthManifest(t, "server", map[string]any{"kind": "http", "endpoint": "web", "path": "/healthz",
|
||||
m, err := ParseManifest(healthManifest(t, "server", map[string]any{"kind": "http", "endpoint": "web", "path": "/healthz",
|
||||
"needs": "postgres-database"}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -109,7 +163,7 @@ func TestHealthIsComposedAsThePortThisMachineGaveTheEndpoint(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTheUndeclaredAreTheLongRunningWithoutHealth(t *testing.T) {
|
||||
m, err := decodeForTest(healthManifest(t, "server", map[string]any{"kind": "runtime"}))
|
||||
m, err := ParseManifest(healthManifest(t, "server", map[string]any{"kind": "runtime"}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -122,7 +176,7 @@ func TestATooledHealthIsGrantedToTheEngine(t *testing.T) {
|
||||
raw := healthManifest(t, "server", map[string]any{"kind": "http", "endpoint": "web"},
|
||||
map[string]any{"id": "admin", "type": "service", "unit": "app-admin.service", "state": "running",
|
||||
"health": map[string]any{"kind": "tool", "tool": "app_status"}})
|
||||
m, err := decodeForTest(raw)
|
||||
m, err := ParseManifest(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -140,7 +194,7 @@ func TestEveryComposedHealthIsOneTheNodeEngineTakes(t *testing.T) {
|
||||
{"kind": "runtime"},
|
||||
{"kind": "exec", "command": "pg_isready -q"},
|
||||
} {
|
||||
m, err := decodeForTest(healthManifest(t, "server", h,
|
||||
m, err := ParseManifest(healthManifest(t, "server", h,
|
||||
map[string]any{"id": "admin", "type": "service", "unit": "app-admin.service", "state": "running",
|
||||
"health": map[string]any{"kind": "tool", "tool": "app_status"}}))
|
||||
if err != nil {
|
||||
|
||||
@@ -174,3 +174,47 @@ func (r Resolution) Overflowing() []Overflow {
|
||||
// passes on every machine that mesh has. `module check --longest-machine-name` says another mesh's;
|
||||
// a mesh that names a longer machine raises this in the same change (ADR 0225).
|
||||
const DefaultLongestMachine = 6
|
||||
|
||||
// IdentityProblems is every module whose identity would overflow a provision it wants, on a machine
|
||||
// whose name is `longestMachine` characters — judged before merge, over the catalogue alone, so the
|
||||
// pull request that introduces an overflow is the one refused (novox/hq ADR 0225, issue 263). A
|
||||
// provision no module in the shelf offers is not judged: its bound is not known here.
|
||||
func IdentityProblems(shelf Shelf, longestMachine int) []string {
|
||||
offeredBy := map[string][]string{}
|
||||
for _, name := range shelfOrder(shelf) {
|
||||
for _, o := range shelf[name].Offers() {
|
||||
offeredBy[o] = append(offeredBy[o], name)
|
||||
}
|
||||
}
|
||||
machine := strings.Repeat("n", longestMachine)
|
||||
var problems []string
|
||||
for _, name := range shelfOrder(shelf) {
|
||||
m := shelf[name]
|
||||
source := IdentitySource(m.Slug, m.Module)
|
||||
for _, want := range m.Wants() {
|
||||
// The tightest bound among the modules offering it: whichever one answers on a given
|
||||
// machine, the identity has to fit it.
|
||||
tightest, by := IdentityBound{}, ""
|
||||
for _, provider := range offeredBy[want] {
|
||||
if provider == name {
|
||||
continue // a module answering its own requirement is not its own consumer
|
||||
}
|
||||
b := shelf[provider].IdentityBoundOf(want)
|
||||
if b.Bounded() && (!tightest.Bounded() || b.Max < tightest.Max) {
|
||||
tightest, by = b, provider
|
||||
}
|
||||
}
|
||||
if CheckIdentityWithin(machine, source, tightest) == nil {
|
||||
continue
|
||||
}
|
||||
got := ConsumerIdentity(machine, source)
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s wants %s, and %s keeps its consumers' identities in %s of at most %d characters: "+
|
||||
"on a machine with a %d-character name it is identified as %q, %d — give %s a "+
|
||||
"`slug` of at most %d characters",
|
||||
name, want, by, tightest.In, tightest.Max, longestMachine, got, len(got), name,
|
||||
tightest.Max-len(IdentityPrefix)-longestMachine-1))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -60,6 +61,97 @@ func TestAnUnstatedBoundFollowsWhatTheProviderIsTold(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The field reads as written and writes back the same, and refuses what says nothing.
|
||||
func TestAnOffersIdentityIsParsedStrictly(t *testing.T) {
|
||||
for _, raw := range []string{
|
||||
`{"name":"s3-bucket","scope":"mesh","identity":{"max":20,"in":"an S3 access key"}}`,
|
||||
`{"name":"wildcard-resolution","scope":"mesh","identity":false}`,
|
||||
`{"name":"redis-cache","scope":"mesh","identity":{"in":"a Redis ACL user"}}`,
|
||||
} {
|
||||
var o Offer
|
||||
if err := json.Unmarshal([]byte(raw), &o); err != nil {
|
||||
t.Fatalf("%s: %v", raw, err)
|
||||
}
|
||||
back, err := json.Marshal(o)
|
||||
if err != nil || string(back) != raw {
|
||||
t.Errorf("did not round-trip:\n%s\n%s (%v)", raw, back, err)
|
||||
}
|
||||
}
|
||||
for _, raw := range []string{
|
||||
`{"name":"x","identity":true}`,
|
||||
`{"name":"x","identity":{"max":20,"in":"y","most":3}}`,
|
||||
} {
|
||||
var o Offer
|
||||
if err := json.Unmarshal([]byte(raw), &o); err == nil {
|
||||
t.Errorf("accepted %s", raw)
|
||||
}
|
||||
}
|
||||
bad := Manifest{Module: "bad", Version: "1", Provides: []Offer{
|
||||
{Name: "unsaid", Scope: ScopeMesh, Identity: &OfferIdentity{Max: 20}},
|
||||
{Name: "tiny", Scope: ScopeMesh, Identity: &OfferIdentity{Max: 4, In: "nothing usable"}},
|
||||
}}
|
||||
raw, err := json.Marshal(bad)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = ParseManifest(raw)
|
||||
if err == nil || !strings.Contains(err.Error(), "without saying what keeps them") ||
|
||||
!strings.Contains(err.Error(), "the shortest the mesh makes") {
|
||||
t.Fatalf("a bound with no `in`, or too short for any identity, was accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Refused before merge: the catalogue check judges each module's identity, on the longest machine
|
||||
// name, against the bound of every provision it wants — and names the module and the slug to set.
|
||||
func TestTheCatalogueCheckRefusesAnIdentityThatOverflowsWhatItRequires(t *testing.T) {
|
||||
shelf := Shelf{
|
||||
"objects": {Module: "objects", Provides: []Offer{{Name: "s3-bucket", Scope: ScopeMesh,
|
||||
Identity: &OfferIdentity{Max: 20, In: "an S3 access key"}}},
|
||||
Receives: map[string]string{"s3-bucket": "/var/lib/mesh/objects/mesh.json"}},
|
||||
"photoalbum": {Module: "photoalbum", Requires: []string{"s3-bucket"}},
|
||||
"files": {Module: "files", Requires: []string{"s3-bucket"}},
|
||||
}
|
||||
problems := IdentityProblems(shelf, 6)
|
||||
if len(problems) != 1 || !strings.Contains(problems[0], "photoalbum wants s3-bucket") ||
|
||||
!strings.Contains(problems[0], `"mesh_nnnnnn_photoalbum", 22`) ||
|
||||
!strings.Contains(problems[0], "`slug` of at most 8 characters") {
|
||||
t.Fatalf("one overflow, named with its remedy, was expected: %q", problems)
|
||||
}
|
||||
// A longer machine name refuses more: the check is about the mesh's machines, not one.
|
||||
if got := IdentityProblems(shelf, 10); len(got) != 2 {
|
||||
t.Fatalf("on a 10-character name both overflow (mesh_nnnnnnnnnn_files is 21): %q", got)
|
||||
}
|
||||
// And a slug is the remedy it names.
|
||||
album := shelf["photoalbum"]
|
||||
album.Slug = "album"
|
||||
shelf["photoalbum"] = album
|
||||
if got := IdentityProblems(shelf, 6); len(got) != 0 {
|
||||
t.Fatalf("a slug that fits is still refused: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Tonight's case (issue 263): networkmanager, no slug, requiring the mesh's resolver provision on a
|
||||
// machine with a six-character name. Under ADR 0049's one bound it was refused, and its provider's
|
||||
// whole machine with it; the resolver keeps no name, so it is not refused at all.
|
||||
func TestARequirementOnAKeylessProvisionComposesWithALongName(t *testing.T) {
|
||||
shelf := Shelf{
|
||||
"resolver": {Module: "resolver", Provides: []Offer{{Name: "wildcard-resolution", Scope: ScopeMesh}}},
|
||||
"networkmanager": {Module: "networkmanager", Requires: []string{"wildcard-resolution"}},
|
||||
}
|
||||
if CheckIdentity("laptop", "networkmanager") == nil {
|
||||
t.Fatal("the regression is not reproduced: mesh_laptop_networkmanager fits the old global bound")
|
||||
}
|
||||
if got := IdentityProblems(shelf, 6); len(got) != 0 {
|
||||
t.Fatalf("a requirement on a keyless provision was refused for its length: %q", got)
|
||||
}
|
||||
r := Resolution{Node: "laptop", Modules: []Manifest{shelf["networkmanager"]},
|
||||
Needs: []Needed{{Name: "wildcard-resolution", From: "anchor", For: "networkmanager",
|
||||
Identity: shelf["resolver"].IdentityBoundOf("wildcard-resolution")}}}
|
||||
if got := r.Overflowing(); len(got) != 0 {
|
||||
t.Fatalf("a keyless requirement is reported as overflowing: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The consumer's side of the same judgement the provider's composition makes: what `status` says.
|
||||
func TestAnOverflowingRequirementIsNamedFromTheConsumersSide(t *testing.T) {
|
||||
bound := IdentityBound{Max: 20, In: "an S3 access key"}
|
||||
@@ -82,3 +174,17 @@ func TestAnOverflowingRequirementIsNamedFromTheConsumersSide(t *testing.T) {
|
||||
t.Fatalf("the overflow does not say what keeps it or the remedy: %s", said)
|
||||
}
|
||||
}
|
||||
|
||||
// An identity served as a DNS label is never truncated, so the offer's bound must keep it in one.
|
||||
func TestAnIdentityServedAsADNSLabelIsBoundedToOne(t *testing.T) {
|
||||
serves := map[string]map[string]any{"bucket": {"name": "${consumer:as:dns}"}}
|
||||
wide := Manifest{Module: "wide", Serves: serves, Provides: []Offer{{Name: "bucket", Scope: ScopeMesh,
|
||||
Identity: &OfferIdentity{Max: 255, In: "a client id"}}}}
|
||||
if got := CheckServes(wide); len(got) != 1 || !strings.Contains(got[0], "a label keeps 63") {
|
||||
t.Fatalf("a 255-character bound on a DNS label passed: %q", got)
|
||||
}
|
||||
unsaid := Manifest{Module: "unsaid", Serves: serves, Provides: []Offer{{Name: "bucket", Scope: ScopeMesh}}}
|
||||
if got := CheckServes(unsaid); len(got) != 0 {
|
||||
t.Fatalf("the default bound (20) on a DNS label was refused: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A definition names no installation (novox/hq ADR 0112, ADR 0155, issues 122 and 134).
|
||||
//
|
||||
// A module definition holds what is true of the module everywhere; what is particular to one mesh —
|
||||
@@ -25,3 +34,203 @@ package catalogue
|
||||
// NamesOnPurpose is the catalogue-level word a resource carries for the names it means to name:
|
||||
// each name mapped to its reason. The host never sees it.
|
||||
const NamesOnPurpose = "names-on-purpose"
|
||||
|
||||
// prose is every key whose value the mesh never reads.
|
||||
var prose = map[string]bool{"why": true, "description": true}
|
||||
|
||||
// Registries the world runs, which an image may name because an image reference must say where it
|
||||
// is pulled from. Anything else in an image reference is a registry of some installation.
|
||||
var worldsRegistries = map[string]bool{
|
||||
"docker.io": true, "registry-1.docker.io": true, "index.docker.io": true, "ghcr.io": true,
|
||||
"quay.io": true, "gcr.io": true, "registry.k8s.io": true, "k8s.gcr.io": true,
|
||||
"mcr.microsoft.com": true, "lscr.io": true, "public.ecr.aws": true, "registry.gitlab.com": true,
|
||||
"codeberg.org": true, "cgr.dev": true,
|
||||
}
|
||||
|
||||
// Services the world runs that a definition may name as a policy default, the way it may name a
|
||||
// public resolver: the public certificate authorities' ACME directories. Anything else a served
|
||||
// fact or a file names is somebody's installation.
|
||||
var worldsServices = map[string]bool{
|
||||
"acme-v02.api.letsencrypt.org": true, "acme-staging-v02.api.letsencrypt.org": true,
|
||||
"api.buypass.com": true, "api.test4.buypass.no": true, "dv.acme-v02.api.pki.goog": true,
|
||||
"acme.zerossl.com": true,
|
||||
}
|
||||
|
||||
// Resolvers the world runs, which a machine's resolver may forward to as a policy default.
|
||||
var worldsResolvers = map[string]bool{
|
||||
"1.1.1.1": true, "1.0.0.1": true, "8.8.8.8": true, "8.8.4.4": true, "9.9.9.9": true,
|
||||
"149.112.112.112": true, "208.67.222.222": true, "208.67.220.220": true,
|
||||
}
|
||||
|
||||
// hostname is a dotted name whose last label is a top-level domain a real installation would have.
|
||||
// Not every dotted token: `module.json`, `index.html` and `docker.sock` are dotted and name nothing.
|
||||
// Boundaries are checked by hand rather than in the pattern, because two names one character apart
|
||||
// — `a.example.tld,b.example.tld` — would otherwise share the delimiter and the second would be lost.
|
||||
var hostname = regexp.MustCompile(
|
||||
`(?i)(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+` +
|
||||
`(?:be|nl|de|fr|uk|eu|com|net|org|io|dev|app|cloud|site|online|me|co|ch|at|lu|` +
|
||||
`internal|example|tld|test|invalid)`)
|
||||
|
||||
// upstreamVersion is where an artifact pinned by URL says which release it is (Artifact.Version).
|
||||
var upstreamVersion = regexp.MustCompile(`^build\.artifacts\[[0-9]+\]\.version$`)
|
||||
|
||||
// address is a dotted quad.
|
||||
var address = regexp.MustCompile(`(?:[0-9]{1,3}\.){3}[0-9]{1,3}`)
|
||||
|
||||
// isName is whether a byte may be part of a name; a match bordered by one is a longer token.
|
||||
func isName(b byte) bool {
|
||||
return b == '.' || b == '-' || (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9')
|
||||
}
|
||||
|
||||
// standalone are the matches of re in value that are whole tokens, not parts of a longer one.
|
||||
func standalone(re *regexp.Regexp, value string) []string {
|
||||
var out []string
|
||||
for _, span := range re.FindAllStringIndex(value, -1) {
|
||||
if span[0] > 0 && isName(value[span[0]-1]) {
|
||||
continue
|
||||
}
|
||||
if span[1] < len(value) && isName(value[span[1]]) {
|
||||
continue
|
||||
}
|
||||
out = append(out, value[span[0]:span[1]])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// InstallationProblems is every value of a definition that names an installation, in the
|
||||
// definition's own words: where it is, and what it names.
|
||||
func InstallationProblems(m Manifest) []string {
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return []string{fmt.Sprintf("%s could not be read back: %v", m.Module, err)}
|
||||
}
|
||||
var tree any
|
||||
if err := json.Unmarshal(raw, &tree); err != nil {
|
||||
return []string{fmt.Sprintf("%s could not be read back: %v", m.Module, err)}
|
||||
}
|
||||
var problems []string
|
||||
// The module's own name is a value too: a module named after the domain it serves is a
|
||||
// definition that can only be installed there (issue 134).
|
||||
for _, name := range namesIn(m.Module) {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s is named after %s, and a module is named for what it is, not for where it runs", m.Module, name))
|
||||
}
|
||||
walk(tree, "", nil, func(at string, value string, meant map[string]bool, isImage bool) {
|
||||
for _, name := range namesIn(value) {
|
||||
if (isImage && worldsRegistries[strings.ToLower(name)]) || meant[name] {
|
||||
continue
|
||||
}
|
||||
problems = append(problems, fmt.Sprintf("%s names %s at %s", m.Module, name, at))
|
||||
}
|
||||
for _, ip := range addressesIn(value) {
|
||||
// An upstream pin's version may have four dotted numbers (Owl 1.5.6.1), which is no address; a name
|
||||
// in it is still judged above. Its URL is judged whole: the vendor's host goes through the artifact's
|
||||
// names-on-purpose like any name a definition means (novox/hq ADR 0316).
|
||||
if meant[ip] || upstreamVersion.MatchString(at) {
|
||||
continue
|
||||
}
|
||||
problems = append(problems, fmt.Sprintf("%s names the public address %s at %s", m.Module, ip, at))
|
||||
}
|
||||
})
|
||||
sort.Strings(problems)
|
||||
return problems
|
||||
}
|
||||
|
||||
// walk visits every string in the tree with its path, the names the enclosing resource means to
|
||||
// name (with a reason), and whether it is an image reference.
|
||||
func walk(node any, at string, meant map[string]bool, visit func(at, value string, meant map[string]bool, isImage bool)) {
|
||||
switch v := node.(type) {
|
||||
case map[string]any:
|
||||
if declared, has := v[NamesOnPurpose].(map[string]any); has {
|
||||
widened := map[string]bool{}
|
||||
for name := range meant {
|
||||
widened[name] = true
|
||||
}
|
||||
for name, reason := range declared {
|
||||
if r, ok := reason.(string); ok && strings.TrimSpace(r) != "" {
|
||||
widened[strings.ToLower(name)] = true
|
||||
}
|
||||
}
|
||||
meant = widened
|
||||
}
|
||||
keys := make([]string, 0, len(v))
|
||||
for k := range v {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
// Prose is a string a person reads. A key that is called `why` or `description` and holds
|
||||
// anything else — a setting of that name, whose default the mesh writes — is walked like any
|
||||
// other (novox/hq ADR 0262).
|
||||
if _, isString := v[k].(string); (prose[k] && isString) || k == NamesOnPurpose || (at == "" && k == "module") {
|
||||
continue
|
||||
}
|
||||
child := at + "." + k
|
||||
if at == "" {
|
||||
child = k
|
||||
}
|
||||
if s, isString := v[k].(string); isString {
|
||||
visit(child, s, meant, k == "image")
|
||||
continue
|
||||
}
|
||||
walk(v[k], child, meant, visit)
|
||||
}
|
||||
case []any:
|
||||
for i, item := range v {
|
||||
child := fmt.Sprintf("%s[%d]", at, i)
|
||||
if s, isString := item.(string); isString {
|
||||
visit(child, s, meant, false)
|
||||
continue
|
||||
}
|
||||
walk(item, child, meant, visit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// namesIn is every hostname in a value that could belong to an installation.
|
||||
func namesIn(value string) []string {
|
||||
var out []string
|
||||
for _, found := range standalone(hostname, value) {
|
||||
name := strings.ToLower(found)
|
||||
switch {
|
||||
case strings.HasSuffix(name, ".docker.internal"):
|
||||
// The container runtime's alias for its own host: every machine running it has one.
|
||||
case worldsServices[name]:
|
||||
// A public authority named as a policy default, true of any mesh that wants it.
|
||||
case name == "example.tld", strings.HasSuffix(name, ".example.tld"),
|
||||
name == "example.com", name == "example.net", name == "example.org",
|
||||
strings.HasSuffix(name, ".example.com"), strings.HasSuffix(name, ".example.net"),
|
||||
strings.HasSuffix(name, ".example.org"), strings.HasSuffix(name, ".example"),
|
||||
strings.HasSuffix(name, ".test"), strings.HasSuffix(name, ".invalid"):
|
||||
// Documentation names, which is what a definition's own example should use.
|
||||
default:
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// addressesIn is every public address in a value: not a private range, loopback, link-local, the
|
||||
// unspecified address, a documentation range, or a resolver the world runs.
|
||||
func addressesIn(value string) []string {
|
||||
var out []string
|
||||
for _, found := range standalone(address, value) {
|
||||
ip := net.ParseIP(found)
|
||||
if ip == nil || ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() ||
|
||||
ip.IsUnspecified() || ip.IsMulticast() || worldsResolvers[found] || documentation(ip) {
|
||||
continue
|
||||
}
|
||||
out = append(out, found)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func documentation(ip net.IP) bool {
|
||||
for _, cidr := range []string{"192.0.2.0/24", "198.51.100.0/24", "203.0.113.0/24", "100.64.0.0/10"} {
|
||||
_, block, _ := net.ParseCIDR(cidr)
|
||||
if block.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1 +1,93 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A definition names no installation (novox/hq ADR 0112, ADR 0155). What the mesh acts on is judged;
|
||||
// prose is not; the world's registries and resolvers are the world's; a declared exception is a
|
||||
// reason a reader sees.
|
||||
func TestADefinitionNamingAnInstallationIsNamedBack(t *testing.T) {
|
||||
m := Manifest{Module: "idp", Resources: []map[string]any{
|
||||
{"id": "server", "type": "container", "image": "quay.io/keycloak/keycloak@sha256:aa",
|
||||
"env": map[string]any{"KC_HOSTNAME": "https://login.mesh-one.be"}},
|
||||
{"id": "env", "type": "file", "content": "REAL_IP_FROM=192.168.1.0/24,127.0.0.0/8,203.0.113.7,51.15.22.9\n"},
|
||||
}, Listens: []Listening{{Port: 8080, From: FromMesh, Why: "the login page; login.mesh-one.be is a route grant"}}}
|
||||
got := strings.Join(InstallationProblems(m), "\n")
|
||||
for _, want := range []string{
|
||||
"idp names login.mesh-one.be at resources[0].env.KC_HOSTNAME",
|
||||
"idp names the public address 51.15.22.9 at resources[1].content",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
for _, mustNot := range []string{"quay.io", "why", "203.0.113.7", "192.168.1.0", "127.0.0.0"} {
|
||||
if strings.Contains(got, mustNot) {
|
||||
t.Errorf("%q was reported and should not be:\n%s", mustNot, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheWorldsNamesAreNotAnInstallations(t *testing.T) {
|
||||
m := Manifest{Module: "resolver", Resources: []map[string]any{
|
||||
{"id": "conf", "type": "file", "content": "server=1.1.1.1\nserver=8.8.8.8\nlisten=127.0.0.55\n"},
|
||||
{"id": "proxy", "type": "container", "image": "docker.io/library/traefik@sha256:bb"},
|
||||
{"id": "adapter", "type": "file", "content": "{\"machine\": \"host.docker.internal\"}\n"},
|
||||
{"id": "doc", "type": "file", "content": "root = https://git.example.tld/\n"},
|
||||
}}
|
||||
if got := InstallationProblems(m); len(got) != 0 {
|
||||
t.Fatalf("the world's names were reported: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestANameMeantOnPurposeIsDeclaredWithItsReason(t *testing.T) {
|
||||
// The federation's public directory in a homeserver's config: the world's, said so, and a name
|
||||
// the map does not cover is still reported.
|
||||
m := Manifest{Module: "homeserver", Resources: []map[string]any{
|
||||
{"id": "conf", "type": "file", "content": "trusted_key_servers: matrix.org\nwell_known: https://mesh-one.be\n",
|
||||
NamesOnPurpose: map[string]any{"matrix.org": "the federation's public key server, the world's"}},
|
||||
}}
|
||||
got := InstallationProblems(m)
|
||||
if len(got) != 1 || !strings.Contains(got[0], "mesh-one.be") {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnImageFromAnInstallationsRegistryNeedsAReason(t *testing.T) {
|
||||
bare := Manifest{Module: "site", Resources: []map[string]any{
|
||||
{"id": "server", "type": "container", "image": "registry.mesh-one.be/org/site@sha256:cc"},
|
||||
}}
|
||||
if got := InstallationProblems(bare); len(got) != 1 || !strings.Contains(got[0], "registry.mesh-one.be") {
|
||||
t.Fatalf("an image on an installation's registry was not named: %v", got)
|
||||
}
|
||||
excepted := Manifest{Module: "site", Resources: []map[string]any{
|
||||
{"id": "server", "type": "container", "image": "registry.mesh-one.be/org/site@sha256:cc",
|
||||
NamesOnPurpose: map[string]any{"registry.mesh-one.be": "built outside the mesh until the site's repository is a build source here"}},
|
||||
}}
|
||||
if got := InstallationProblems(excepted); len(got) != 0 {
|
||||
t.Fatalf("a declared exception was still reported: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAModuleNamedAfterADomainIsNamedBack(t *testing.T) {
|
||||
got := InstallationProblems(Manifest{Module: "mesh-one.be"})
|
||||
if len(got) != 1 || !strings.Contains(got[0], "named after mesh-one.be") {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestABuildContextOnASeatNamesNoForge(t *testing.T) {
|
||||
m := Manifest{Module: "packager", Build: &Build{Artifacts: []Artifact{
|
||||
{Name: "server", Kind: "image", From: "Dockerfile",
|
||||
Context: &ArtifactContext{Seat: "git", Repository: "org/controller", Ref: "main"}},
|
||||
}}}
|
||||
if got := InstallationProblems(m); len(got) != 0 {
|
||||
t.Fatalf("a context on a seat was reported: %v", got)
|
||||
}
|
||||
m.Build.Artifacts[0].Context = &ArtifactContext{Repository: "https://git.mesh-one.be/org/controller.git"}
|
||||
if got := InstallationProblems(m); len(got) != 1 {
|
||||
t.Fatalf("a context by URL was not reported: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A manifest may say which tools its module calls (novox/hq ADR 0152), and the parser accepts the
|
||||
// two shapes the grant has: a named tool, and every tool.
|
||||
func TestAManifestMaySayWhatItInvokes(t *testing.T) {
|
||||
m, err := decodeForTest([]byte(`{"module":"mesh-console","version":"1",` +
|
||||
m, err := ParseManifest([]byte(`{"module":"mesh-console","version":"1",` +
|
||||
`"invokes":["mesh-catalog.catalog_modules","*"]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -16,3 +17,15 @@ func TestAManifestMaySayWhatItInvokes(t *testing.T) {
|
||||
t.Fatalf("invokes not read: %v", m.Invokes)
|
||||
}
|
||||
}
|
||||
|
||||
// An entry that names a module and no tool is refused at parse, in the manifest's words, rather than
|
||||
// at the composition of the bus's user list where it would stop the file for everybody.
|
||||
func TestAnInvokeThatNamesNoToolIsRefusedAtParse(t *testing.T) {
|
||||
_, err := ParseManifest([]byte(`{"module":"mesh-console","version":"1","invokes":["shop"]}`))
|
||||
if err == nil {
|
||||
t.Fatal("an invoke naming no tool was accepted")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `invokes "shop", which does not name a tool`) {
|
||||
t.Fatalf("refused for the wrong reason: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,42 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A provision's credential may be issued by its provider's software (novox/hq ADR 0305, issue 526): the offer
|
||||
// names the tool that answers it and no own secret, and every other shape is refused at parse.
|
||||
func TestAnOfferMaySayItsSoftwareIssuesTheCredential(t *testing.T) {
|
||||
m, err := ParseManifest([]byte(`{"module":"media","version":"1",
|
||||
"provides":[{"name":"media-api","scope":"mesh","credential":{"issued-by":"software","tool":"media_issued_credential"}}],
|
||||
"serves":{"media-api":{"port":32400}}}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tool, issued := m.IssuedCredentialOf("media-api"); !issued || tool != "media_issued_credential" {
|
||||
t.Fatalf("the offer's issued credential was not read: %q %v", tool, issued)
|
||||
}
|
||||
if _, shared := m.SharedCredentialOf("media-api"); shared {
|
||||
t.Fatal("an issued credential was read as an own secret shared")
|
||||
}
|
||||
if _, issued := m.IssuedCredentialOf("other"); issued {
|
||||
t.Fatal("a provision the module does not offer reads as issued")
|
||||
}
|
||||
|
||||
for want, raw := range map[string]string{
|
||||
"the issuers an offer may name": `{"module":"d","version":"1",
|
||||
"provides":[{"name":"d-api","credential":{"issued-by":"vendor","tool":"d_token"}}]}`,
|
||||
"names no tool that answers it": `{"module":"d","version":"1",
|
||||
"provides":[{"name":"d-api","credential":{"issued-by":"software"}}]}`,
|
||||
"names its own secret": `{"module":"d","version":"1","own-secrets":{"token":{"path":"/p","taken":"at-start"}},
|
||||
"provides":[{"name":"d-api","credential":{"issued-by":"software","tool":"d_token","own":"token"}}]}`,
|
||||
"the issuers an offer may name ": `{"module":"d","version":"1",
|
||||
"provides":[{"name":"d-api","credential":{"tool":"d_token"}}]}`,
|
||||
} {
|
||||
want = strings.TrimSpace(want)
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("expected a refusal saying %q, got %v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,46 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A provision's credential may be one only a person outside the mesh can give (novox/hq ADR 0337 §1, issue 164):
|
||||
// the offer says `issued-by: outside` and names neither an own secret nor a tool, and the parser refuses it beside
|
||||
// either. An offer that says `software` keeps ADR 0305's shape.
|
||||
func TestAnOfferMaySayAPersonOutsideTheMeshIssuesItsCredential(t *testing.T) {
|
||||
m, err := ParseManifest([]byte(`{"module":"tv","version":"1",
|
||||
"provides":[{"name":"tv-api","scope":"mesh","credential":{"issued-by":"outside"}}],
|
||||
"serves":{"tv-api":{"port":8989}}}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !m.IssuedOutsideOf("tv-api") {
|
||||
t.Fatal("the offer's credential issued outside was not read")
|
||||
}
|
||||
if m.IssuedOutsideOf("other") {
|
||||
t.Fatal("a provision the module does not offer reads as issued outside")
|
||||
}
|
||||
if _, issued := m.IssuedCredentialOf("tv-api"); issued {
|
||||
t.Fatal("a credential issued outside was read as issued by the software")
|
||||
}
|
||||
if _, shared := m.SharedCredentialOf("tv-api"); shared {
|
||||
t.Fatal("a credential issued outside was read as an own secret shared")
|
||||
}
|
||||
if who, offered := OffersIssuedOutside(map[string]Manifest{"tv": m}, "tv-api"); !offered || who != "tv" {
|
||||
t.Fatalf("the catalogue does not say tv offers tv-api issued outside: %q %v", who, offered)
|
||||
}
|
||||
|
||||
for want, raw := range map[string]string{
|
||||
"names its own secret": `{"module":"d","version":"1","own-secrets":{"key":{"path":"/p","taken":"at-start"}},
|
||||
"provides":[{"name":"d-api","credential":{"issued-by":"outside","own":"key"}}]}`,
|
||||
"names the tool": `{"module":"d","version":"1",
|
||||
"provides":[{"name":"d-api","credential":{"issued-by":"outside","tool":"d_key"}}]}`,
|
||||
"the issuers an offer may name": `{"module":"d","version":"1",
|
||||
"provides":[{"name":"d-api","credential":{"issued-by":"a-person"}}]}`,
|
||||
} {
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("expected a refusal saying %q, got %v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user