Compare commits
124
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af4e4e60a3 | ||
|
|
ff5e00f1f8 | ||
|
|
c28c645ca4 | ||
|
|
5ae69df8cc | ||
|
|
cbbfbf3d2d | ||
|
|
fea48f19ce | ||
|
|
bb01a656d0 | ||
|
|
b611022457 | ||
|
|
fbbfae7629 | ||
|
|
acbf80ad91 | ||
|
|
de8e82c06f | ||
|
|
085f17e935 | ||
|
|
2d1d8def75 | ||
|
|
98d5306d6b | ||
|
|
a4e7629742 | ||
|
|
a320f8e2cd | ||
|
|
7a9c8570cb | ||
|
|
4c54aec972 | ||
|
|
d352fe51b4 | ||
|
|
712ec9481b | ||
|
|
50ea6f748e | ||
|
|
748543c35f | ||
|
|
dfd5d8295e | ||
|
|
dec3bfe7a8 | ||
|
|
8ed3b872e0 | ||
|
|
6bce1fb0d2 | ||
|
|
409c56685d | ||
|
|
b9ef1e4fcb | ||
|
|
17fc9b9b81 | ||
|
|
125bb91771 | ||
|
|
6c5a8ef893 | ||
|
|
91aa7b78fc | ||
|
|
b74e23f973 | ||
|
|
eff81f1220 | ||
|
|
6a71d29b33 | ||
|
|
4e9621edd6 | ||
|
|
0cdbdca139 | ||
|
|
14de8dadb4 | ||
|
|
9685990a15 | ||
|
|
d539d7e4eb | ||
|
|
c1bc21c35f | ||
|
|
16932c93d6 | ||
|
|
754edfae69 | ||
|
|
4c1384faef | ||
|
|
a37181261c | ||
|
|
206f794eb3 | ||
|
|
ef1ec1c935 | ||
|
|
942f906222 | ||
|
|
61b67d61e2 | ||
|
|
44dbb22ea7 | ||
|
|
1dc5d8cb3d | ||
|
|
7115b7bf64 | ||
|
|
e703e9b0e2 | ||
|
|
7b02f63daf | ||
|
|
59311407e2 | ||
|
|
1d9de5dbb6 | ||
|
|
af66ccb833 | ||
|
|
42988a734b | ||
|
|
6e78203959 | ||
|
|
d0625eea3d | ||
|
|
9379e9a96a | ||
|
|
0c99e847a2 | ||
|
|
83cf97b690 | ||
|
|
46d4b4da5e | ||
|
|
2b1e148bf5 | ||
|
|
2da0de75c3 | ||
|
|
044ead7db8 | ||
|
|
78c9ec8817 | ||
|
|
6e4a3014cb | ||
|
|
007b6fd45f | ||
|
|
8fe3467753 | ||
|
|
fa5a6aeff5 | ||
|
|
31e90aaa0f | ||
|
|
7bd0f6c2bd | ||
|
|
f3677eb6f3 | ||
|
|
992ea0ab66 | ||
|
|
85f5fd0b22 | ||
|
|
9f0bb1cfa4 | ||
|
|
08fc70783a | ||
|
|
bb993dd606 | ||
|
|
196c5b6af2 | ||
|
|
dc78524e38 | ||
|
|
f9a496d3e1 | ||
|
|
7fdbadc655 | ||
|
|
6eae0765f4 | ||
|
|
94d0de5c19 | ||
|
|
58a83249c8 | ||
|
|
6b8c694381 | ||
|
|
dedd988fc9 | ||
|
|
f701db0516 | ||
|
|
161fba56ae | ||
|
|
692dbf9a17 | ||
|
|
66ff33dbd3 | ||
|
|
6e39ed1852 | ||
|
|
56514aa4a2 | ||
|
|
4a967933b3 | ||
|
|
233b6be5dd | ||
|
|
4204afd240 | ||
|
|
2d2aa7ba9b | ||
|
|
2e5cc4f166 | ||
|
|
bc6c8a6d24 | ||
|
|
20cac82ab7 | ||
|
|
fbd00c8943 | ||
|
|
7df90445ad | ||
|
|
32f5ce3712 | ||
|
|
8e3d7a4c3c | ||
|
|
b6ef01d6c6 | ||
|
|
7000bd5049 | ||
|
|
69fcb6c1c1 | ||
|
|
2a04bf0ac5 | ||
|
|
0b12b62a01 | ||
|
|
5a47192b57 | ||
|
|
83d928fe50 | ||
|
|
544720b00a | ||
|
|
575a05e49e | ||
|
|
4fff214eeb | ||
|
|
187fb63439 | ||
|
|
22c9ddd32b | ||
|
|
94cee5d199 | ||
|
|
52056010fe | ||
|
|
307b6234e6 | ||
|
|
dbcaa29a1a | ||
|
|
394fec8a63 | ||
|
|
c2999a4e13 |
+30
-5
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"path"
|
||||
"slices"
|
||||
"sort"
|
||||
@@ -42,14 +43,30 @@ type AskSteps struct {
|
||||
// RunAsk runs ask's job with steps and answers a delivery.BuildAnswer or a delivery.CheckAnswer.
|
||||
func RunAsk(ctx context.Context, steps AskSteps, ask delivery.StepAsk) (any, error) {
|
||||
if ask.Build != nil {
|
||||
return steps.build(ctx, *ask.Build)
|
||||
job := *ask.Build
|
||||
job.Fetch = withPackages(job.Fetch, ask.Packages)
|
||||
return steps.build(ctx, job)
|
||||
}
|
||||
if ask.Check != nil {
|
||||
return steps.check(ctx, *ask.Check)
|
||||
job := *ask.Check
|
||||
job.Fetch = withPackages(job.Fetch, ask.Packages)
|
||||
return steps.check(ctx, job)
|
||||
}
|
||||
return nil, Refuse("the ask %s holds no job", ask.ID)
|
||||
}
|
||||
|
||||
// withPackages is the fetch's arguments with the ask's package versions (novox/hq ADR 0573 §2), which the build module
|
||||
// writes into the workspace it fetches; a copy, so the ask is left as it was. An ask with none, or a copy alone that
|
||||
// fetches nothing, is left as it is.
|
||||
func withPackages(fetch map[string]any, packages delivery.PackageVersions) map[string]any {
|
||||
if len(packages) == 0 || fetch == nil {
|
||||
return fetch
|
||||
}
|
||||
out := maps.Clone(fetch)
|
||||
out["packages"] = maps.Clone(packages)
|
||||
return out
|
||||
}
|
||||
|
||||
func raw(v any) (json.RawMessage, error) {
|
||||
if r, ok := v.(json.RawMessage); ok {
|
||||
return r, nil
|
||||
@@ -228,8 +245,10 @@ func (s AskSteps) level(ctx context.Context, job delivery.CheckJob, level string
|
||||
_ = json.Unmarshal(r, &t)
|
||||
return t.Passed, nil
|
||||
}
|
||||
// Every test step of the level runs, also after one that did not pass, so one check names every failing test of the
|
||||
// level and not only the first (issue 1921); the level as a whole passes only when each of its steps did.
|
||||
// The packages (or Node directories) whose tests the change touches run first, 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.
|
||||
all := true
|
||||
if s.Digest == nil {
|
||||
// A module with no digest (Node) tests each module directory of the tree, those whose tests changed first.
|
||||
@@ -244,6 +263,9 @@ 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
|
||||
}
|
||||
@@ -279,7 +301,10 @@ func (s AskSteps) level(ctx context.Context, job delivery.CheckJob, level string
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("the %s level: %w", level, err)
|
||||
}
|
||||
all = all && passed // the packages left still run (issue 1921)
|
||||
all = all && passed // 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)
|
||||
}
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
+64
-36
@@ -74,15 +74,21 @@ func TestRunAskBuilds(t *testing.T) {
|
||||
steps := r.steps()
|
||||
var released []string
|
||||
steps.Release = func(ws string) error { released = append(released, ws); return nil }
|
||||
out, err := RunAsk(context.Background(), steps, ask(delivery.BuildJob{Module: "m", Path: "modules/m", Publish: true,
|
||||
// Issue 1585: the ask's package versions reach the fetch, and the ask's own fetch arguments stay as they were.
|
||||
a := ask(delivery.BuildJob{Module: "m", Path: "modules/m", Publish: true,
|
||||
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit},
|
||||
Artifacts: []delivery.AskArtifact{{Artifact: "base", From: "registry/b@sha256:00"}, {Artifact: "bundle"}, {Artifact: "lib"}}}))
|
||||
Artifacts: []delivery.AskArtifact{{Artifact: "base", From: "registry/b@sha256:00"}, {Artifact: "bundle"}, {Artifact: "lib"}}})
|
||||
a.Packages = delivery.PackageVersions{"git.novox.be/novox/mesh-sdk/go": runCommit}
|
||||
out, err := RunAsk(context.Background(), steps, a)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, put := a.Build.Fetch["packages"]; put {
|
||||
t.Error("the ask's fetch arguments were changed")
|
||||
}
|
||||
got := strings.Join(r.calls, "\n")
|
||||
want := `copy {"artifact":"base","from":"registry/b@sha256:00","module":"m"}
|
||||
fetch {"commit":"` + runCommit + `","repository":"novox/x"}
|
||||
fetch {"commit":"` + runCommit + `","packages":{"git.novox.be/novox/mesh-sdk/go":"` + runCommit + `"},"repository":"novox/x"}
|
||||
build {"artifact":"bundle","module":"modules/m","publish":true,"workspace":"/w/1"}
|
||||
build {"artifact":"lib","module":"modules/m","publish":true,"workspace":"/w/1"}`
|
||||
if got != want {
|
||||
@@ -91,9 +97,9 @@ build {"artifact":"lib","module":"modules/m","publish":true,"workspace":"/w/1"}`
|
||||
if len(released) != 1 || released[0] != "/w/1" {
|
||||
t.Errorf("released %v, want the one workspace", released)
|
||||
}
|
||||
a := out.(delivery.BuildAnswer)
|
||||
if len(a.Artifacts) != 3 || a.Artifacts[1].Reference != "artifact-store://bundle" {
|
||||
t.Fatalf("answer %+v", a)
|
||||
answer := out.(delivery.BuildAnswer)
|
||||
if len(answer.Artifacts) != 3 || answer.Artifacts[1].Reference != "artifact-store://bundle" {
|
||||
t.Fatalf("answer %+v", answer)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,7 +119,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, then the rest also after a failure (issue 1921), and runs no
|
||||
// verdicts, tests the packages whose tests changed first and, when one of them fails, no other (issue 1289), and runs no
|
||||
// level after one that did not pass (ADR 0484 clause 13).
|
||||
func TestRunAskChecksTheLevelsOfOneCheck(t *testing.T) {
|
||||
r := &recorder{
|
||||
@@ -129,8 +135,7 @@ 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/a"],"workspace":"/w"}`
|
||||
test {"level":"unit","packages":["x/pkg/b"],"workspace":"/w"}`
|
||||
if got != want {
|
||||
t.Fatalf("calls:\n%s\nwant:\n%s", got, want)
|
||||
}
|
||||
@@ -139,7 +144,7 @@ test {"level":"unit","packages":["x/pkg/a"],"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) != 2 || 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) != 1 || u.Digest == nil {
|
||||
t.Fatalf("the unit level %+v", u)
|
||||
}
|
||||
|
||||
@@ -254,41 +259,64 @@ func TestRunAskChecksNodeModulesAndConflicts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1921: a failure in the packages whose tests changed does not stop the level: the other packages still run, so two
|
||||
// failing packages, one changed and one not, are two test results of the one level, and the level does not pass. A Node
|
||||
// check runs every module directory after one fails too.
|
||||
func TestRunAskRunsEveryPackageAfterAFailure(t *testing.T) {
|
||||
r := &recorder{
|
||||
fetch: map[string]any{"workspace": "/w", "changed": []string{"pkg/b/b_test.go"}},
|
||||
digests: map[string]string{"x/pkg/a": "d-a", "x/pkg/b": "d-b"},
|
||||
tests: map[string]bool{"x/pkg/a": false, "x/pkg/b": false},
|
||||
}
|
||||
out, err := RunAsk(context.Background(), r.steps(), ask(delivery.CheckJob{Levels: []string{delivery.LevelIntegration, delivery.LevelUnit},
|
||||
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := out.(delivery.CheckAnswer)
|
||||
if len(a.Levels) != 1 || len(a.Levels[0].Tests) != 2 {
|
||||
t.Fatalf("levels %+v, want the one level with both packages' results", a.Levels)
|
||||
}
|
||||
for i, raw := range a.Levels[0].Tests {
|
||||
var res struct{ Passed bool }
|
||||
if err := json.Unmarshal(raw, &res); err != nil || res.Passed {
|
||||
t.Errorf("result %d %s, want a failure", i, raw)
|
||||
// Issue 1289: 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)
|
||||
}
|
||||
}
|
||||
|
||||
r = &recorder{fetch: map[string]any{"workspace": "/w", "modules": []string{"a", "b"}, "changed": []string{"b/test/x.test.ts"}},
|
||||
r := &recorder{fetch: map[string]any{"workspace": "/w", "modules": []string{"a", "b"}, "changed": []string{"b/test/x.test.ts"}},
|
||||
tests: map[string]bool{"a": true, "b": false}}
|
||||
s := r.steps()
|
||||
s.Digest = nil
|
||||
out, err = RunAsk(context.Background(), s, ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit, delivery.LevelIntegration},
|
||||
out, err := RunAsk(context.Background(), s, ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit, delivery.LevelIntegration},
|
||||
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a := out.(delivery.CheckAnswer); len(r.calls) != 3 || len(a.Levels) != 1 || len(a.Levels[0].Tests) != 2 {
|
||||
t.Fatalf("calls %v, levels %+v: want both directories of the unit level and no integration level", r.calls, a.Levels)
|
||||
if a := out.(delivery.CheckAnswer); len(r.calls) != 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,12 @@ package buildstep
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -27,6 +30,10 @@ type FetchRequest struct {
|
||||
Base string `json:"base,omitempty"`
|
||||
Dir string `json:"dir,omitempty"`
|
||||
Paths []string `json:"paths,omitempty"`
|
||||
// Packages are the version of each mesh package the build or the check builds against (delivery.StepAsk.Packages,
|
||||
// novox/hq ADR 0573 §2, §4): the build module writes each into the workspace's go.mod and go.sum, whatever version
|
||||
// the repository names, checksum-verified, and names them in its log.
|
||||
Packages delivery.PackageVersions `json:"packages,omitempty"`
|
||||
}
|
||||
|
||||
// FetchRequestProperties are the JSON schema properties of FetchRequest's dir and paths, for a fetch tool's Input.
|
||||
@@ -35,6 +42,8 @@ func FetchRequestProperties() map[string]any {
|
||||
"dir": map[string]any{"type": "string", "description": "the module's directory in the repository (\".\" for its root), with paths"},
|
||||
"paths": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
|
||||
"description": "the paths the module names, relative to dir, from the indexer's kept inputs: the build sees only these, the manifest and its lock files"},
|
||||
"packages": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "string"},
|
||||
"description": "optional: the version (a full commit) of each mesh package to build against, by its module path, whatever version go.mod names (novox/hq ADR 0573 §2); the builder and the checker fill it"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +58,12 @@ func (r FetchRequest) Args() map[string]any {
|
||||
if len(r.Paths) > 0 {
|
||||
args["paths"] = append([]string(nil), r.Paths...)
|
||||
}
|
||||
if len(r.Packages) > 0 {
|
||||
args["packages"] = map[string]any{}
|
||||
for p, v := range r.Packages {
|
||||
args["packages"].(map[string]any)[p] = v
|
||||
}
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
@@ -78,9 +93,42 @@ func ParseFetchRequest(args map[string]any) (FetchRequest, error) {
|
||||
default:
|
||||
return FetchRequest{}, fmt.Errorf("paths is a list of strings")
|
||||
}
|
||||
packages, err := PackagesArg(args["packages"])
|
||||
if err != nil {
|
||||
return FetchRequest{}, err
|
||||
}
|
||||
r.Packages = packages
|
||||
return r, r.Check()
|
||||
}
|
||||
|
||||
// PackagesArg reads a tool call's packages argument, an object of strings (as JSON gives it) or the map an ask gives;
|
||||
// nil for none.
|
||||
func PackagesArg(v any) (delivery.PackageVersions, error) {
|
||||
out := delivery.PackageVersions{}
|
||||
switch v := v.(type) {
|
||||
case nil:
|
||||
return nil, nil
|
||||
case delivery.PackageVersions:
|
||||
maps.Copy(out, v)
|
||||
case map[string]string:
|
||||
maps.Copy(out, v)
|
||||
case map[string]any:
|
||||
for p, version := range v {
|
||||
s, ok := version.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("packages is an object of versions (strings), by package")
|
||||
}
|
||||
out[p] = s
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("packages is an object of versions (strings), by package")
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return out, out.Check()
|
||||
}
|
||||
|
||||
// Check is what ParseFetchRequest refuses.
|
||||
func (r FetchRequest) Check() error {
|
||||
if !requestRepositoryOK.MatchString(r.Repository) || strings.Contains(r.Repository, "..") {
|
||||
|
||||
@@ -10,11 +10,15 @@ import (
|
||||
// from the indexer's kept inputs and the build module reads them from the call, never from the manifest (ADR 0495
|
||||
// clause 1). The request survives the trip through a tool call's arguments.
|
||||
func TestAFetchRequestCarriesTheNamedPathsThroughTheToolCall(t *testing.T) {
|
||||
// And the versions of the mesh packages to build against (novox/hq ADR 0573 §2; issue 1585 S4).
|
||||
req := FetchRequest{Repository: "novox/mesh-sdk", Commit: strings.Repeat("a", 40), Ref: "refs/pull/7/head",
|
||||
Dir: ".", Paths: []string{"package.json", "src"}}
|
||||
Dir: ".", Paths: []string{"package.json", "src"}, Packages: map[string]string{"git.novox.be/novox/mesh-sdk/go": strings.Repeat("d", 40)}}
|
||||
args := req.Args()
|
||||
// A tool call's arguments arrive as JSON: lists of any.
|
||||
// A tool call's arguments arrive as JSON: lists of any, objects of any.
|
||||
args["paths"] = []any{"package.json", "src"}
|
||||
if p, ok := args["packages"].(map[string]any); !ok || p["git.novox.be/novox/mesh-sdk/go"] != strings.Repeat("d", 40) {
|
||||
t.Fatalf("the packages are not in the call's arguments: %v", args["packages"])
|
||||
}
|
||||
got, err := ParseFetchRequest(args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -42,6 +46,9 @@ func TestAFetchRequestCarriesTheNamedPathsThroughTheToolCall(t *testing.T) {
|
||||
{map[string]any{"dir": "../a", "paths": []any{"src"}}, "leaves the repository"},
|
||||
{map[string]any{"dir": "modules/a", "paths": []any{3}}, "paths is a list of strings"},
|
||||
{map[string]any{"dir": "modules/a"}, "names a module directory and no path"},
|
||||
{map[string]any{"packages": map[string]any{"git.novox.be/novox/mesh-sdk/go": "v0.1.7"}}, "no full commit"},
|
||||
{map[string]any{"packages": map[string]any{"a b": strings.Repeat("d", 40)}}, "no module path"},
|
||||
{map[string]any{"packages": []any{"x"}}, "packages is an object"},
|
||||
} {
|
||||
args := map[string]any{}
|
||||
for k, v := range ok {
|
||||
|
||||
@@ -25,6 +25,8 @@ type ModuleTree struct {
|
||||
// Paths are, of a module whose every artifact names its paths, each named path's git object id at the commit; Tree
|
||||
// is then their delivery.NamedTree with Manifest (novox/hq ADR 0499 clause 2). Absent otherwise.
|
||||
Paths map[string]string `json:"paths,omitempty"`
|
||||
// Packages are the mesh packages the directory's go.sum names (delivery.MeshPackagesOf, novox/hq ADR 0573 §2).
|
||||
Packages []string `json:"packages,omitempty"`
|
||||
}
|
||||
|
||||
var lockFiles = []string{"go.sum", "package-lock.json"}
|
||||
@@ -67,6 +69,9 @@ func ModuleTrees(ctx context.Context, run forgekey.Runner, env []string, dir, co
|
||||
mt.Locks = map[string]string{}
|
||||
}
|
||||
mt.Locks[lock] = sha256Hex(raw)
|
||||
if lock == "go.sum" {
|
||||
mt.Packages = delivery.MeshPackagesOf(raw) // the mesh packages it uses (novox/hq ADR 0573 §2)
|
||||
}
|
||||
}
|
||||
}
|
||||
trees[mdir] = mt
|
||||
|
||||
@@ -33,7 +33,8 @@ func TestModuleTreesAnswersTreeManifestAndLocksPerModuleDirectory(t *testing.T)
|
||||
writeTreeFile(t, root, "module.json", `{"root":1}`)
|
||||
writeTreeFile(t, root, "go.sum", "rootsum")
|
||||
writeTreeFile(t, root, "modules/a/module.json", `{"a":1}`)
|
||||
writeTreeFile(t, root, "modules/a/go.sum", "asum")
|
||||
asum := "git.novox.be/novox/mesh-sdk/go v0.1.7 h1:x=\ngit.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:y=\ngithub.com/nats-io/nats.go v1.0.0 h1:z=\n"
|
||||
writeTreeFile(t, root, "modules/a/go.sum", asum)
|
||||
writeTreeFile(t, root, "modules/a/package-lock.json", "alock")
|
||||
writeTreeFile(t, root, "modules/b/module.json", `{"b":1}`)
|
||||
merge := strings.Repeat("c", 40)
|
||||
@@ -55,8 +56,9 @@ func TestModuleTreesAnswersTreeManifestAndLocksPerModuleDirectory(t *testing.T)
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]ModuleTree{
|
||||
".": {Tree: "tree-of-" + merge + "^{tree}", Manifest: hexOf(`{"root":1}`), Locks: map[string]string{"go.sum": hexOf("rootsum")}},
|
||||
"modules/a": {Tree: "tree-of-" + merge + ":modules/a", Manifest: hexOf(`{"a":1}`), Locks: map[string]string{"go.sum": hexOf("asum"), "package-lock.json": hexOf("alock")}},
|
||||
".": {Tree: "tree-of-" + merge + "^{tree}", Manifest: hexOf(`{"root":1}`), Locks: map[string]string{"go.sum": hexOf("rootsum")}},
|
||||
"modules/a": {Tree: "tree-of-" + merge + ":modules/a", Manifest: hexOf(`{"a":1}`), Locks: map[string]string{"go.sum": hexOf(asum), "package-lock.json": hexOf("alock")},
|
||||
Packages: []string{"git.novox.be/novox/mesh-sdk/go"}}, // issue 1585: the mesh packages its go.sum names
|
||||
"modules/b": {Tree: "tree-of-" + merge + ":modules/b", Manifest: hexOf(`{"b":1}`)},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
|
||||
+17
-12
@@ -187,12 +187,17 @@ func HeldCredential(w WantedAccount, password string) ([]byte, error) {
|
||||
Claims: w.Claims})
|
||||
}
|
||||
|
||||
// **One issuer at every moment: minting turns on at the lease write** (novox/hq ADR 0561 §3, §6). The controller's lease
|
||||
// is the one key `holder` of its bucket (LeaseBucket). The operator's credential hands it away by writing a value that
|
||||
// names its new holder in `handed-to`; while the key holds that, the vault mints and the grants part creates the durable
|
||||
// consumers an account needs. A controller's value, a key deleted or expired, or one that cannot be read or parsed, turn
|
||||
// both off: when in doubt nothing is minted, so the controller and the vault never both mint one account. No clock and
|
||||
// no setting turns it.
|
||||
// **The lease write** (novox/hq ADR 0561 §3, §6). The controller's lease is the one key `holder` of its bucket
|
||||
// (LeaseBucket). The operator's credential hands it away by writing a value that names its new holder in `handed-to`;
|
||||
// while the key holds that, the grants part creates the durable consumers an account needs, the assigner reads the grants
|
||||
// part's grants (delivery.GrantSource), and the vault retires and rotates accounts. A controller's value, a key deleted or
|
||||
// expired, or one that cannot be read or parsed, turn those off. No clock and no setting turns it.
|
||||
//
|
||||
// **Minting is no longer on the lease** (issue 774, research 072's gap "vault mints after the lease"): the vault is the
|
||||
// one issuer of every account the grants part wants, whoever holds the lease, and the controller mints none once a vault
|
||||
// is installed (it forwards `module issue` to the vault's issue-account). One issuer at every moment holds by who mints,
|
||||
// not by the lease: an account whose value the vault holds is never minted anew, and a value the controller hands or
|
||||
// moves never replaces one the vault minted.
|
||||
|
||||
// ControllerLease is the lease's bucket as a reader names it; the vault and the grants part alone read it
|
||||
// (NamedReaders), the controller's announcement marking it readable by nobody.
|
||||
@@ -221,24 +226,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 nothing is minted"
|
||||
return false, "the controller's lease cannot be read (" + readErr.Error() + "), so it is not handed away"
|
||||
case !found || len(raw) == 0 || string(raw) == "null":
|
||||
return false, "no lease is written (expired, deleted, or never taken): a controller may take it, so nothing is minted"
|
||||
return false, "no lease is written (expired, deleted, or never taken): a controller may take it, so it is not handed away"
|
||||
}
|
||||
var h LeaseHolder
|
||||
if err := json.Unmarshal(raw, &h); err != nil {
|
||||
return false, "the controller's lease is not readable as a holder (" + err.Error() + "), so nothing is minted"
|
||||
return false, "the controller's lease is not readable as a holder (" + err.Error() + "), so it is not handed away"
|
||||
}
|
||||
if strings.TrimSpace(h.HandedTo) == "" {
|
||||
who := h.Instance
|
||||
if who == "" {
|
||||
who = "an instance that names itself not"
|
||||
}
|
||||
return false, "the controller holds the lease (" + who + "): it mints, so the vault mints nothing (ADR 0561 §3)"
|
||||
return false, "the controller holds the lease (" + who + "), so it is not handed away (ADR 0561 §3)"
|
||||
}
|
||||
if h.Instance != "" {
|
||||
return false, "the lease names both a controller instance (" + h.Instance + ") and a holder it is handed to (" +
|
||||
h.HandedTo + "), so nothing is minted"
|
||||
h.HandedTo + "), so it is not handed away"
|
||||
}
|
||||
return true, "the lease is handed to " + h.HandedTo + ": the vault mints every new account (ADR 0561 §1, §3)"
|
||||
return true, "the lease is handed to " + h.HandedTo + " (ADR 0561 §3)"
|
||||
}
|
||||
|
||||
@@ -42,16 +42,12 @@ func ControllerAnnouncement(node string) announce.States {
|
||||
return s
|
||||
}
|
||||
|
||||
// ReadsAnnounced splits a reader's `reads` (`<owner>.<name>`) by what each owner announced: granted when the owner
|
||||
// announced the state readable, or announced nothing yet (the transition); refused when the owner announced and did
|
||||
// not announce that state readable.
|
||||
func ReadsAnnounced(reads []string, announced map[string]announce.States) (granted, refused []string) {
|
||||
return ReadsAnnouncedBy("", reads, announced)
|
||||
}
|
||||
|
||||
// ReadsAnnouncedBy is ReadsAnnounced for one reader module, which also honours the readers an owner named for a state
|
||||
// (announce.State.Readers) and the composer's named readers (NamedReaders, ADR 0547 clause 9): a read named for other
|
||||
// modules is refused. An empty reader is judged by the readable mark alone.
|
||||
// ReadsAnnouncedBy splits one reader module's `reads` (`<owner>.<name>`) by what each owner announced: granted when the
|
||||
// owner announced the state readable (by this reader, where it named its readers: announce.State.Readers), when the
|
||||
// composer names the reader among the state's readers (NamedReaders, ADR 0547 clause 9), or when the owner announced
|
||||
// nothing yet (the transition); refused otherwise, and a read named for other modules is refused. There is no judgement
|
||||
// without the reader (issue 774): one made without it refused every named reader, and Users judges again for the
|
||||
// reader whatever split it is given (withAnnouncedReads). An empty reader is judged by the readable mark alone.
|
||||
func ReadsAnnouncedBy(reader string, reads []string, announced map[string]announce.States) (granted, refused []string) {
|
||||
for _, r := range reads {
|
||||
owner, name, ok := strings.Cut(r, ".")
|
||||
|
||||
@@ -13,7 +13,10 @@ 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"
|
||||
)
|
||||
|
||||
@@ -33,8 +36,17 @@ 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
|
||||
@@ -48,6 +60,22 @@ 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
|
||||
}
|
||||
@@ -65,7 +93,15 @@ 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+".")
|
||||
strings.Contains(g, "KV_"+EngineActsBucket) || strings.HasPrefix(g, "$KV."+EngineActsBucket+"."+node+".") ||
|
||||
// And its node's declared packages (novox/hq ADR 0565 clause 6).
|
||||
strings.Contains(g, "KV_"+EnginePackagesBucket) || g == "$KV."+EnginePackagesBucket+"."+node ||
|
||||
// And its own health report (issue 774).
|
||||
strings.Contains(g, "KV_"+delivery.NodeHealthBucket) || g == "$KV."+delivery.NodeHealthBucket+"."+node ||
|
||||
// And the provisions of its node's modules, read (novox/hq ADR 0570 clause 6).
|
||||
strings.Contains(g, "KV_"+provisions.ProvisionsBucket) ||
|
||||
// And its node's placed modules' data items, its own node's keys (novox/hq ADR 0577 clause 1).
|
||||
strings.Contains(g, "KV_"+EngineDataBucket) || strings.HasPrefix(g, "$KV."+EngineDataBucket+"."+node+".")
|
||||
}
|
||||
|
||||
// createsAnAccountsConsumer is the grants part's create and read of a durable consumer an account needs, by its stream
|
||||
@@ -121,11 +157,16 @@ func grantsAsTheControllers(t *testing.T, got, want string) {
|
||||
}
|
||||
intended := beyondTheControllers(user)
|
||||
for g := range was {
|
||||
if !now[g] {
|
||||
// A runtime's direct get of its membership from the assignments stream went with the stream (research 072
|
||||
// row 33): it reads the grants part's state memberships instead (ADR 0571 clause 7).
|
||||
if !now[g] && !strings.HasPrefix(g, "$JS.API.DIRECT.GET."+AssignmentsStream+".mesh.assignment."+strings.SplitN(user, ".", 2)[0]+".") {
|
||||
t.Errorf("%s: the copy does not grant %s, which the controller's does", user, g)
|
||||
}
|
||||
}
|
||||
for g := range now {
|
||||
if strings.HasPrefix(g, "$JS.API.DIRECT.GET."+AssignmentsStream+".") && !strings.HasSuffix(user, "."+GrantsModule) {
|
||||
t.Errorf("%s: the copy grants %s, a read of the assignments stream, which goes at the lease write", user, g)
|
||||
}
|
||||
if !was[g] && !intended[g] && !mayBeyondTheControllers(user)[g] && !createdAdvisory.MatchString(`"`+g+`", `) &&
|
||||
!createsAnAccountsConsumer(user, g) && !engineBeyond(user, g) {
|
||||
t.Errorf("%s: the copy grants %s beyond the controller's, and no decision names it", user, g)
|
||||
@@ -212,6 +253,7 @@ 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)
|
||||
}
|
||||
@@ -290,6 +332,21 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
|
||||
!MaySubscribe(perms, "$SRV.INFO."+EngineSeat) || MaySubscribe(perms, "mesh.seat."+EngineSeat+".tool.hand-over.two") {
|
||||
t.Errorf("node one hears %v", perms.Subscribe)
|
||||
}
|
||||
// A verb or an event the seat's contract gains later is its engine's on its own node without a release of
|
||||
// the grants part (2026-10-31: mesh-host #184's optional verb composition was refused on every node, the
|
||||
// grants part's mesh-sdk pin predating it): the seat's subjects on its node, any verb, any event.
|
||||
for _, verb := range []string{"composition", "compose", "a-verb-added-later"} {
|
||||
if !MaySubscribe(perms, "mesh.seat."+EngineSeat+".tool."+verb+".one") ||
|
||||
!MaySubscribe(perms, "mesh.seat."+EngineSeat+".call."+verb+".one.messenger~one") ||
|
||||
MaySubscribe(perms, "mesh.seat."+EngineSeat+".tool."+verb+".two") ||
|
||||
MaySubscribe(perms, "mesh.seat."+EngineSeat+".call."+verb+".two.messenger~one") {
|
||||
t.Errorf("node one does not answer %s on its own node alone: %v", verb, perms.Subscribe)
|
||||
}
|
||||
}
|
||||
if !MayPublish(perms, "mesh.seat."+EngineSeat+".event.an-event-added-later.one") ||
|
||||
MayPublish(perms, "mesh.seat."+EngineSeat+".event.an-event-added-later.two") {
|
||||
t.Errorf("node one does not say a later event on its own node alone: %v", perms.Publish)
|
||||
}
|
||||
case u.Kind == KindNodeTools || u.Module == "messenger":
|
||||
if !MayPublish(perms, "mesh.seat."+EngineSeat+".tool.root-free.one") {
|
||||
t.Errorf("%s cannot call one's root-free", u.Username())
|
||||
@@ -522,6 +579,34 @@ 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 ||
|
||||
@@ -548,6 +633,8 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
|
||||
t.Errorf("the grants part creates consumers as %v", u.CreatesConsumers)
|
||||
}
|
||||
}
|
||||
// 1952 S5 (novox/hq ADR 0564 clauses 2, 5, 8; Limits 2, 4, 8): the health holders' credentials.
|
||||
healthChecks(t)
|
||||
q := ConsumerConfig(QueueConsumer(Principal{Kind: KindProcess, Node: "a", Module: "m", Process: "p"},
|
||||
QueueOwner{Process: "p", Stream: "M_WORK", MaxDeliver: 3}))
|
||||
if q.Durable != "a_m_p" || q.FilterSubject != "" || q.MaxDeliver != 3 || q.DeliverPolicy != jetstream.DeliverAllPolicy {
|
||||
@@ -555,3 +642,328 @@ 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
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ func TestTheGrantsPartCreatesOnlyDeclaredStreams(t *testing.T) {
|
||||
Streams: []ModuleStream{{Module: "mesh-delivery-builder", Name: "BUILDASKS", Events: []string{"ask"}}}},
|
||||
{Module: GrantsModule, State: []Bucket{{Module: GrantsModule, Name: "assertions"}}, Reads: []string{VaultBusAccounts}},
|
||||
{Module: RuntimeModule},
|
||||
// The node-engine's manifest as a placement names it (issue 774, met twice on 2026-10-30): no account of its
|
||||
// own, so the runner would carry it, and its state is the node's to write alone.
|
||||
{Module: EngineModule, NoAccount: true, Consumes: []string{"restic.backed-up"},
|
||||
State: []Bucket{{Module: EngineModule, Name: "health"}, {Module: EngineModule, Name: EngineActsState}}},
|
||||
}},
|
||||
// The node-engine's own manifest, placed on every node though no node is assigned it (novox/hq ADR 0563 §2, §3).
|
||||
Engine: &Declared{Module: EngineModule, State: []Bucket{{Module: EngineModule, Name: EngineActsState}}},
|
||||
@@ -32,6 +36,9 @@ func TestTheGrantsPartCreatesOnlyDeclaredStreams(t *testing.T) {
|
||||
}
|
||||
perms := map[string]Permissions{}
|
||||
for _, u := range users {
|
||||
if u.Module == EngineModule {
|
||||
t.Errorf("the node-engine's manifest composed a user of its own: %s", u.Username())
|
||||
}
|
||||
if u.Kind == KindNode || u.Kind == KindNodeTools {
|
||||
p, err := PermissionsFor(u)
|
||||
if err != nil {
|
||||
@@ -64,6 +71,8 @@ func TestTheGrantsPartCreatesOnlyDeclaredStreams(t *testing.T) {
|
||||
{GrantsModule, "$JS.API.STREAM.CREATE.KV_mesh-delivery-builder_undeclared", false},
|
||||
{GrantsModule, "$JS.API.STREAM.CREATE.EVENTS", false},
|
||||
{GrantsModule, "$JS.API.STREAM.CREATE.ASSIGNMENTS", false},
|
||||
// Research 072 row 33: a runtime reads its membership from the grants part's state, never the assignments stream.
|
||||
{"mesh-delivery-builder", "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.novox.mesh-delivery-builder", false},
|
||||
{GrantsModule, "$JS.API.STREAM.INFO.EVENTS", false},
|
||||
{"mesh-delivery-builder", "$JS.API.STREAM.CREATE.KV_mesh-delivery-builder_steps", false},
|
||||
// The node-engine's acts: created by the grants part, written by each node's engine under its own keys alone.
|
||||
@@ -75,6 +84,11 @@ func TestTheGrantsPartCreatesOnlyDeclaredStreams(t *testing.T) {
|
||||
{"node.ace", "$JS.API.DIRECT.GET.KV_mesh-host_acts.$KV.mesh-host_acts.ace.ask.hand-over-1", true},
|
||||
{"node.ace", "$JS.API.DIRECT.GET.KV_mesh-host_acts.$KV.mesh-host_acts.novox.ask.hand-over-1", false},
|
||||
{"novox.node-tools", "$KV.mesh-host_acts.novox.act.hand-over-1", false},
|
||||
// The runner never carries the engine (issue 774): its health report is the node's own key alone.
|
||||
{"novox.node-tools", "$KV.mesh-host_health.novox", false},
|
||||
{"novox.node-tools", "$KV.mesh-host_acts.novox.ask.x", false},
|
||||
{"node.novox", "$KV.mesh-host_health.novox", true},
|
||||
{"node.novox", "$KV.mesh-host_health.ace", false},
|
||||
// The sealed user list (ADR 0547 clause 7): the grants part writes its own bucket and no other.
|
||||
{GrantsModule, "$JS.API.STREAM.CREATE.OBJ_mesh-grants-user-list", true},
|
||||
{GrantsModule, "$O.mesh-grants-user-list.C.abc", true},
|
||||
|
||||
+43
-6
@@ -3,6 +3,8 @@ package buscompose
|
||||
import (
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/provisions"
|
||||
)
|
||||
|
||||
// The node seat node-engine (novox/hq ADR 0563 §1): every node's engine holds it on its own bus account, and no module
|
||||
@@ -22,18 +24,34 @@ const (
|
||||
// EngineActsBucket is that state's bucket.
|
||||
var EngineActsBucket = BucketName(EngineModule, EngineActsState)
|
||||
|
||||
// EnginePackagesBucket is the engine's state of its node's declared packages (novox/hq ADR 0565 clause 6): one key, the
|
||||
// node's name, written by that node's engine alone.
|
||||
var EnginePackagesBucket = BucketName(EngineModule, delivery.EnginePackagesState)
|
||||
|
||||
// EngineDataBucket is the engine's state of its node's placed modules' data items (novox/hq ADR 0577 clause 1): one
|
||||
// key `<node>.<module>` per module, written by that node's engine alone.
|
||||
var EngineDataBucket = delivery.EngineDataBucket
|
||||
|
||||
// EnginePackagesGrants is what a node's engine publishes to keep its node's key of the packages bucket: the key alone,
|
||||
// and its read.
|
||||
func EnginePackagesGrants(node string) []string {
|
||||
return append([]string{"$KV." + EnginePackagesBucket + "." + node}, readGrants(EnginePackagesBucket, node)...)
|
||||
}
|
||||
|
||||
// engineGrants is what a node's engine is granted to hold its node seat and to ask the operator (ADR 0563 §1, §3): it
|
||||
// answers its own node's verbs (the caller-named subjects are added beside them with every principal's), says the
|
||||
// seat's events about its node, answers discovery for the seat, asks who answers what (the login shell's execute, ADR
|
||||
// 0563 §6), and asks the operator under asks.EngineAsker(node) on the router's seat as its holder declares it, as any
|
||||
// user of that seat is granted.
|
||||
//
|
||||
// Its verbs and events are granted as any verb and any event of the seat on its own node, not as the contract's list:
|
||||
// the list is compiled into whichever mesh-sdk the grants part pins, so a verb the engine gained before the grants part
|
||||
// was released was refused on every node and the engine reopened its link every few minutes (2026-10-31, the optional
|
||||
// verb composition of mesh-host #184). As a module answers `mesh.mod.<module>.tool.>`, nothing is given away: no other
|
||||
// principal may subscribe this seat's subjects of this node, and a caller is still granted each verb by name.
|
||||
func engineGrants(p Principal) (pub, sub []string) {
|
||||
for _, v := range catalogue.EngineVerbs() {
|
||||
sub = append(sub, "mesh.seat."+EngineSeat+".tool."+v.Name+"."+p.Node)
|
||||
}
|
||||
for _, e := range catalogue.EngineEvents {
|
||||
pub = append(pub, "mesh.seat."+EngineSeat+".event."+e+"."+p.Node)
|
||||
}
|
||||
sub = append(sub, "mesh.seat."+EngineSeat+".tool.*."+p.Node)
|
||||
pub = append(pub, "mesh.seat."+EngineSeat+".event.*."+p.Node)
|
||||
sub = append(sub, announcing(EngineSeat)...)
|
||||
pub = append(pub, discovering()...)
|
||||
tp, ts := seatTrafficGrants(SeatTrafficOf(asks.EngineAsker(p.Node), nil, p.Uses, nil))
|
||||
@@ -41,5 +59,24 @@ func engineGrants(p Principal) (pub, sub []string) {
|
||||
if own, err := NodeKeysGrants(EngineActsBucket, p.Node); err == nil {
|
||||
pub = append(pub, own...)
|
||||
}
|
||||
// And the provisions its node's modules consume, its own node's keys alone, read (novox/hq ADR 0570 clause 6: the
|
||||
// consuming engine checks the vault's name and generation of what it is handed).
|
||||
if safeSubject.MatchString(p.Node) {
|
||||
pub = append(pub, readGrants(provisions.ProvisionsBucket, p.Node+".>")...)
|
||||
}
|
||||
// And its node's own health report, its key `<node>` alone (issue 774, the operator's alert table): what the watchman
|
||||
// reads for the machine's alerts.
|
||||
if safeSubject.MatchString(p.Node) {
|
||||
pub = append(pub, append([]string{"$KV." + delivery.NodeHealthBucket + "." + p.Node},
|
||||
readGrants(delivery.NodeHealthBucket, p.Node)...)...)
|
||||
}
|
||||
// And its node's placed modules' data items, its own node's keys alone (ADR 0577 clause 1).
|
||||
if own, err := NodeKeysGrants(EngineDataBucket, p.Node); err == nil {
|
||||
pub = append(pub, own...)
|
||||
}
|
||||
// And its node's declared packages, its own key alone (ADR 0565 clause 6).
|
||||
if safeSubject.MatchString(p.Node) {
|
||||
pub = append(pub, EnginePackagesGrants(p.Node)...)
|
||||
}
|
||||
return append(pub, tp...), append(sub, ts...)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
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 catalogue refuses it (catalogue.BusRemovals).
|
||||
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(catalogue.BusRemovals, ", "))
|
||||
}
|
||||
}
|
||||
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+">")
|
||||
}
|
||||
+62
-4
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
@@ -14,8 +15,10 @@ import (
|
||||
// A log follows a bucket in every respect (ADR 0201): the module names it locally, the mesh derives
|
||||
// its stream and subjects, the controller creates it from the catalogue on every raise and from
|
||||
// registration, never a module, and **no path of the mesh removes one** — a log whose declaration
|
||||
// is gone is reported, as a bucket is. Unlike a bucket, a log is its owner's alone: no other module
|
||||
// reads it, so there is no read of a log to grant or issue.
|
||||
// is gone is reported, as a bucket is. Only its owner appends to it. Another module reads it when its manifest's
|
||||
// `reads` names it, `<module>.<log>` (novox/hq ADR 0571 clause 1; the lead, 2026-10-29): by direct gets, and through an
|
||||
// ordered consumer of its own it creates and lets go of to read from a time or the newest per key and then follow new
|
||||
// entries; never a durable consumer, never a write.
|
||||
//
|
||||
// Pure, but for the stream's configuration, which is the server's own type so that what is tested
|
||||
// is exactly what is sent; jetstream.go is the part that asks a server.
|
||||
@@ -37,6 +40,8 @@ type Log struct {
|
||||
Name string
|
||||
// MaxMiB is its cap in MiB; zero is LogDefaultMiB.
|
||||
MaxMiB int
|
||||
// MaxAgeDays is how long an entry is kept; zero is as long as the log (catalogue.LogDeclaration).
|
||||
MaxAgeDays int
|
||||
}
|
||||
|
||||
// LogStreamPrefix starts every log's stream name, which no other stream of the mesh's starts with.
|
||||
@@ -74,7 +79,7 @@ func (l Log) Why() string {
|
||||
}
|
||||
|
||||
// Config is the stream a log is, field by field as novox/hq ADR 0297 fixes it: a file stream kept by
|
||||
// limits, with no maximum age and no cap per key, whose message is an entry's value and 4 KiB of
|
||||
// limits, with no maximum age unless the log says one (ADR 0565 §10) and no cap per key, whose message is an entry's value and 4 KiB of
|
||||
// headers at most, that refuses a new entry when full rather than
|
||||
// drop an old one, and that refuses deleting an entry or purging it. Direct gets are allowed, which
|
||||
// is how the runtime reads it.
|
||||
@@ -85,7 +90,7 @@ func (l Log) Config() jetstream.StreamConfig {
|
||||
Subjects: []string{l.Subject() + ".>"},
|
||||
Storage: jetstream.FileStorage,
|
||||
Retention: jetstream.LimitsPolicy,
|
||||
MaxAge: 0,
|
||||
MaxAge: time.Duration(l.MaxAgeDays) * 24 * time.Hour,
|
||||
MaxMsgs: -1,
|
||||
MaxMsgsPerSubject: -1,
|
||||
MaxBytes: l.MaxBytes(),
|
||||
@@ -135,6 +140,59 @@ func logGrants(module string, names []string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// DeclaredLogs is every log a module placed anywhere declares, as a read names it: `<module>.<log>`.
|
||||
func DeclaredLogs(r Records) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, ds := range r.Assigned {
|
||||
for _, d := range ds {
|
||||
for _, l := range d.Logs {
|
||||
out[d.Module+"."+l.Name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// withReadLogs is d with ReadLogs set: of its reads, those naming a log of logs.
|
||||
func withReadLogs(d Declared, logs map[string]bool) Declared {
|
||||
d.ReadLogs = nil
|
||||
for _, r := range d.Reads {
|
||||
if logs[strings.TrimSpace(r)] {
|
||||
d.ReadLogs = append(d.ReadLogs, strings.TrimSpace(r))
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// readLogGrants is what a reader of other modules' logs publishes, for each `<module>.<log>` read: the stream's info,
|
||||
// direct gets (an entry by sequence, the last of a key), and an ordered consumer of its own (created with any name and
|
||||
// filter, pulled, read and deleted). No append, no purge, no delete of an entry: the log's stream refuses those anyway.
|
||||
func readLogGrants(reads []string) []string {
|
||||
var out []string
|
||||
for _, r := range reads {
|
||||
module, name, ok := strings.Cut(r, ".")
|
||||
if !ok || !safeSubject.MatchString(module) || !safeSubject.MatchString(name) {
|
||||
continue
|
||||
}
|
||||
stream := LogStreamName(module, name)
|
||||
out = append(out, "$JS.API.STREAM.INFO."+stream, "$JS.API.DIRECT.GET."+stream, "$JS.API.DIRECT.GET."+stream+".>",
|
||||
"$JS.API.CONSUMER.CREATE."+stream+".>", "$JS.API.CONSUMER.MSG.NEXT."+stream+".*",
|
||||
"$JS.API.CONSUMER.INFO."+stream+".*", "$JS.API.CONSUMER.DELETE."+stream+".*")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// readLogsIssuedFor is every other module's log a module's code reads, as its membership lists them: by the read's
|
||||
// name, never written.
|
||||
func readLogsIssuedFor(d Declared) []LogIssued {
|
||||
var out []LogIssued
|
||||
for _, r := range d.ReadLogs {
|
||||
module, name, _ := strings.Cut(r, ".")
|
||||
out = append(out, LogIssued{Name: r, Stream: LogStreamName(module, name), Subject: LogSubject(module, name)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// logNames is the local names of a module's logs.
|
||||
func logNames(logs []Log) []string {
|
||||
out := make([]string, 0, len(logs))
|
||||
|
||||
@@ -24,6 +24,9 @@ type Placements struct {
|
||||
Interchangeable map[string]bool
|
||||
// Kinds is every kind held of a kinded bench, by whom and with what capabilities (ADR 0259 §5).
|
||||
Kinds []KindHeld
|
||||
// Logs is every log a module placed anywhere declares, `<module>.<log>` (DeclaredLogs): a read naming one is issued
|
||||
// as a log read (novox/hq ADR 0571 clause 1).
|
||||
Logs map[string]bool
|
||||
}
|
||||
|
||||
// AnswersForTheModule says whether an instance of a module on one machine is issued the module's
|
||||
@@ -58,7 +61,7 @@ func MembershipFor(node string, d Declared, where Placements) Membership {
|
||||
}
|
||||
}
|
||||
m.State = stateIssuedFor(d, node)
|
||||
m.Logs = logsIssuedFor(d)
|
||||
m.Logs = append(logsIssuedFor(d), readLogsIssuedFor(withReadLogs(d, where.Logs))...)
|
||||
m.Streams = streamsIssuedFor(d)
|
||||
m.Hears = HeardIssuedFor(node, d)
|
||||
m.Waits = waitsIssuedFor(d)
|
||||
@@ -103,7 +106,7 @@ func MembershipFor(node string, d Declared, where Placements) Membership {
|
||||
|
||||
// PlacementsOf reads where everything runs from the records the bus's accounts are composed from.
|
||||
func PlacementsOf(r Records, interchangeable map[string]bool) Placements {
|
||||
p := Placements{Nodes: map[string][]string{}, Interchangeable: interchangeable}
|
||||
p := Placements{Nodes: map[string][]string{}, Interchangeable: interchangeable, Logs: DeclaredLogs(r)}
|
||||
for node, declared := range r.Assigned {
|
||||
for _, d := range declared {
|
||||
p.Nodes[d.Module] = append(p.Nodes[d.Module], node)
|
||||
|
||||
+69
-17
@@ -181,6 +181,8 @@ type Principal struct {
|
||||
KeyedReads []KeyedRead
|
||||
// Logs is the local names of the logs this principal's module keeps (novox/hq ADR 0297).
|
||||
Logs []string
|
||||
// ReadLogs are, of Reads, those naming another module's log (novox/hq ADR 0571 clause 1).
|
||||
ReadLogs []string
|
||||
// Streams are the streams this principal's module owns, and Hears the module streams it hears with the
|
||||
// subjects it was granted (novox/hq ADR 0363).
|
||||
Streams []ModuleStream
|
||||
@@ -196,6 +198,12 @@ 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
|
||||
@@ -655,6 +663,19 @@ 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:
|
||||
@@ -988,6 +1009,10 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
if p.Module == WatchmanModule {
|
||||
sub = append(sub, BusConnectSubject, BusDisconnectSubject)
|
||||
pub = append(pub, BusConnzSubject)
|
||||
// And the dead letters, from its own consumer, never the controller's (novox/hq ADR 0564 clause 5).
|
||||
pub = append(pub, WatchmanDeadLetterGrants(p.DeadLetterSources)...)
|
||||
// And the words its watchdogs hear (ADR 0564 clause 4, Limits 3, 11).
|
||||
sub = append(sub, WatchmanHears()...)
|
||||
}
|
||||
// The grants part alone publishes the modules' memberships into the assignments stream (novox/hq ADR 0547 §5;
|
||||
// the operator, 2026-10-25 22:31): one per subject, `mesh.assignment.<node>.<module>`, reading the last one of each
|
||||
@@ -1036,10 +1061,11 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
}
|
||||
}
|
||||
sub = append(sub, announcing(announced...)...)
|
||||
// Its own membership (ADR 0160): the one subject a runtime derives for itself, read
|
||||
// directly from the stream and followed live. Nothing else's.
|
||||
// Its own membership (ADR 0160): the one subject a runtime derives for itself, heard live. Nothing else's.
|
||||
sub = append(sub, MembershipSubject(p.Node, p.Module))
|
||||
pub = append(pub, "$JS.API.DIRECT.GET."+AssignmentsStream+"."+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))...)
|
||||
|
||||
// 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
|
||||
@@ -1157,6 +1183,7 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
sub = append(sub, stateCreatedHeard(kept)...)
|
||||
// And its logs (novox/hq ADR 0297): appended to and read directly, its own alone.
|
||||
pub = append(pub, logGrants(p.Module, p.Logs)...)
|
||||
pub = append(pub, readLogGrants(p.ReadLogs)...)
|
||||
|
||||
// 6. Its traffic on seats that name their caller or their kind, ask proofs or keep records
|
||||
// (novox/hq ADR 0259 §3).
|
||||
@@ -1199,11 +1226,11 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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
|
||||
// Every assigned module's membership on this node (ADR 0160): one per module, heard live and read from the
|
||||
// grants part's state memberships (ADR 0571 clause 7). This node's and no other's — the one token
|
||||
// that varies is the module, so the pattern is the machine's own assignments.
|
||||
sub = append(sub, "mesh.assignment."+p.Node+".*")
|
||||
pub = append(pub, "$JS.API.DIRECT.GET."+AssignmentsStream+".mesh.assignment."+p.Node+".*")
|
||||
pub = append(pub, readGrants(GrantsMembershipsBucket, p.Node+".*")...)
|
||||
// And that it is there (novox/hq to-be 45 §3, S11): its own heartbeat, under its machine's
|
||||
// name and no other's, on core NATS like the host's.
|
||||
pub = append(pub, "mesh.control."+p.Node+".tools-alive")
|
||||
@@ -1260,6 +1287,7 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
// runtime's to keep, from the logs each membership lists.
|
||||
for _, d := range p.Carries {
|
||||
pub = append(pub, logGrants(d.Module, logNames(d.Logs))...)
|
||||
pub = append(pub, readLogGrants(d.ReadLogs)...)
|
||||
}
|
||||
// **And the event families a session may wait on** (novox/hq ADR 0367 §2): the tool runner's module's own,
|
||||
// named one by one in its manifest, as a plain subscription — no consumer is made for them, and no
|
||||
@@ -1649,17 +1677,7 @@ func ComposeAccounts(principals []Principal) (string, error) {
|
||||
if p.PasswordHash == "" {
|
||||
return "", fmt.Errorf("%s has no password hash: a user without one is a user anybody is", p.Username())
|
||||
}
|
||||
fmt.Fprintf(&b, " { user: %q, password: %q, permissions: {\n", p.Username(), p.PasswordHash)
|
||||
if len(perms.PublishDeny) > 0 {
|
||||
fmt.Fprintf(&b, " publish: { allow: [%s], deny: [%s] }\n", quoted(perms.Publish), quoted(perms.PublishDeny))
|
||||
} else {
|
||||
fmt.Fprintf(&b, " publish: { allow: [%s] }\n", quoted(perms.Publish))
|
||||
}
|
||||
fmt.Fprintf(&b, " subscribe: { allow: [%s] }\n", quoted(perms.Subscribe))
|
||||
if perms.AllowResponses {
|
||||
fmt.Fprintf(&b, " allow_responses: { max: 1, ttl: \"%dm\" }\n", int(ResponseTTL/time.Minute))
|
||||
}
|
||||
b.WriteString(" } }\n")
|
||||
b.WriteString(AccountsEntry(p.Username(), p.PasswordHash, perms))
|
||||
}
|
||||
b.WriteString(" ]\n }\n")
|
||||
b.WriteString(systemAccount)
|
||||
@@ -1667,6 +1685,25 @@ func ComposeAccounts(principals []Principal) (string, error) {
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// AccountsEntry is one user of the accounts block, as ComposeAccounts writes each: its name, its password hash and its
|
||||
// permissions, in the lines the bus reads. Exported so a writer that keeps a user from grants it holds rather than
|
||||
// composes (mesh-grants' refused users) writes the same lines, never a copy of them (issue 774).
|
||||
func AccountsEntry(user, passwordHash string, perms Permissions) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, " { user: %q, password: %q, permissions: {\n", user, passwordHash)
|
||||
if len(perms.PublishDeny) > 0 {
|
||||
fmt.Fprintf(&b, " publish: { allow: [%s], deny: [%s] }\n", quoted(perms.Publish), quoted(perms.PublishDeny))
|
||||
} else {
|
||||
fmt.Fprintf(&b, " publish: { allow: [%s] }\n", quoted(perms.Publish))
|
||||
}
|
||||
fmt.Fprintf(&b, " subscribe: { allow: [%s] }\n", quoted(perms.Subscribe))
|
||||
if perms.AllowResponses {
|
||||
fmt.Fprintf(&b, " allow_responses: { max: 1, ttl: \"%dm\" }\n", int(ResponseTTL/time.Minute))
|
||||
}
|
||||
b.WriteString(" } }\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// WatchmanModule is the one module whose principal hears the bus's connect and disconnect events and asks its list of
|
||||
// connections (novox/hq ADR 0431 §2, issue 1147): the watchman of the repository mesh-delivery, which writes presence
|
||||
// from them. No other principal is granted the subjects below, and nothing is a wildcard over them.
|
||||
@@ -1705,6 +1742,21 @@ func CreateConsumerGrants(consumers []ModuleConsumer) []string {
|
||||
// part, which composes them from the manifests and placement. It defines no stream and is granted no other subject here.
|
||||
const GrantsModule = "mesh-grants"
|
||||
|
||||
// The grants part's state memberships (novox/hq ADR 0571 clause 7): one key per assignment, `<node>.<module>`, its
|
||||
// membership (Membership) bare, as the assignments stream carries it, written only when it differs. A module's runtime
|
||||
// reads its own key, the tool runner its node's; the grants part alone writes it. Both sides go through
|
||||
// membership.EncodeGrantsMembership and membership.DecodeGrantsMembership (or BareGrantsMembership for a reader with a
|
||||
// type of its own), never json of a type of their own: the grants part once wrote its wrapper here and every reader
|
||||
// served nothing (issue 774). The decoder still reads that wrapper, for the transition.
|
||||
const (
|
||||
GrantsMembershipsState = "memberships"
|
||||
GrantsMembershipsBucket = GrantsModule + "_" + GrantsMembershipsState
|
||||
GrantsMembershipsRead = GrantsModule + "." + GrantsMembershipsState
|
||||
)
|
||||
|
||||
// MembershipKey is an assignment's key in the grants part's memberships: `<node>.<module>`.
|
||||
func MembershipKey(node, module string) string { return node + "." + module }
|
||||
|
||||
// The bus's own events, imported into MESH from the default system account `$SYS` under subjects of MESH's own
|
||||
// (novox/hq ADR 0431 §1, issue 1147): the server says an account's connections opening and closing only to the system
|
||||
// account, and the list of an account's connections only to a request made there. `$SYS` holds no user, so nobody
|
||||
|
||||
@@ -3,9 +3,11 @@ package buscompose
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/provisions"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
@@ -34,15 +36,19 @@ var ConfigResolvedBucket = BucketName("mesh-config", "resolved")
|
||||
// ProvisionerReads are the domains' buckets a provisioner client reads its own machine's keys of, each keyed
|
||||
// `<node>.…` (research 051): placement, the controller's assignments mirror (`<node>.<module>`), whose value carries
|
||||
// the provisioner's plan of a module handed to it (inventory.Provisioning, design 57 P5); and config,
|
||||
// mesh-config's resolved values. **Named one by one**, as the controller's asks are: a domain that moves to the
|
||||
// mesh-config's resolved values; and the seat mesh-provisioner's plans of its machine, `<node>.<module>` (novox/hq ADR
|
||||
// 0570 clause 2). **Named one by one**, as the controller's asks are: a domain that moves to the
|
||||
// provisioner adds its bucket here, and a bucket not named is one the client cannot read.
|
||||
var ProvisionerReads = []string{ConfigResolvedBucket}
|
||||
|
||||
// ProvisionerReadsWhole are the buckets a provisioner client reads every key of: the controller's assignments mirror,
|
||||
// where a provider's consumers are bound to it in their own machines' plans, which the provider machine's client
|
||||
// reads to fill ${consumers:<provision>} with each one's credential (novox/hq ADR 0373, issue 679). It holds placement
|
||||
// and plans, never a secret's value; the client writes none of it.
|
||||
var ProvisionerReadsWhole = []string{AssignmentsBucket}
|
||||
// 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}
|
||||
|
||||
// BoundState is a providing module's state of entries, one per consumer (mesh-sdk go/provider, issue 678): a
|
||||
// consumer's machine's client reads its own keys of it, `<node>.<module>.<provision>[.<local>]`, to fill
|
||||
@@ -68,8 +74,13 @@ var (
|
||||
)
|
||||
|
||||
// ProvisionerWrites are the server's buckets a client writes, and reads, its own machine's keys of (`<node>.>`)
|
||||
// and no other's: the needs it records, what it applied, and the items of the collections on its machine. A
|
||||
// client cannot write another machine's needs, or say another machine applied a file.
|
||||
// and no other's: the needs it records, what it applied, and the items of the collections on its machine. A client
|
||||
// cannot write another machine's needs, or say another machine applied a file.
|
||||
//
|
||||
// **The needs stay granted for one release, and are removed after the lease write** (review round 3 of design 47's
|
||||
// release): the client's V3 reads its machine's plans from the seat mesh-provisioner's `plans` (novox/hq ADR 0570 clause
|
||||
// 2) and records no needs, but the client before it still runs on a node until its delivery reaches it, and it writes
|
||||
// them.
|
||||
var ProvisionerWrites = []string{ProvisionerNeedsBucket, ProvisionerAppliedBucket, ProvisionerCollectBucket}
|
||||
|
||||
// VerbsTheProvisionerAsksTheVault are the vault's verbs a provisioner client asks in its own name (ADR 0349
|
||||
|
||||
@@ -58,6 +58,20 @@ 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,6 +156,12 @@ func stateGrants(a stateAccess) []string {
|
||||
}
|
||||
for _, r := range a.Reads {
|
||||
if bucket, ok := bucketOfRead(r); ok {
|
||||
if NodeKeyedReads[r] {
|
||||
if machineKey != "" {
|
||||
read(bucket, machineKey+".>")
|
||||
}
|
||||
continue
|
||||
}
|
||||
read(bucket, "")
|
||||
}
|
||||
}
|
||||
@@ -167,6 +173,11 @@ func stateGrants(a stateAccess) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// NodeKeyedReads are the states keyed `<node>.>` whose reader reads its own machine's keys alone, whatever its `reads`
|
||||
// names: the node-engine's data items, each node's disk-load reading its own node's (novox/hq ADR 0577 clause 1). A
|
||||
// machine with no usable name reaches none of them.
|
||||
var NodeKeyedReads = map[string]bool{delivery.EngineDataRead: true}
|
||||
|
||||
// StreamCreatedAdvisory is the subject the bus says a stream was created on (the server's own advisory), here for a
|
||||
// bucket's stream KV_<bucket>.
|
||||
func StreamCreatedAdvisory(bucket string) string {
|
||||
|
||||
@@ -171,14 +171,9 @@ func MeshStreams() []Stream {
|
||||
Why: "one declaration per node, always the newest; a node that sees sequence n refuses " +
|
||||
"n-1 by construction (issue 107)",
|
||||
},
|
||||
{
|
||||
Name: AssignmentsStream,
|
||||
Subjects: []string{"mesh.assignment.*.*"},
|
||||
Retention: RetentionLastPerSubject,
|
||||
Direct: true,
|
||||
Why: "one membership per assignment, always the newest: what the mesh issued this module " +
|
||||
"on this machine to serve and to reach (ADR 0160); read directly by the runtime it is for",
|
||||
},
|
||||
// The assignments stream (ADR 0160) is no longer the mesh's: a runtime reads its membership from the grants
|
||||
// part's state memberships (ADR 0571 clause 7), and the bus module's tools delete the stream at the lease write
|
||||
// (research 072 row 33), so it is not declared here, which would make it again.
|
||||
{
|
||||
Name: EventsStream,
|
||||
// A seat's own events ride here too: they are 1:many like any event, and the
|
||||
|
||||
@@ -3,8 +3,12 @@ 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
|
||||
@@ -77,12 +81,30 @@ 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}}
|
||||
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}}
|
||||
|
||||
// ReaderMay says whether module may read the state read (`<owner>.<name>`) by the named readers: always, for a state
|
||||
// that names none.
|
||||
@@ -103,6 +125,23 @@ func readsNamed(module string, reads []string) (granted, refused []string) {
|
||||
return granted, refused
|
||||
}
|
||||
|
||||
// withAnnouncedReads is d with its reads judged for d's module (ReadsAnnouncedBy) against what the owners announced:
|
||||
// every read its manifest names, granted or refused by the caller, judged again here, so a caller that split them
|
||||
// without the reader (the controller's own copy of a reader-less judgement, issue 774: the vault, the assigner, the
|
||||
// grants part and the artifact store's tools refused the lease they are named for) cannot refuse a read the reader is
|
||||
// named for, nor grant one named for another. With no announcements read it is withNamedReaders.
|
||||
func withAnnouncedReads(d Declared, announced map[string]announce.States) Declared {
|
||||
if announced == nil {
|
||||
return withNamedReaders(d)
|
||||
}
|
||||
all := append(append([]string(nil), d.Reads...), d.ReadsRefused...)
|
||||
if len(all) == 0 {
|
||||
return d
|
||||
}
|
||||
d.Reads, d.ReadsRefused = ReadsAnnouncedBy(d.Module, all, announced)
|
||||
return d
|
||||
}
|
||||
|
||||
// withNamedReaders is d with the reads named for other modules moved to its refused reads.
|
||||
func withNamedReaders(d Declared) Declared {
|
||||
granted, refused := readsNamed(d.Module, d.Reads)
|
||||
@@ -113,3 +152,30 @@ func withNamedReaders(d Declared) Declared {
|
||||
d.ReadsRefused = append(append([]string(nil), d.ReadsRefused...), refused...)
|
||||
return d
|
||||
}
|
||||
|
||||
// **The hearings the grants part withholds, on the bus** (issue 1955 L7, mesh-delivery e564eaf): a hearing of a restricted
|
||||
// stream a module asks in the tool runner, withheld because only an account of its own may hear it (ADR 0376 §2), kept
|
||||
// in the grants part's state `withheld`, one key `<module>.<stream, lower case>.<machine>` (the controller's condition id
|
||||
// of it), written on change only and deleted once heard; the grants part's tools read it (D-heard was dropped on
|
||||
// 2026-10-30, so no other module reads it).
|
||||
const (
|
||||
GrantsWithheldState = "withheld"
|
||||
GrantsWithheldBucket = GrantsModule + "_" + GrantsWithheldState
|
||||
GrantsWithheldRead = GrantsModule + "." + GrantsWithheldState
|
||||
WithheldVersion = 1
|
||||
)
|
||||
|
||||
// WithheldHearing is one withheld hearing: the machine, the module, the restricted stream and its emitter, the plain
|
||||
// words of it and the remedy (Withheld.Said), and when the grants part first wrote it as it stands.
|
||||
type WithheldHearing struct {
|
||||
V int `json:"v"`
|
||||
Node string `json:"node"`
|
||||
Module string `json:"module"`
|
||||
Stream string `json:"stream"`
|
||||
Emitter string `json:"emitter"`
|
||||
Said string `json:"said"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// WithheldKey is a withheld hearing's key: `<module>.<stream, lower case>.<machine>`.
|
||||
func WithheldKey(w Withheld) string { return w.Module + "." + strings.ToLower(w.Stream) + "." + w.Node }
|
||||
|
||||
+34
-7
@@ -64,6 +64,9 @@ type Declared struct {
|
||||
// ReadsRefused are the reads its manifest names that the owner's announcement does not make readable (novox/hq ADR
|
||||
// 0495 clause 3, issue 1790): granted nothing, kept to be said.
|
||||
ReadsRefused []string
|
||||
// ReadLogs are, of its reads, those naming another module's log (`<module>.<log>`, novox/hq ADR 0571 clause 1): filled
|
||||
// by Users and PlacementsOf from the logs the modules placed anywhere declare, never by a caller.
|
||||
ReadLogs []string
|
||||
// KeyedReads are keys of other modules' state it reads, each one key alone: what a seat's holder is
|
||||
// granted for the pieces the modules beside it offer (novox/hq ADR 0260).
|
||||
KeyedReads []KeyedRead
|
||||
@@ -105,6 +108,9 @@ type ProcessPrincipal struct {
|
||||
Emits []string
|
||||
// Queues are the module's own work-queue streams this process is the one consumer of.
|
||||
Queues []QueueOwner
|
||||
// Removes are what its account may remove from the bus, as its bus declares them (catalogue.BusRemovals; 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
|
||||
@@ -154,6 +160,7 @@ 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
|
||||
@@ -193,9 +200,14 @@ func Users(r Records) ([]Principal, error) {
|
||||
}
|
||||
}
|
||||
for _, d := range r.Assigned[node] {
|
||||
// A state with named readers is read by them alone (ADR 0547 clause 9): a read named for another module is
|
||||
// granted nothing, here and in the runtime carrying the module.
|
||||
d = withNamedReaders(d)
|
||||
// **The node-engine's manifest is no placed module's** (novox/hq ADR 0563 §2, §3; issue 774): where a placement
|
||||
// names it, it adds no user, no process and nothing the runner carries; its grants are the node's account's.
|
||||
if !Composed(d) {
|
||||
continue
|
||||
}
|
||||
// A state with named readers is read by them alone (ADR 0547 clause 9), and a read is granted what its owner
|
||||
// announced (ADR 0495 clause 3): judged here, for this reader, whatever split the caller made (issue 774).
|
||||
d = withReadLogs(withAnnouncedReads(d, r.Announced), logs)
|
||||
// **A process account is its own, delivered in its own secret** (broker-<process id>, novox/hq
|
||||
// ADR 0436 §1, §2), never the module's `broker` — so it is composed whether or not the module
|
||||
// itself has an account, and is never carried by the machine's runtime, which runs as the
|
||||
@@ -204,7 +216,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,
|
||||
Invokes: pr.Invokes, Removes: pr.Removes,
|
||||
})
|
||||
}
|
||||
if runtimeHere && d.Module == RuntimeModule {
|
||||
@@ -223,12 +235,15 @@ func Users(r Records) ([]Principal, error) {
|
||||
Kind: KindModule, Node: node, Module: d.Module,
|
||||
Emits: d.Emits, Consumes: d.Consumes, Serves: d.Serves,
|
||||
Holds: d.Holds, Uses: d.Uses, Watches: d.Watches, Invokes: d.Invokes,
|
||||
State: stateNames(d.State), Reads: d.Reads, SnapshotsTheBus: d.SnapshotsTheBus,
|
||||
State: stateNames(d.State), Reads: d.Reads, ReadLogs: d.ReadLogs, SnapshotsTheBus: d.SnapshotsTheBus,
|
||||
PerMachine: perMachineNames(d.State), KeyedReads: d.KeyedReads,
|
||||
Logs: logNames(d.Logs), Streams: d.Streams, Hears: ComposedHears(d), Queues: d.Queues,
|
||||
})
|
||||
// **The grants part creates what a manifest declares** (novox/hq ADR 0547 §5; the operator, 2026-10-26
|
||||
// 08:54): every placed module's buckets, logs and streams, each by its stream's name.
|
||||
if d.Module == WatchmanModule {
|
||||
out[len(out)-1].DeadLetterSources = deadLetterSourcesOf(r)
|
||||
}
|
||||
if d.Module == GrantsModule {
|
||||
out[len(out)-1].CreatesStreams = declaredStreamsOf(r)
|
||||
grants = len(out) - 1
|
||||
@@ -243,8 +258,8 @@ func Users(r Records) ([]Principal, error) {
|
||||
if runtimeHere {
|
||||
var carried []Declared
|
||||
for _, d := range r.Assigned[node] {
|
||||
if d.RunsAs == "" && runnerServes(d) {
|
||||
d = withNamedReaders(d)
|
||||
if CarriedByRunner(d) {
|
||||
d = withReadLogs(withAnnouncedReads(d, r.Announced), logs)
|
||||
// Never a restricted stream for what the tool runner carries (novox/hq ADR 0376 §1).
|
||||
d.Hears = ComposedHears(d)
|
||||
carried = append(carried, d)
|
||||
@@ -440,6 +455,18 @@ func boundBucketsOf(r Records) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// Composed says a placed module is composed as one: its own user, its processes and what the runner carries of it.
|
||||
// The node-engine's manifest (EngineModule) is not, though a placement may name it: each node's engine speaks as the
|
||||
// node's account, which alone writes the engine's state under its own key (engineGrants, novox/hq ADR 0563 §2, §3), and
|
||||
// Records.Engine creates that state. Granted to another account it is a second writer, and the writers table refuses
|
||||
// the whole list (issue 774: met for mesh-host_acts and then mesh-host_health, 2026-10-30).
|
||||
func Composed(d Declared) bool { return d.Module != EngineModule }
|
||||
|
||||
// CarriedByRunner says the machine's tool runner carries this placed module: a module composed at all (Composed) that
|
||||
// runs as no account of its own and has something for the runner to serve (runnerServes). The one test of what the
|
||||
// runner's grants are composed from.
|
||||
func CarriedByRunner(d Declared) bool { return Composed(d) && d.RunsAs == "" && runnerServes(d) }
|
||||
|
||||
// runnerServes says the machine's tool runner has something of this module to serve (novox/hq ADR 0436 §1,
|
||||
// ADR 0437 §4, issue 1329): tools or loads it answers, a seat it holds, event families a session waits on,
|
||||
// a bundle the runtime loads (Loads), or a check its health asks. A module with none of these is a process of its own on its own credential, and
|
||||
|
||||
@@ -43,6 +43,9 @@ 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
|
||||
@@ -185,7 +188,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)"},
|
||||
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)"},
|
||||
{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"},
|
||||
@@ -220,7 +223,8 @@ 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: isController},
|
||||
Subjects: []string{deadLetterPrefix + ">", againPrefix + ">"}, Writes: keepsDeadLetters,
|
||||
Shared: "the watchman keeps the copies from the controller's lease write, the controller before it; delivering again stays the controller's (novox/hq ADR 0564 clause 5)"},
|
||||
// The record mirrors (novox/hq ADR 0289 §9, design 54 §2): rows of this table, so a grant that lets
|
||||
// anything else write them is refused at composition. Built so far: the assignments and each registered
|
||||
// module's declared config (design 57 F3), and the list of predefined keys kept (design 57 K3).
|
||||
@@ -247,9 +251,25 @@ 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: ownNodeKeys},
|
||||
Others: "read through the node seat's verb acts", Subjects: kvOf(EngineActsBucket),
|
||||
Writes: func(p Principal, pattern string) bool {
|
||||
return ownNodeKeys(p, pattern) || removesEngineActs(p, pattern)
|
||||
},
|
||||
Shared: "the bus module's tools account removes keys of a full bucket, for removal only (novox/hq ADR 0564 Limits 4)"},
|
||||
// The 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 " +
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// Package busretry waits out a bus that answers nothing for a few seconds (issue 774, the sweep of 2026-10-30: bus
|
||||
// blackouts at every user-list reload). nats-server 2.11's reloadAuthorization rebuilds the accounts, and for a few
|
||||
// seconds the mesh's account has none of its imports, $JS.API.> among them: every request is answered "no responders"
|
||||
// and every JetStream call "jetstream not enabled". A write or a call made in that window failed, and was lost.
|
||||
//
|
||||
// Only an error that says the request reached no one is waited out (Undelivered): nothing was done, so asking again
|
||||
// is safe even for a write that is not idempotent. A timeout, or any answer, is never asked again: the request may
|
||||
// have been delivered. The wait is bounded (Bound, about 30 s, backing off), and said on standard error when it ends
|
||||
// without an answer.
|
||||
//
|
||||
// This is the quick fix. The thorough one is a reload that keeps the account's imports, in the bus's holder.
|
||||
package busretry
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// Bound is how long a request that reached no one is asked again; FirstWait the first pause, doubled to MostWait.
|
||||
var (
|
||||
Bound = 30 * time.Second
|
||||
FirstWait = 250 * time.Millisecond
|
||||
MostWait = 4 * time.Second
|
||||
)
|
||||
|
||||
// Undelivered says err means the request reached no one: no responders, JetStream not enabled (for the account), or
|
||||
// no response from the stream after the client's own retries on no responders; also said in words by a runtime that
|
||||
// passed the error on.
|
||||
func Undelivered(err error) bool {
|
||||
var answered *Answered
|
||||
if err == nil || errors.As(err, &answered) {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, nats.ErrNoResponders) || errors.Is(err, jetstream.ErrJetStreamNotEnabled) ||
|
||||
errors.Is(err, jetstream.ErrJetStreamNotEnabledForAccount) || errors.Is(err, jetstream.ErrNoStreamResponse) ||
|
||||
errors.Is(err, nats.ErrJetStreamNotEnabled) || errors.Is(err, nats.ErrJetStreamNotEnabledForAccount) {
|
||||
return true
|
||||
}
|
||||
words := strings.ToLower(err.Error())
|
||||
return strings.Contains(words, "no responders") || strings.Contains(words, "jetstream not enabled")
|
||||
}
|
||||
|
||||
// Answered is an error a callee answered with: the request was delivered, whatever its words say ("jetstream not
|
||||
// enabled" in a callee's own answer), so Undelivered never counts it and Do never asks it again.
|
||||
type Answered struct{ Words string }
|
||||
|
||||
func (a *Answered) Error() string { return a.Words }
|
||||
|
||||
// Do runs try, and again while its error says the request reached no one, until Bound has passed. blackout, when
|
||||
// given, is asked after each such error and Do stops waiting when it says the bus is not blacked out: a call nobody
|
||||
// answers because its callee is not running fails at once, as before. what names the request in the error.
|
||||
func Do[T any](what string, try func() (T, error), blackout func() bool) (T, error) {
|
||||
v, err := try()
|
||||
if !Undelivered(err) || (blackout != nil && !blackout()) {
|
||||
return v, err
|
||||
}
|
||||
start, wait := time.Now(), FirstWait
|
||||
for time.Since(start) < Bound {
|
||||
time.Sleep(wait)
|
||||
wait = min(wait*2, MostWait)
|
||||
v, err = try()
|
||||
if !Undelivered(err) || (blackout != nil && !blackout()) {
|
||||
return v, err
|
||||
}
|
||||
}
|
||||
err = fmt.Errorf("%s: the bus answered nothing for %s, asked again until then (a user-list reload drops the "+
|
||||
"account's imports for a few seconds; this was longer): %w", what, time.Since(start).Round(time.Second), err)
|
||||
fmt.Fprintf(os.Stderr, "[mesh-sdk] %v\n", err)
|
||||
return v, err
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package busretry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// Issue 774 (bus blackouts at every user-list reload): only a request that reached no one is asked again, within the
|
||||
// bound; a blackout probe that says the bus answers ends the wait at once.
|
||||
func TestABusThatAnswersNothingIsWaitedOut(t *testing.T) {
|
||||
Bound, FirstWait, MostWait = 60*time.Millisecond, time.Millisecond, 4*time.Millisecond
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
errs []error // what each try answers; the last repeats
|
||||
blackout func() bool
|
||||
tries int // at least
|
||||
atMost int
|
||||
delivered bool
|
||||
}{
|
||||
{"no responders, then an answer", []error{nats.ErrNoResponders, nats.ErrNoResponders, nil}, nil, 3, 3, true},
|
||||
{"jetstream not enabled, then an answer", []error{jetstream.ErrJetStreamNotEnabled, nil}, nil, 2, 2, true},
|
||||
{"said in words by a runtime", []error{errors.New("nats: no responders available for request"), nil}, nil, 2, 2, true},
|
||||
{"a timeout may have been delivered: never asked again", []error{context.DeadlineExceeded}, nil, 1, 1, false},
|
||||
{"an answer that refuses is an answer", []error{errors.New("refused")}, nil, 1, 1, false},
|
||||
{"nobody answers on a bus that answers: the callee is not running", []error{nats.ErrNoResponders}, func() bool { return false }, 1, 1, false},
|
||||
{"blacked out past the bound: said", []error{jetstream.ErrJetStreamNotEnabledForAccount}, func() bool { return true }, 3, 1000, false},
|
||||
} {
|
||||
tries := 0
|
||||
_, err := Do("the test", func() (int, error) {
|
||||
e := row.errs[min(tries, len(row.errs)-1)]
|
||||
tries++
|
||||
return tries, e
|
||||
}, row.blackout)
|
||||
if tries < row.tries || tries > row.atMost || (err == nil) != row.delivered {
|
||||
t.Errorf("%s: %d tries, %v", row.name, tries, err)
|
||||
}
|
||||
if strings.HasPrefix(row.name, "blacked out") && (!Undelivered(err) || !strings.Contains(fmt.Sprint(err), "the bus answered nothing")) {
|
||||
t.Errorf("%s: %v", row.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package catalogue
|
||||
|
||||
// **The artifact sweep, out of the controller** (novox/hq ADR 0571 clause 2, issue 1955): what the artifact store keeps
|
||||
// and lets go of, the seat `mesh-artifact-sweep` (named for its scope, ADR 0121 §1; the operator, 2026-10-29), held
|
||||
// once for the mesh by artifact-store-tools, serves what the controller served of its store (artifacts, held, mirrors, images, collect) and says `artifacts-gone`. Its keep set is the store's own listing joined
|
||||
// with every want's fingerprint and each module's five newest builds from the builder's log (ADR 0526 §1); it lets go
|
||||
// of nothing until the controller's lease names a holder that is no controller.
|
||||
|
||||
// ArtifactSweepSeat is the seat artifact-store-tools holds. Not the seat mesh-artifact-store, the store itself held by
|
||||
// distribution, and not the provision artifact-store it delivers.
|
||||
const ArtifactSweepSeat = "mesh-artifact-sweep"
|
||||
|
||||
// artifactSweepVerbs are the seat's verbs, with the controller's arguments so a caller changes only the seat it asks.
|
||||
func artifactSweepVerbs() []Verb {
|
||||
return []Verb{
|
||||
{Name: "artifacts", Description: "Every artifact the mesh made that lives in its artifact store, each with its " +
|
||||
"repository, digest, kind (image or archive) and state: kept, and why (a want names it, or one of the five " +
|
||||
"newest builds of its module in the builder's log), eligible (made, kept for no reason, not yet let go), or " +
|
||||
"unrecorded (in the store and named by nothing the mesh made: never removed). Reads only.",
|
||||
Input: schema(map[string]string{
|
||||
"repository": "only this repository's references, as <module>/<artifact> (the counts stay whole)",
|
||||
"collected": "\"true\": list the collected references too (counted always)",
|
||||
}, nil, "collected")},
|
||||
{Name: "held", Description: "Whether the artifact store holds each reference given, asked with one HEAD each: " +
|
||||
"held, gone (the store answered 404) or unknown with why. The builder asks it before it reuses a done key. " +
|
||||
"Reads only.",
|
||||
Input: listed(schema(map[string]string{
|
||||
"references": "the artifacts' references, artifact-store://<module>/<artifact>/blobs/sha256:<hex>: a list, " +
|
||||
"or one text separated by commas",
|
||||
}, []string{"references"}), "references")},
|
||||
{Name: "collect", Description: "The artifact store's sweep, now: what is eligible is let go of. Without confirm a " +
|
||||
"dry run: what would be let go, nothing held or deleted. With confirm and why: every kept archive held first, " +
|
||||
"then each eligible artifact let go of, oldest first, an index with the platform manifests no kept index " +
|
||||
"names, each deletion recorded in the hand-act log. Bounded by most (500 by default, at most 5000) and 45 " +
|
||||
"seconds; what is left is said. Never touches a digest the mesh did not make.",
|
||||
Input: schema(map[string]string{
|
||||
"confirm": "\"true\": let go of what is eligible (needs why); without it nothing is done",
|
||||
"why": "with confirm: why, required and recorded",
|
||||
"most": "let go of at most this many (500 by default, at most 5000)",
|
||||
}, nil, "confirm")},
|
||||
{Name: "mirrors", Description: "The bases the mesh copied into its artifact store, each kept (a kept build stood on " +
|
||||
"it), eligible (only builds no longer kept stood on it) or unrecorded (no build the builder's log holds stood on " +
|
||||
"it: never removed), by what the builder's log says each build stood on. Records nothing by hand.",
|
||||
Input: schema(map[string]string{}, nil)},
|
||||
{Name: "images", Description: "Every container image one machine's want and have name, each with the resources " +
|
||||
"naming it: what the machine keeps when it prunes its images. Reads only.",
|
||||
Input: schema(map[string]string{"node": "the machine's name"}, []string{"node"})},
|
||||
}
|
||||
}
|
||||
+30
-18
@@ -126,23 +126,7 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) {
|
||||
continue
|
||||
}
|
||||
made := by[a.Name]
|
||||
// What the runtime loads: what the artifact said, else every entrypoint of a module
|
||||
// that declares tools, else nothing (the field's own rule; see Artifact.Loads).
|
||||
//
|
||||
// **Never, unasked, a bundle one of the module's own resources runs** (novox/hq issue 213).
|
||||
// A process the host runs is the module's service, not its tools: the controller declares
|
||||
// the verbs it answers as `tools` and serves them itself, and its bundle would otherwise
|
||||
// have been launched a second time by the node's runtime, as an MCP child it is not.
|
||||
loads := append([]string(nil), a.Loads...)
|
||||
if a.Loads == nil && len(m.Tools) > 0 && !run[a.Name] {
|
||||
loads = append([]string(nil), a.Entrypoints...)
|
||||
// A bundle compiled to a binary has no entrypoints: the binary is what it is, and what
|
||||
// the runtime starts to serve it (novox/hq ADR 0193). So a Go tools bundle is served
|
||||
// as Go — the runtime execs it — exactly as a TypeScript one is through its launcher.
|
||||
if bin := BinaryOf(a); bin != "" {
|
||||
loads = []string{bin}
|
||||
}
|
||||
}
|
||||
loads := BundleLoads(m, a, run)
|
||||
// **Kept, never routed** (ADR 0155): the builder publishes to the store at the address
|
||||
// it reached it by, and a manifest carrying that address names an installation —
|
||||
// registration refused node-tools for exactly this on 2026-10-02. The build record
|
||||
@@ -152,7 +136,7 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) {
|
||||
Name: a.Name, Source: Recorded(made.Reference), Digest: made.Digest,
|
||||
Language: a.Language, Entrypoints: append([]string(nil), a.Entrypoints...),
|
||||
Loads: loads, Env: copyWords(a.Env), Launchers: copyWords(made.Launchers),
|
||||
Binary: BinaryOf(a),
|
||||
Binary: BinaryOf(a), Bus: a.Bus,
|
||||
})
|
||||
}
|
||||
sort.Slice(out.Bundles, func(i, j int) bool { return out.Bundles[i].Name < out.Bundles[j].Name })
|
||||
@@ -715,3 +699,31 @@ func undeliveredBundles(m Manifest) []string {
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// 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}
|
||||
}
|
||||
}
|
||||
return loads
|
||||
}
|
||||
|
||||
// RunByAResource is the bundles of m one of its own resources runs (its service, not its tools).
|
||||
func RunByAResource(m Manifest) map[string]bool { return runByAResource(m) }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -27,14 +28,28 @@ func TestTheBussAccountSaysNothingOnTheBus(t *testing.T) {
|
||||
"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 {
|
||||
m, err := ParseManifest(bundled)
|
||||
if err != nil {
|
||||
t.Fatalf("a bus module whose tools bundle declares what it emits was refused: %v", err)
|
||||
}
|
||||
// And so does its built manifest, its bundle in Bundles and its build read away (issue 774: nats's built manifest
|
||||
// was refused, and with it every plan of the control-node).
|
||||
built, err := m.Resolve([]Built{{Name: "tools", Kind: ArtifactBundle, Reference: "store/tools:1", Digest: "sha256:1"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err = json.Marshal(built)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ParseManifestForInputs(raw); err != nil {
|
||||
t.Fatalf("the built manifest of 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)
|
||||
m, err = ParseManifest(quiet)
|
||||
if err != nil {
|
||||
t.Fatalf("a bus module with an account and tools was refused: %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package catalogue
|
||||
|
||||
// **The control records, out of the controller** (novox/hq ADR 0565 clause 1, issue 1953): two seats at mesh scope,
|
||||
// each held once, and the node seat's verb `packages`.
|
||||
//
|
||||
// - mesh-wants, held by the assigner (mesh-delivery-assigner): a person's upgrade policy and the bus step (clauses 7,
|
||||
// 8, 9);
|
||||
// - mesh-hand-acts, held by the module mesh-hand-acts: the hand-act log and the warrants recorded (clause 10).
|
||||
//
|
||||
// Their events are their holders' to say on the seat; their layouts are go/handacts' and go/delivery's.
|
||||
|
||||
// The two seats.
|
||||
const (
|
||||
WantsSeat = "mesh-wants"
|
||||
HandActsSeat = "mesh-hand-acts"
|
||||
)
|
||||
|
||||
// controlRecordsDecision is the record that made the two seats.
|
||||
const controlRecordsDecision = "novox/hq ADR 0565"
|
||||
|
||||
// wantsVerbs are the assigner's verbs on mesh-wants (ADR 0565 clauses 7, 8, 9, 15).
|
||||
func wantsVerbs() []Verb {
|
||||
return []Verb{
|
||||
{Name: "upgrade", Description: "Whether each module's new builds wait for a person: its policy (roll, together or record) " +
|
||||
"and where it comes from (a person, the module, the bus, its irreplaceable data, the default); the bus is never " +
|
||||
"rolled. With module, that one; with module, policy and why, a person's choice for it, recorded as a hand act; " +
|
||||
"with policy default, the choice taken back. With compare, every module whose policy differs from the one the " +
|
||||
"controller writes beside its compositions.",
|
||||
Input: schema(map[string]string{
|
||||
"module": "one module",
|
||||
"policy": "with module: roll, together, record, or default to take a person's choice back",
|
||||
"why": "with policy: why, required and kept",
|
||||
"compare": "\"true\": every module whose policy at the assigner differs from the controller's",
|
||||
}, nil, "compare")},
|
||||
{Name: "bus", Description: "What a bus upgrade would do: the fingerprint of the bus module each node runs against the " +
|
||||
"indexer's newest, and the last bus steps from the hand-act log and the assigner's puts of the bus. Read-only: " +
|
||||
"the step itself is the put on the operator's warrant.",
|
||||
Input: schema(map[string]string{}, nil)},
|
||||
}
|
||||
}
|
||||
|
||||
// handActsVerbs are mesh-hand-acts' verbs (ADR 0565 clause 10), as the controller's took them.
|
||||
func handActsVerbs() []Verb {
|
||||
return []Verb{
|
||||
{Name: "hand-act", Description: "Record one act done by hand: what, why, its cause in a word or a condition's kind " +
|
||||
"(the word a second act for the same reason uses) and the condition it addresses. Who did it is the caller the " +
|
||||
"runtime proves, never said by the caller. Said as act-recorded.",
|
||||
Input: schema(map[string]string{
|
||||
"what": "what was done, in a line",
|
||||
"why": "why it had to be done by hand",
|
||||
"cause": "the cause in a word, or a condition's kind",
|
||||
"condition": "the key of the condition it addressed, if any (optional)",
|
||||
"verb": "the act's verb in the log: a repair when absent, `hand-act drill` for a drill, or a recorder's own",
|
||||
}, []string{"what", "why"})},
|
||||
{Name: "hand-acts", Description: "What was done by hand lately, over this log and the controller's: who, why and the " +
|
||||
"cause of each, and the causes recorded twice within a fortnight (a healer is wanted).",
|
||||
Input: schema(map[string]string{"days": "how many days back (default 14)", "cause": "only this cause",
|
||||
"who": "only acts whose who contains this", "warrants": "\"true\": every warrant recorded"}, nil, "warrants")},
|
||||
{Name: "warranted", Description: "Record what a module did on the operator's warrant: who chose, through which channel, " +
|
||||
"with which proofs and which answer read from the router's own record of that module's ask, never from the " +
|
||||
"caller; once per ask. Refused for an ask still open, ended without a choice, or not that module's.",
|
||||
Input: schema(map[string]string{"asker": "the module that asked", "ask": "its ask's id"}, []string{"asker", "ask"})},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// **The data items of a node's modules, placed, read from the composition** (novox/hq ADR 0571 clause 3, ADR 0233 §22 to
|
||||
// §25): disk-load measures each item every module on its node declares, and the node-backup holder, mounts and nfs-server
|
||||
// read the readings. Each item is its module's declaration with its path resolved to this node's place, its protection
|
||||
// and how it is measured: the same lines the node-backup holder is handed as `${backup:data}`, composed by the same
|
||||
// function, so a reader that composes from the bus and a holder handed the file read one list.
|
||||
|
||||
// PlacedDataItem is one data item a module on a node declares, as placed on that node.
|
||||
type PlacedDataItem struct {
|
||||
Module string `json:"module"`
|
||||
ID string `json:"item"`
|
||||
Class string `json:"class"`
|
||||
// Path is the item's place on the node; CoveredBy the path whose snapshot keeps it (the item, or the item its dump
|
||||
// writes into), empty when it is not backed up.
|
||||
Path string `json:"path"`
|
||||
CoveredBy string `json:"covered_by,omitempty"`
|
||||
// Protection is backup, redundancy, both or none; Measure walk, dataset or shallow.
|
||||
Protection string `json:"protection,omitempty"`
|
||||
Measure string `json:"measure,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
dataModuleHeader = regexp.MustCompile(`^#\s*([a-z0-9][a-z0-9-]*)$`)
|
||||
dataSafePath = regexp.MustCompile(`^/[^\s'"\\$` + "`" + `]*$`)
|
||||
)
|
||||
|
||||
// ParseDataItems reads the node-backup holder's data text (`${backup:data}`: a `# <module>` line, then one line per item,
|
||||
// `item <id> <class> <path> <covered-by or -> <protection> <measure>`) into each module's items, in order. A line it does
|
||||
// not read is refused, naming the module: a path with a space or a quote is never handed to a measuring shell.
|
||||
func ParseDataItems(text string) (map[string][]PlacedDataItem, error) {
|
||||
out := map[string][]PlacedDataItem{}
|
||||
module := ""
|
||||
for _, raw := range strings.Split(text, "\n") {
|
||||
line := strings.TrimSpace(raw)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if m := dataModuleHeader.FindStringSubmatch(line); m != nil {
|
||||
module = m[1]
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "#") || module == "" {
|
||||
continue
|
||||
}
|
||||
f := strings.Fields(line)
|
||||
if len(f) < 5 || len(f) > 7 || f[0] != "item" || !dataSafePath.MatchString(f[3]) || (f[4] != "-" && !dataSafePath.MatchString(f[4])) {
|
||||
return nil, fmt.Errorf("%s declares a data line that is not read: %s", module, line)
|
||||
}
|
||||
it := PlacedDataItem{Module: module, ID: f[1], Class: f[2], Path: f[3], Measure: MeasureWalk}
|
||||
if len(f) >= 6 {
|
||||
it.Protection = f[5]
|
||||
}
|
||||
if len(f) == 7 {
|
||||
it.Measure = f[6]
|
||||
}
|
||||
if it.Measure != MeasureWalk && it.Measure != MeasureDataset && it.Measure != MeasureShallow {
|
||||
return nil, fmt.Errorf("%s declares a data line that is not read: %s", module, line)
|
||||
}
|
||||
if f[4] != "-" {
|
||||
it.CoveredBy = f[4]
|
||||
}
|
||||
out[module] = append(out[module], it)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DataItems is every data item the resolution's modules declare, placed on its node with the rendering given: the text
|
||||
// the node-backup holder is handed, read. A module left out of the node's declaration keeps its items, as its data is
|
||||
// still on the node (ADR 0262); an item that cannot be placed is said in unplaced and left out.
|
||||
func (r Resolution) DataItems(with Rendering) (items map[string][]PlacedDataItem, unplaced []string, err error) {
|
||||
left := r.LeftOut(with.Settings, with.Adopted)
|
||||
for module, why := range with.NotPlaced {
|
||||
if _, already := left[module]; !already {
|
||||
left[module] = why
|
||||
}
|
||||
}
|
||||
modules := make([]Manifest, 0, len(r.Modules))
|
||||
for _, m := range r.Modules {
|
||||
if _, isLeft := left[m.Module]; isLeft {
|
||||
m = backupView(m)
|
||||
}
|
||||
modules = append(modules, m)
|
||||
}
|
||||
text, unplaced, err := seatContributions(modules, Manifest{}, BackupSeat+":"+BackupKindData, with,
|
||||
machineFacts(r, with.Names, with.MeshRange), r.Capabilities)
|
||||
if err != nil {
|
||||
return nil, unplaced, err
|
||||
}
|
||||
items, err = ParseDataItems(text)
|
||||
return items, unplaced, err
|
||||
}
|
||||
@@ -192,6 +192,16 @@ func TestTheBackupHoldersLinesAreDerivedFromTheData(t *testing.T) {
|
||||
if data != want {
|
||||
t.Fatalf("data lines:\n%s\nwant:\n%s", data, want)
|
||||
}
|
||||
// ADR 0571 clause 3: the same text, read into items, as disk-load and the backup holder read it; a path a shell
|
||||
// would split is refused.
|
||||
items, err := ParseDataItems(data)
|
||||
if err != nil || len(items) != 3 || items["media"][0] != (PlacedDataItem{Module: "media", ID: "films", Class: "irreplaceable",
|
||||
Path: "/tank/films", Protection: "redundancy", Measure: MeasureDataset}) || items["pg"][0].CoveredBy != "/srv/store/dumps" {
|
||||
t.Fatalf("the data lines read as %+v: %v", items, err)
|
||||
}
|
||||
if _, err := ParseDataItems("# a\nitem x cache /a b - none walk\n"); err == nil {
|
||||
t.Fatal("a path with a space was read")
|
||||
}
|
||||
if _, _, err := seatContributions([]Manifest{agent}, Manifest{}, BackupSeat+":"+BackupKindData, Rendering{}, nil, nil); err == nil {
|
||||
t.Fatal("a home directory with no account on the machine reached the holder as a placeholder")
|
||||
}
|
||||
|
||||
+122
-28
@@ -221,6 +221,11 @@ type Rendering struct {
|
||||
// They win over anything the mesh would assign and over a manifest's own long-form mapping.
|
||||
Given map[string]map[int]int
|
||||
|
||||
// Planned says this rendering composes the plans the seat mesh-provisioner keeps (compose.Planning). A contribution a
|
||||
// plan carries is then refused where its port is none the module listens on: no machine port stands for it, and its
|
||||
// provider dials what the plan says (issue 774, research 072 row 34).
|
||||
Planned bool
|
||||
|
||||
// Taken is the modules taken on this adopted node (novox/hq ADR 0100). The guard is derived
|
||||
// from these only (ADR 0103): a port of a module assigned but not taken may still be the
|
||||
// predecessor's.
|
||||
@@ -277,6 +282,10 @@ type Rendering struct {
|
||||
// templated files are handed to the client (handed.go); without either, it composes as before.
|
||||
Hands bool
|
||||
Provisioner string
|
||||
// Assigned is, by module, whether the vault judged it handed (AssignmentHands of the plan in its assignment), as the
|
||||
// vault answered the node composing itself; where it names a module, Handing follows it alone (issue 774). Nil, or a
|
||||
// module it does not name, is judged by the manifest and the machine, as the composer of the plans does.
|
||||
Assigned map[string]bool
|
||||
}
|
||||
|
||||
// RootField is a user resource's field saying the account must never become root without a person
|
||||
@@ -700,13 +709,7 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
handsSecrets := with.Handing(m)
|
||||
for _, name := range sortedKeys(m.OwnSecrets.Plain()) {
|
||||
if handsSecrets {
|
||||
owner := m.SecretsOwner
|
||||
if m.Module == RuntimeModule && r.Account != "" {
|
||||
owner = r.Account
|
||||
}
|
||||
if o := m.OwnSecrets[name].Owner; o != "" {
|
||||
owner = o
|
||||
}
|
||||
owner := r.ownSecretOwner(m, name)
|
||||
first = append(first, ownedBy(owner, map[string]any{
|
||||
"id": NeedID(name), "type": "file", "path": m.OwnSecrets[name].Path, HandedSecretField: name,
|
||||
}))
|
||||
@@ -718,13 +721,7 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
if string(box) == string(ForeseenVaultBox) {
|
||||
*foreseen = append(*foreseen, m.Module+"/"+name)
|
||||
}
|
||||
owner := m.SecretsOwner
|
||||
if m.Module == RuntimeModule && r.Account != "" {
|
||||
owner = r.Account
|
||||
}
|
||||
if o := m.OwnSecrets[name].Owner; o != "" {
|
||||
owner = o
|
||||
}
|
||||
owner := r.ownSecretOwner(m, name)
|
||||
first = append(first, ownedBy(owner, map[string]any{
|
||||
"id": NeedID(name), "type": "file", "path": m.OwnSecrets[name].Path, VaultSealedField: box,
|
||||
}))
|
||||
@@ -754,14 +751,7 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
// the manifest, because a manifest cannot say ${machine:account} safely — a node with no
|
||||
// account has nothing to resolve it to, and then the runtime runs as root and the file
|
||||
// stays root's.
|
||||
owner := m.SecretsOwner
|
||||
if m.Module == RuntimeModule && r.Account != "" {
|
||||
owner = r.Account
|
||||
}
|
||||
if o := m.OwnSecrets[name].Owner; o != "" {
|
||||
owner = o
|
||||
}
|
||||
first = append(first, ownedBy(owner, map[string]any{
|
||||
first = append(first, ownedBy(r.ownSecretOwner(m, name), map[string]any{
|
||||
"id": NeedID(name), "type": "file", "path": m.OwnSecrets[name].Path, "sealed": sealed,
|
||||
}))
|
||||
}
|
||||
@@ -1084,15 +1074,22 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// **What the mesh calls the module through each provision it requires** (issue 792): the names its binding
|
||||
// file carries, composed by the same call, handed to the provider through the plan's entry.
|
||||
if _, ok := handed.plans[m.Module]; ok && handing != nil {
|
||||
// **A plan for what the seat keys of it all the same** (catalogue.Keyed, issue 774): a module not handed whose
|
||||
// provider's helper answers a provision of it, its route the commonest, has that provision in a plan of its own,
|
||||
// so the seat keys it and the helper hears it.
|
||||
keyedOnly := handing == nil && handed.keyed(r.Node, m, r.Needs)
|
||||
if _, ok := handed.plans[m.Module]; ok && (handing != nil || keyedOnly) {
|
||||
for _, to := range requirementsContributedTo(m) {
|
||||
own, err := r.ownNames(m, to, with.Settings[m.Module])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
handed.name(m.Module, to, own)
|
||||
contributed, err := r.contributedOf(m, to, with)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
handed.contribute(m.Module, to, contributed)
|
||||
}
|
||||
}
|
||||
// A handed collector is filled from items alone: each old form its collections would have held is said
|
||||
@@ -1103,6 +1100,9 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
}
|
||||
for _, unsettled := range resources {
|
||||
if pr, ok := unsettled[HandedProvisionField].(PlanProvision); ok {
|
||||
if handing == nil && m.Stubbed {
|
||||
continue // held: its credential file is what runs of it, left out (issue 1126)
|
||||
}
|
||||
if handing == nil {
|
||||
return nil, fmt.Errorf("%s: its credential of %s is handed to the provisioner and the module is "+
|
||||
"not: nothing would fill it", m.Module, pr.Provision)
|
||||
@@ -1127,6 +1127,9 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
continue
|
||||
}
|
||||
if unsettled[HandedSecretField] != nil {
|
||||
if handing == nil && m.Stubbed {
|
||||
continue // held: its own secret's file is what runs of it, left out (issue 1126)
|
||||
}
|
||||
if handing == nil {
|
||||
return nil, fmt.Errorf("%s: its own secret %v is handed to the provisioner and the module is "+
|
||||
"not: nothing would fill it", m.Module, unsettled[HandedSecretField])
|
||||
@@ -1362,8 +1365,10 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
owner[fmt.Sprint(process["id"])] = RuntimeModule
|
||||
out = append(out, process)
|
||||
if process != nil { // none for a held runtime's stand-in: what runs of it is carried
|
||||
owner[fmt.Sprint(process["id"])] = RuntimeModule
|
||||
out = append(out, process)
|
||||
}
|
||||
// And a runtime of its own for each module of its own account, after it (novox/hq ADR 0259 §8).
|
||||
owns, err := r.ownRuntimes(with)
|
||||
if err != nil {
|
||||
@@ -1388,7 +1393,7 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
words[m.Module] = w
|
||||
}
|
||||
// And a file a module's words name is one the runtime is restarted for when it changes.
|
||||
if named := givenTo(out, owner, words, r.Account); len(named) > 0 {
|
||||
if named := givenTo(out, owner, words, r.Account); len(named) > 0 && process != nil {
|
||||
restarts, _ := process["restart-on"].([]any)
|
||||
seen := map[string]bool{}
|
||||
for _, id := range restarts {
|
||||
@@ -1671,6 +1676,19 @@ func (r Resolution) provisionsAs(m Manifest) string {
|
||||
return m.SecretsOwner
|
||||
}
|
||||
|
||||
// ownSecretOwner is who one of a module's own secret files belongs to: the account the manifest names for it, else the
|
||||
// runtime's account for the runtime's own credential and for a tools bundle's bus account (ToolsBusSecret; novox/hq ADR
|
||||
// 0564 Limits 4), which the runtime runs that bundle as, else the module's secrets-owner (root when none).
|
||||
func (r Resolution) ownSecretOwner(m Manifest, name string) string {
|
||||
if o := m.OwnSecrets[name].Owner; o != "" {
|
||||
return o
|
||||
}
|
||||
if r.Account != "" && (m.Module == RuntimeModule || ToolsBusSecret(m, name)) {
|
||||
return r.Account
|
||||
}
|
||||
return m.SecretsOwner
|
||||
}
|
||||
|
||||
// grantPath is where one consumer's sealed credential lands on the providing machine.
|
||||
//
|
||||
// Suffixed, so the directory can also hold whatever the module writing it keeps there and so a
|
||||
@@ -1819,6 +1837,82 @@ func (r Resolution) contributions(settings SettingsBy, grants []Grant,
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// contributedOf is each contribution m makes to one requirement, composed as its provider receives it in a received
|
||||
// file (contributions: the one of `contributes` with what the module shares of its bucket, then each of several by
|
||||
// local name), with where m's machine is, encoded for PlanProvision.Contributed (issue 774): one composition, so a
|
||||
// plan and a file cannot say different things. Nil where m contributes nothing to it.
|
||||
//
|
||||
// **Its port is the one the machine published** (atAssignedPort): a plan is read on whatever machine its provider runs,
|
||||
// which cannot redirect the number as a received file on this machine is redirected (atMachinePort), so the plan
|
||||
// carries the machine port already. route-proxy dialled gitea's 3000 for git (published at 20000) and grafana's 3000
|
||||
// on another machine (published at 20012) until it did (2026-10-31).
|
||||
func (r Resolution) contributedOf(m Manifest, to string, with Rendering) (json.RawMessage, error) {
|
||||
layers := with.Settings[m.Module]
|
||||
form := ContributedForm{At: r.At}
|
||||
add := func(local string, values map[string]any) error {
|
||||
values, err := r.atAssignedPort(m, to, values, with)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := json.Marshal(values)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s contributing to %s: %w", m.Module, to, err)
|
||||
}
|
||||
form.Each = append(form.Each, ContributedOne{Local: local, Values: raw})
|
||||
return nil
|
||||
}
|
||||
if raw, ok := m.Contributes[to]; ok {
|
||||
values, err := r.composed(m, to, raw, layers, m.Module+" contributing to "+to)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if values, err = withShares(m, to, values); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := add("", values); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, local := range sortedKeys(m.ContributesMany[to]) {
|
||||
values, err := r.composed(m, to, m.ContributesMany[to][local], layers, m.Module+" contributing "+local+" to "+to)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := add(local, values); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if len(form.Each) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return EncodeContributed(form)
|
||||
}
|
||||
|
||||
// atAssignedPort is a contribution of m with its port where this machine publishes it for m: the number
|
||||
// `${port:<software port>}` gives m itself and its declaration publishes (machinePort, port_into.go), the placement's
|
||||
// assignment where it holds one and the software's own number where it holds none (a bare port not yet moved, a fixed
|
||||
// mapping, the host's network). What its provider dials, on this machine over loopback and from another at m's address.
|
||||
//
|
||||
// **Refused where a plan carries a port m does not listen on** (with.Planned): no machine port stands for it, so the
|
||||
// provider would dial a number nothing on the machine is known to publish. Elsewhere (a declaration, a composition
|
||||
// with no ports) such a port is left as it is, as machinePort leaves it.
|
||||
func (r Resolution) atAssignedPort(m Manifest, to string, values map[string]any, with Rendering) (map[string]any, error) {
|
||||
number, ok := asPort(values["port"])
|
||||
if !ok {
|
||||
return values, nil
|
||||
}
|
||||
_, given := with.Given[m.Module][number]
|
||||
_, placed := with.Ports[m.Module][number]
|
||||
if !given && !placed {
|
||||
if with.Planned {
|
||||
return nil, fmt.Errorf("%s contributing to %s: its port %d on %s is none of the ports it listens on, so no "+
|
||||
"machine port stands for it", m.Module, to, number, r.Node)
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
return atMachinePort(values, m.Module, map[string]map[int]int{m.Module: {number: with.machinePort(m.Module, number)}}), nil
|
||||
}
|
||||
|
||||
// composed is one contribution as its provider receives it: settled with this node's settings, its
|
||||
// endpoint's port filled in, and its names composed from the label.
|
||||
//
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package catalogue
|
||||
|
||||
import "fmt"
|
||||
|
||||
// The delivery's owner (novox/hq ADR 0239, to-be 47): one holder for the mesh, the module mesh-delivery,
|
||||
// which owns a delivery — one commit in one repository, from its pull request's head to every machine —
|
||||
// and a delivery group, and asks the controller for every act. A seat of the mesh's own, so the role has
|
||||
@@ -105,6 +107,29 @@ func SeatSays(emits []string, event string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// seatEventProblems refuses a module that lists among its own `emits` an event a seat it claims says (the lead,
|
||||
// 2026-10-29, issue 1952, the second of a kind: the self-check's `checked` and mesh-hand-acts' `act-recorded`). A
|
||||
// consumer of `<seat>.<event>` hears the seat's subject (SeatSays), so the holder says it there, with stdio.SeatPublish
|
||||
// on `mesh.seat.<seat>.event.<event>`; said through stdio.Emit as the module's own, it lands where nobody listens. Caught
|
||||
// here, when the manifest is read at the build, not when nothing is heard on the machines.
|
||||
func seatEventProblems(m Manifest) []string {
|
||||
var problems []string
|
||||
for _, c := range m.Claims {
|
||||
seat, known := SeatNamed(c.Name)
|
||||
if !known || len(seat.Emits) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, e := range m.Emits {
|
||||
if SeatSays(seat.Emits, e) {
|
||||
problems = append(problems, fmt.Sprintf("%s lists %q among its own emits, and the seat %s it claims says it: "+
|
||||
"say it on the seat with stdio.SeatPublish(\"mesh.seat.%s.event.%s\", ...) and leave it out of emits, or "+
|
||||
"no consumer of %s.%s hears it", m.Module, e, seat.Name, seat.Name, e, seat.Name, e))
|
||||
}
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// topicMatches is whether an event's name matches a declared pattern, `*` one dot-separated segment and
|
||||
// `>` or `**` the rest.
|
||||
func topicMatches(pattern, event string) bool {
|
||||
|
||||
@@ -43,6 +43,32 @@ func EngineVerbs() []Verb {
|
||||
"each hand-over, access act and setuid search, open, done or refused, with who asked, the ask and the " +
|
||||
"operator's warrant. Read-only.",
|
||||
Input: schema(map[string]string{"limit": "at most this many, newest", "id": "one act, by its id"}, nil)},
|
||||
// The packages this node's compositions declare (novox/hq ADR 0565 §6, §15): optional until every engine serves it.
|
||||
{Name: "packages", Optional: true, Description: "The packages this machine's compositions declare, present or absent, " +
|
||||
"each with the modules that declare it, as the engine keeps them in its key of `packages`; until the controller " +
|
||||
"hands its lease away, beside the controller's mirror for this machine and their difference. With write, the " +
|
||||
"key written again from what the engine composed.",
|
||||
Input: schema(map[string]string{"write": "\"true\": write this machine's key again from what the engine composed"}, nil, "write")},
|
||||
// Read-only (issue 774): why a module waits for a yes, or what its next apply would change. Optional until every
|
||||
// engine serves it.
|
||||
{Name: "composition", Optional: true, Description: "One module's composition on this machine beside what runs of " +
|
||||
"it, read-only: the composition its want's build has in the bucket composed-<node> (the latest revision, or the " +
|
||||
"one whose digest is given, from the revisions the bucket keeps), the digest of each kept revision, what runs " +
|
||||
"(applied, its have, or the kept declaration), its want and have (phase running, failed, held or waiting-yes, " +
|
||||
"and the digest that waits for a yes), and the move's judgement with its words: why a record module waits for " +
|
||||
"a yes. With diff, every resource added, removed or changed, a provider's received file naming the consumers " +
|
||||
"that join or leave.",
|
||||
Input: schema(map[string]string{"module": "the module, as placed on this machine; node for the node's own",
|
||||
"digest": "a composition's digest (sha256:…), to read that revision rather than the latest",
|
||||
"diff": "\"true\": list the resources that differ from what runs"}, []string{"module"})},
|
||||
// The recovery of a composition that failed (issue 774, 2026-10-31: ace kept route-proxy failed for a vault that
|
||||
// answered again a minute later). Optional until every engine serves it.
|
||||
{Name: "compose", Optional: true, Description: "Compose this machine's modules now and say how it went, so a " +
|
||||
"composition that failed, or a module held, recovers without a restart: what the pass refused or held, each " +
|
||||
"with why, and when the engine composes again on its own after a seat that did not answer (a deadline, no " +
|
||||
"responders: retried with a growing wait; a refusal never). With module, that module's apply is also tried " +
|
||||
"again. Changes only what a pass the engine makes on its own would change.",
|
||||
Input: schema(map[string]string{"module": "a module placed on this machine whose apply is also tried again (optional)"}, nil)},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+179
-6
@@ -1,6 +1,7 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
@@ -66,6 +67,16 @@ 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
|
||||
@@ -124,16 +135,91 @@ 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, as string, contributes map[string]map[string]any,
|
||||
func provisionsOf(needs []Needed, module string, handed bool, as string, contributes map[string]map[string]any,
|
||||
named map[string]bool) []PlanProvision {
|
||||
var out []PlanProvision
|
||||
for _, n := range needs {
|
||||
if n.For != module || n.ByRecord || n.Module == "" || n.From == "" {
|
||||
// **The one rule** (Keyed, issue 774): what the seat keys and the want waits for.
|
||||
if n.For != module || !Keyed(handed, n) {
|
||||
continue
|
||||
}
|
||||
p := PlanProvision{Provision: n.Name, Local: n.Local, Provider: n.From, ProviderModule: n.Module,
|
||||
@@ -283,16 +369,43 @@ func handedBy(m Manifest, archive Built) *Handed {
|
||||
return h
|
||||
}
|
||||
|
||||
// handing is whether this composition hands the module's templated files to the provisioner.
|
||||
// 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.
|
||||
func (with Rendering) handing(m Manifest) bool {
|
||||
return m.Moved && m.Handed != nil && with.Hands && with.Provisioner != ""
|
||||
return with.Handing(m) && m.Handed != nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (with Rendering) Handing(m Manifest) bool { return with.handing(m) }
|
||||
//
|
||||
// **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
|
||||
}
|
||||
|
||||
// requiredByWords begins why a module only pulled in by another is on a machine (Resolution.Because): `required by
|
||||
// <module>`.
|
||||
@@ -589,6 +702,56 @@ 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.
|
||||
@@ -768,7 +931,8 @@ 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.identities[module], h.contributes[module], h.required[module])
|
||||
provisions := provisionsOf(needs, module, !h.keyedOnly[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
|
||||
@@ -784,6 +948,15 @@ 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
|
||||
}
|
||||
|
||||
@@ -42,9 +42,10 @@ 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}},
|
||||
Manifest{Module: "store", Version: "1", Provides: []Offer{{Name: "postgres-database", Scope: ScopeMesh,
|
||||
Credential: &OfferCredential{Form: CredentialFormPair}}},
|
||||
Serves: map[string]map[string]any{"postgres-database": {"port": 5432}}},
|
||||
Manifest{Module: "store-two", Version: "1", Provides: []Offer{{Name: "postgres-database", Scope: ScopeMesh}},
|
||||
Manifest{Module: "store-two", Version: "1", Provides: []Offer{{Name: "postgres-database", Scope: ScopeMesh, Credential: &OfferCredential{Form: CredentialFormPair}}},
|
||||
Serves: map[string]map[string]any{"postgres-database": {"port": 5434}}})
|
||||
r, err := Resolve(stores, []string{m.Module}, node, w)
|
||||
if err != nil {
|
||||
@@ -130,7 +131,10 @@ func TestAHandedModulesPlanNamesTheCredentialFormOfItsProvidersOffer(t *testing.
|
||||
return mustParse(t, raw+`}`)
|
||||
}
|
||||
for credential, want := range map[string]string{
|
||||
``: CredentialFormPair,
|
||||
// No default (the lead, 2026-10-29): an offer that says nothing has no form, and the catalogue refuses it.
|
||||
``: "",
|
||||
`,"credential":{"form":"pair"}`: CredentialFormPair,
|
||||
`,"credential":{"form":"none"}`: CredentialFormNone,
|
||||
`,"credential":{"own":"admin"}`: CredentialFormShared,
|
||||
`,"credential":{"own":"token"}`: CredentialFormGiven,
|
||||
`,"credential":{"issued-by":"outside"}`: CredentialFormGiven,
|
||||
@@ -149,10 +153,29 @@ func TestAHandedModulesPlanNamesTheCredentialFormOfItsProvidersOffer(t *testing.
|
||||
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)
|
||||
}
|
||||
// Every form a plan names is in the one list each reader checks (issue 774); an undeclared one is not.
|
||||
if IsCredentialForm(want) != (want != "") {
|
||||
t.Errorf("the form %q of an offer with %q: in CredentialForms %v", want, credential, IsCredentialForm(want))
|
||||
}
|
||||
}
|
||||
if forms := strings.Join(CredentialForms(), ","); forms != "shared,pair,issued,given,none" || IsCredentialForm("maybe") {
|
||||
t.Errorf("the credential forms are %s, and maybe is one: %v", forms, IsCredentialForm("maybe"))
|
||||
}
|
||||
if got := (Manifest{Module: "x"}).CredentialFormOf("postgres-database"); got != "" {
|
||||
t.Errorf("a module offering nothing has the credential form %q", got)
|
||||
}
|
||||
// The catalogue refuses an offer saying no form, and a form beside an own secret or not pair or none.
|
||||
for credential, refused := range map[string]string{``: "says no credential form", `,"credential":{}`: "says no credential form",
|
||||
`,"credential":{"form":"pair","own":"admin"}`: "beside an own secret", `,"credential":{"form":"maybe"}`: `not "pair" or "none"`,
|
||||
`,"credential":{"form":"none"}`: ""} {
|
||||
raw := `{"module":"store","version":"1","provides":[{"name":"postgres-database","scope":"mesh"` + credential + `}]}`
|
||||
var m Manifest
|
||||
_ = json.Unmarshal([]byte(raw), &m)
|
||||
problems := strings.Join(CredentialFormProblems(m), "; ")
|
||||
if (refused == "") != (problems == "") || !strings.Contains(problems, refused) {
|
||||
t.Errorf("%q: %q, want %q", credential, problems, refused)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A consumer's plan entry carries what its manifest contributes to that provision (issue 776), so the provider's
|
||||
@@ -164,7 +187,9 @@ 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"}}}
|
||||
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"}}]}`)}}
|
||||
if got := c.Plans["notify"].Provisions; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("the plan names %+v, want %+v", got, want)
|
||||
}
|
||||
|
||||
@@ -65,6 +65,15 @@ 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") {
|
||||
@@ -99,6 +108,9 @@ 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 {
|
||||
@@ -185,6 +197,22 @@ func TestAHandedModulesCredentialIssuedForItIsNamedInItsPlanAndNoValueIsSealed(t
|
||||
if !reflect.DeepEqual(c.Plans["bot"].Provisions, want) {
|
||||
t.Fatalf("the plan names %+v, want %+v", c.Plans["bot"].Provisions, want)
|
||||
}
|
||||
// Issue 1126 (novox's seed: "gitea: its credential of postgres-database is handed to the provisioner and the module
|
||||
// is not"): held, the module is composed as its stub, with no template and no item, so nothing is handed. Its
|
||||
// credential and its own secret are what runs of it, left out, and the composition is not refused.
|
||||
r := forgeReader(t, true, true)
|
||||
for i := range r.Modules {
|
||||
if r.Modules[i].Module == "bot" {
|
||||
h := *r.Modules[i].Handed
|
||||
h.Templates = nil
|
||||
r.Modules[i].Handed, r.Modules[i].Stubbed, r.Modules[i].Build = &h, true, nil
|
||||
r.Modules[i].Resources = slices.DeleteFunc(r.Modules[i].Resources, func(x map[string]any) bool { return x["type"] != "directory" })
|
||||
}
|
||||
}
|
||||
c, got = composeWith(t, r, handingTo("store:5000"))
|
||||
if strings.Contains(got, SecretID("forge-read")) || strings.Contains(got, NeedID("token")) || c.Plans["bot"].Provisions != nil {
|
||||
t.Fatalf("a held module's handed credential or own secret is composed: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACredentialIssuedForAHolderIsSealedAsBeforeWhereItsModuleIsNotHanded(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package catalogue
|
||||
|
||||
// **The mesh's health, out of the controller** (novox/hq ADR 0564 clause 1, issue 1952): four seats at mesh scope, each
|
||||
// held once, each holder a process on its own bus credential that writes only its own state.
|
||||
//
|
||||
// - mesh-conditions, held by the module mesh-conditions (the condition keeper): the one writer of the open conditions,
|
||||
// raised from the observers' findings and cleared when they go (clauses 2, 3, 9, 13);
|
||||
// - mesh-watchdogs, held by the watchman (mesh-delivery-watchman): the signals as keys that expire, its stuck keys and
|
||||
// presence as findings, and the dead letters from the lease write (clauses 4, 5);
|
||||
// - mesh-self-check, held by the module mesh-self-check: the probes, every five minutes (clause 6);
|
||||
// - mesh-healers, held by the module mesh-healers: the healers, acting on the keeper's events (clauses 7, 8).
|
||||
//
|
||||
// Their events keep the controller's names and bodies (go/health), so a reader changes only the seat it hears.
|
||||
|
||||
// The four health seats.
|
||||
const (
|
||||
ConditionsSeat = "mesh-conditions"
|
||||
WatchdogsSeat = "mesh-watchdogs"
|
||||
SelfCheckSeat = "mesh-self-check"
|
||||
HealersSeat = "mesh-healers"
|
||||
)
|
||||
|
||||
// healthDecision is the record that made the four seats.
|
||||
const healthDecision = "novox/hq ADR 0564"
|
||||
|
||||
// conditionsVerbs are the condition keeper's verbs (ADR 0564 clauses 3, 9, 12).
|
||||
func conditionsVerbs() []Verb {
|
||||
return []Verb{
|
||||
{Name: "conditions", Description: "What is wrong with the mesh now: every open condition, urgent first, then oldest, " +
|
||||
"raised from the observers' findings (the watchman, the self-check, the healers, the assigner) and cleared when " +
|
||||
"the finding goes, never by hand. Given a key, that one whole with its evidence; with history, every transition " +
|
||||
"lately, the controller's before the take-over included.",
|
||||
Input: schema(map[string]string{
|
||||
"key": "a condition's key: that one whole; with history, only its transitions",
|
||||
"scope": "only this scope: machine, plan, call, build, merge, provider, seat, bus, core, probe, mesh, delivery or module",
|
||||
"severity": "only urgent, or only warning",
|
||||
"machine": "only those about this machine",
|
||||
"history": "\"true\": every raising, change, silence and clearing lately, oldest first",
|
||||
"days": "with history: how many days back (default 7, at most 90)",
|
||||
}, nil, "history")},
|
||||
{Name: "silence", Description: "Stop one condition's messages for a while, a hand act with why: it stays open and in " +
|
||||
"status, and the silence is kept as its transition in history.",
|
||||
Input: schema(map[string]string{
|
||||
"key": "the condition's key",
|
||||
"for": "how long: 30m, 4h, 2d; at most 7d",
|
||||
"why": "why, required and recorded",
|
||||
"cause": "the cause in a word (the condition's kind when absent)",
|
||||
}, []string{"key", "for", "why"})},
|
||||
{Name: "compare", Description: "The keeper's open conditions against the controller's: every key open in one and not " +
|
||||
"the other, or open in both with another severity, each with its reason; the kinds that end with the controller " +
|
||||
"and the keys held while their module is delivered listed apart and not counted. The take-over waits for it empty.",
|
||||
Input: schema(map[string]string{}, nil)},
|
||||
{Name: "take-over", Description: "Ask the operator's Approve to switch who the router, the grants part and " +
|
||||
"mesh-watcher hear from the controller to the keeper; refused while compare shows a difference. On the warrant " +
|
||||
"the keeper writes its take-over key.",
|
||||
Input: schema(map[string]string{"why": "why now, shown to the operator"}, []string{"why"})},
|
||||
{Name: "hand-back", Description: "Switch the readers back to the controller's conditions, until the controller's " +
|
||||
"lease is handed away; refused after it.",
|
||||
Input: schema(map[string]string{"why": "why, kept with the switch"}, []string{"why"})},
|
||||
{Name: "resync", Description: "Read every observer's findings again and raise and clear what differs from the open " +
|
||||
"conditions: the recovery of a keeper that missed a change.",
|
||||
Input: schema(map[string]string{}, nil)},
|
||||
}
|
||||
}
|
||||
|
||||
// watchdogsVerbs are the watchman's verbs (ADR 0564 clauses 4, 5, 12).
|
||||
func watchdogsVerbs() []Verb {
|
||||
return []Verb{
|
||||
{Name: "signals", Description: "Every watchdog of the signals table the watchman keeps, each a key that expires unless " +
|
||||
"its signal renews it: its row, what renews it, its bound, the age of its newest signal, and the finding its " +
|
||||
"expiry wrote.",
|
||||
Input: schema(map[string]string{"row": "one row, e.g. S1"}, nil)},
|
||||
{Name: "stuck", Description: "Every want, build or step the watchman says stopped moving past its bound, with since " +
|
||||
"when and the words of its finding.",
|
||||
Input: schema(map[string]string{"machine": "only those on this machine"}, nil)},
|
||||
{Name: "presence", Description: "Every machine and tool runner the watchman sees connected, with its last word, and " +
|
||||
"those it says silent; a machine that said it sleeps is shown as sleeping, not silent.",
|
||||
Input: schema(map[string]string{"machine": "one machine"}, nil)},
|
||||
{Name: "dead-letters", Description: "Every message a consumer on the bus gave up on, kept in DEAD_LETTERS: whose " +
|
||||
"consumer, the subject, how often it was handed over and when it was given up, newest first. With id: that one " +
|
||||
"whole. With deliver or drop, a hand act with why, taken only once the controller's lease is handed away; " +
|
||||
"before, refused naming the controller's verb.",
|
||||
Input: schema(map[string]string{
|
||||
"id": "a dead letter's id, as the list gives it: that one whole, with its message",
|
||||
"consumer": "a consumer's name, or <stream>.<consumer>: only what that one gave up on",
|
||||
"limit": "how many to list, newest first (default 50); only when listing",
|
||||
"deliver": "a dead letter's id: deliver it again to the consumer that gave it up (needs why)",
|
||||
"drop": "a dead letter's id: drop it for good (needs why)",
|
||||
"why": "with deliver or drop: why, required and recorded",
|
||||
"cause": "with deliver or drop: the cause in a word (dead-letter when absent)",
|
||||
}, nil)},
|
||||
{Name: "resync", Description: "Rebuild presence from the bus's connections, and the pulses and findings from the keys " +
|
||||
"the watchman watches, and take every dead-letter notice waiting: the recovery of a watchman that missed a change.",
|
||||
Input: schema(map[string]string{}, nil)},
|
||||
}
|
||||
}
|
||||
|
||||
// selfCheckVerbs are the self-check's verbs (ADR 0564 clause 6).
|
||||
func selfCheckVerbs() []Verb {
|
||||
return []Verb{
|
||||
{Name: "doctor", Description: "The self-check: the last run's verdict at once, each probe passed, failed, deferred " +
|
||||
"(its facts not on the bus yet, named) or could not run, and how long ago. With run, a run now; with probe, one " +
|
||||
"probe's verdict whole. Runs every five minutes on its own; each failure is a finding.",
|
||||
Input: schema(map[string]string{
|
||||
"run": "\"true\": run every probe now and answer the verdict",
|
||||
"probe": "one probe's id (D6): its verdict alone, with every finding",
|
||||
}, nil, "run")},
|
||||
{Name: "probes", Description: "The registry: what each probe asserts, where it reads its facts, and the finding it writes.",
|
||||
Input: schema(map[string]string{}, nil)},
|
||||
}
|
||||
}
|
||||
|
||||
// healersVerbs are the healers' verbs (ADR 0564 clauses 7, 8).
|
||||
func healersVerbs() []Verb {
|
||||
return []Verb{
|
||||
{Name: "healers", Description: "The healers: each registered response to one kind of condition, its repair, its " +
|
||||
"budget and what happens when it is spent; every act taken lately with its outcome, the acts it would have " +
|
||||
"taken while the controller's lease names a controller, and the mesh-wide brake.",
|
||||
Input: schema(map[string]string{"days": "how many days of acts back (default 7)"}, nil)},
|
||||
{Name: "heal", Description: "Take one healer's act for one open condition now, within its budget and the brake.",
|
||||
Input: schema(map[string]string{
|
||||
"key": "the condition's key",
|
||||
"why": "why now, kept with the act",
|
||||
}, []string{"key", "why"})},
|
||||
{Name: "drill", Description: "Record a drill: something broken on purpose to see the mesh raise and clear it, with " +
|
||||
"what it tests. A drill is never a repair: no healer is wanted for it however often it is run.",
|
||||
Input: schema(map[string]string{
|
||||
"what": "what was done on purpose, in a line",
|
||||
"why": "what the drill tests",
|
||||
"condition": "the key of the condition the drill is meant to raise, if any (optional)",
|
||||
}, []string{"what", "why"})},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package catalogue
|
||||
|
||||
import "slices"
|
||||
|
||||
// The one rule of which provisions the seat mesh-provisioner keys and a want waits for (novox/hq ADR 0570 §1, §2, §4;
|
||||
// issue 774). The plan composition puts a need in a consumer's plan by it, the holder of mesh-provisioner keys what the
|
||||
// plans name, and the assigner's rule 8 waits for what Awaited answers: three parts, one rule, so they cannot drift
|
||||
// again (on 2026-10-30 the assigner waited for every `requires` while the plans named only some, and every new build of
|
||||
// grafana, dnsmasq, mesh-knowledge, mesh-mail and the builders waited for a key nobody would make).
|
||||
//
|
||||
// A need a module answers (not a record, not nothing) is keyed when its consumer is handed to the provisioner, or the
|
||||
// provider's offer says the provider helper answers it. A need of a consumer not handed whose provider's helper does
|
||||
// not answer it is still answered by the controller's composition: it is neither keyed nor waited for.
|
||||
|
||||
// HandedByManifest is whether a module is handed to the provisioner as far as its manifest says (Rendering.Handing
|
||||
// without the machine's own words, and begin's): marked moved, not a held module's stub, with a template its build
|
||||
// kept or an item.
|
||||
func (m Manifest) HandedByManifest() bool {
|
||||
return m.Moved && m.Handed != nil && !m.Stubbed && (len(m.Handed.Templates) > 0 || len(m.Items) > 0)
|
||||
}
|
||||
|
||||
// Keyed is whether a need is a provision of the seat mesh-provisioner: a key, an entry in its consumer's plan, and a
|
||||
// wait of the consumer's want. consumerHanded is whether the composition hands the consumer to the provisioner.
|
||||
func Keyed(consumerHanded bool, n Needed) bool {
|
||||
if n.ByRecord || n.Module == "" || n.From == "" {
|
||||
return false
|
||||
}
|
||||
return consumerHanded || n.AnsweredByHelper
|
||||
}
|
||||
|
||||
// Awaited is, of what consumer requires (not optionally), each provision Keyed answers yes for, judged from manifests
|
||||
// alone, as the assigner reads them: providers answers the modules providing a provision. A provision is a need when a
|
||||
// provider offers it to the mesh, or one on the consumer's machine serves facts about it (Resolve: one answered on the
|
||||
// machine with nothing served binds nothing). It is keyed when the consumer is handed, or every provider's offer is
|
||||
// answered by the helper: where the providers disagree, which one answers is the composition's, and nothing is waited
|
||||
// for that might never be keyed. Sorted, each once.
|
||||
func Awaited(consumer Manifest, providers func(provision string) []Manifest) []string {
|
||||
var out []string
|
||||
for _, r := range consumer.Requires {
|
||||
if consumer.OptionalRequires[r] || slices.Contains(out, r) {
|
||||
continue
|
||||
}
|
||||
by := providers(r)
|
||||
if len(by) == 0 {
|
||||
continue
|
||||
}
|
||||
need, helper := false, true
|
||||
for _, p := range by {
|
||||
for _, o := range p.Provides {
|
||||
if o.Name != r {
|
||||
continue
|
||||
}
|
||||
if o.At() == ScopeMesh || len(p.Serves[r]) > 0 {
|
||||
need = true
|
||||
}
|
||||
}
|
||||
if !p.AnsweredByHelperFor(r) {
|
||||
helper = false
|
||||
}
|
||||
}
|
||||
if need && (consumer.HandedByManifest() || helper) {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
slices.Sort(out)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The one rule replays the live cases of 2026-10-30 (issue 774): what the assigner's rule 8 waits for is what the
|
||||
// composition keys, judged from the manifests alone.
|
||||
func TestAwaitedIsWhatTheCompositionKeys(t *testing.T) {
|
||||
built := &Handed{Source: "store://t", Digest: "sha256:t", Templates: map[string]string{"c": "c"}}
|
||||
offer := func(module, name string, scope string, helper bool, serves bool) Manifest {
|
||||
o := Offer{Name: name, Scope: scope}
|
||||
if helper {
|
||||
o.AnsweredBy = AnsweredByHelper
|
||||
}
|
||||
m := Manifest{Module: module, Provides: []Offer{o}}
|
||||
if serves {
|
||||
m.Serves = map[string]map[string]any{name: {"port": 1}}
|
||||
}
|
||||
return m
|
||||
}
|
||||
gitea, distribution := offer("gitea", "forge-account", ScopeMesh, false, false), offer("distribution", "artifact-store", ScopeMesh, false, false)
|
||||
goTools, nodeJS := offer("go", "go-toolchain", ScopeNode, false, false), offer("nodejs", "node-toolchain", ScopeNode, false, false)
|
||||
keycloak, proxy := offer("keycloak", "oidc-client", ScopeMesh, false, false), offer("route-proxy", "route", ScopeMesh, true, false)
|
||||
adapter := offer("route-adapter", "route", ScopeMesh, false, false)
|
||||
ollama := offer("ollama", "llm", ScopeNode, false, true)
|
||||
by := func(ms ...Manifest) func(string) []Manifest {
|
||||
return func(p string) []Manifest {
|
||||
var out []Manifest
|
||||
for _, m := range ms {
|
||||
if slices.Contains(m.Offers(), p) {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
consumer Manifest
|
||||
providers func(string) []Manifest
|
||||
want []string
|
||||
}{
|
||||
{"a handed builder waits for what binds, not for a toolchain on its own machine (mesh-build-go on ace)",
|
||||
Manifest{Module: "mesh-build-go", Moved: true, Handed: built,
|
||||
Requires: []string{"forge-account", "go-toolchain", "artifact-store", "node-tools"}},
|
||||
by(gitea, distribution, goTools), []string{"artifact-store", "forge-account"}},
|
||||
{"node-toolchain alike (mesh-build-node)",
|
||||
Manifest{Module: "mesh-build-node", Moved: true, Handed: built, Requires: []string{"node-toolchain", "forge-account"}},
|
||||
by(gitea, nodeJS), []string{"forge-account"}},
|
||||
{"a module not handed waits for its route, which the helper answers, and not for its login the controller still makes (grafana on ace)",
|
||||
Manifest{Module: "grafana", Requires: []string{"route", "oidc-client", "influxdb-api"},
|
||||
OptionalRequires: map[string]bool{"influxdb-api": true}},
|
||||
by(keycloak, proxy), []string{"route"}},
|
||||
{"where providers disagree on the helper, nothing is waited for that might never be keyed",
|
||||
Manifest{Module: "grafana", Requires: []string{"route"}}, by(proxy, adapter), nil},
|
||||
{"a moved module with no template is not handed (a held stub, issue 1126)",
|
||||
Manifest{Module: "bot", Moved: true, Handed: &Handed{}, Requires: []string{"oidc-client", "route"}},
|
||||
by(keycloak, proxy), []string{"route"}},
|
||||
{"a provision on its own machine that serves facts binds, and is keyed for a handed consumer",
|
||||
Manifest{Module: "chat", Moved: true, Handed: built, Requires: []string{"llm", "llm"}}, by(ollama), []string{"llm"}},
|
||||
} {
|
||||
if got := Awaited(c.consumer, c.providers); !reflect.DeepEqual(got, c.want) {
|
||||
t.Errorf("%s: awaited %v, want %v", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A module not handed has a plan for what a provider's helper answers of it, and for nothing else (issue 774: grafana
|
||||
// had no plan, so no route key, and route-proxy served no route for it); a need the controller still answers stays out.
|
||||
func TestAModuleNotHandedHasAPlanForWhatAHelperAnswers(t *testing.T) {
|
||||
consumer := notifierConsumer(t, false, false)
|
||||
consumer.Requires = append(consumer.Requires, "route")
|
||||
consumer.Contributes = map[string]map[string]any{"route": {"host": "notify.example.org"}}
|
||||
consumer.ContributesMany = map[string]map[string]map[string]any{"route": {"api": {"label": "notify-api", "port": 9000}}}
|
||||
node := workstation()
|
||||
node.At, node.PublicDomain = "workstation.internal", "example.org"
|
||||
proxy := Manifest{Module: "proxy", Version: "1", Provides: []Offer{{Name: "route", Scope: ScopeMesh, AnsweredBy: AnsweredByHelper}}}
|
||||
store := Manifest{Module: "store", Version: "1", Provides: []Offer{{Name: "postgres-database", Scope: ScopeMesh,
|
||||
Credential: &OfferCredential{Form: CredentialFormPair}}}, Serves: map[string]map[string]any{"postgres-database": {"port": 5432}}}
|
||||
w := twoStores("anchor")
|
||||
w.Offered["route"] = []Provider{{Node: "anchor", At: "anchor.internal", Module: "proxy"}}
|
||||
r, err := Resolve(shelf(consumer, proxy, store), []string{consumer.Module}, node, w)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Account = "op"
|
||||
for i, n := range r.Needs {
|
||||
// As the plan sets it (compose's answeredByHelper).
|
||||
r.Needs[i].AnsweredByHelper = n.Name == "route"
|
||||
}
|
||||
_, before := composeWith(t, r, Rendering{})
|
||||
c, got := composeWith(t, r, handingTo("store:5000"))
|
||||
if got != before {
|
||||
t.Fatalf("the plan changed what is composed:\nbefore: %s\nnow: %s", before, got)
|
||||
}
|
||||
plan, ok := c.Plans["notify"]
|
||||
if !ok || len(plan.Provisions) != 1 || plan.Provisions[0].Provision != "route" || plan.Provisions[0].ProviderModule != "proxy" {
|
||||
t.Fatalf("the plan is %+v (%v), want the route alone", plan, ok)
|
||||
}
|
||||
if plan.Templates != (PlanTemplates{}) || plan.Files != nil || plan.Secrets != nil ||
|
||||
!strings.Contains(plan.Provisions[0].As, "notify") || plan.Provisions[0].Contributes["host"] != "notify.example.org" {
|
||||
t.Fatalf("the plan names %+v", plan)
|
||||
}
|
||||
// What it contributes, composed as a received file carried it, and where its machine is, as opaque bytes the
|
||||
// provider's own contract reads (issue 774, research 072 row 34): route-proxy serves the route from the plan alone.
|
||||
given, err := DecodeContributed(plan.Provisions[0].Contributed)
|
||||
if err != nil || given.At != "workstation.internal" || len(given.Each) != 2 || given.Each[0].Local != "" ||
|
||||
string(given.Each[0].Values) != `{"host":"notify.example.org"}` || given.Each[1].Local != "api" ||
|
||||
string(given.Each[1].Values) != `{"internal-name":"notify-api.anchor.internal","label":"notify-api","name":"notify-api.example.org","port":9000}` {
|
||||
t.Fatalf("the plan gives %+v (%v)", given, err)
|
||||
}
|
||||
// Its port is where the machine publishes it, the number ${port:9000} gives the module and its declaration publishes,
|
||||
// never the software's own where the placement moved it (2026-10-31: route-proxy dialled gitea's 3000, published at
|
||||
// 20000): its provider dials what the plan says. Where a plan carries it, a port the module does not listen on is
|
||||
// refused by module, node and port.
|
||||
for _, p := range []struct {
|
||||
name string
|
||||
ports map[string]map[int]int
|
||||
given map[string]map[int]int
|
||||
planned bool
|
||||
port float64
|
||||
refused string
|
||||
}{
|
||||
{"assigned by the placement (gitea 3000 at 20000)", map[string]map[int]int{"notify": {9000: 20009}}, nil, true, 20009, ""},
|
||||
{"given by the node over the placement", map[string]map[int]int{"notify": {9000: 20009}},
|
||||
map[string]map[int]int{"notify": {9000: 9900}}, true, 9900, ""},
|
||||
{"a bare published port the placement holds none for keeps its number (influxdb 8086)",
|
||||
map[string]map[int]int{"notify": {9000: 9000}}, nil, true, 9000, ""},
|
||||
{"a mapping the module fixed (as mailu 7080:80)", map[string]map[int]int{"notify": {9000: 7080}}, nil, true, 7080, ""},
|
||||
{"a port the module does not listen on", nil, nil, true, 0,
|
||||
"notify contributing to route: its port 9000 on workstation is none of the ports it listens on"},
|
||||
{"a declaration with no ports leaves it as written", nil, nil, false, 9000, ""},
|
||||
} {
|
||||
with := handingTo("store:5000")
|
||||
with.Ports, with.Given, with.Planned = p.ports, p.given, p.planned
|
||||
c, err := r.Compose(with)
|
||||
if p.refused != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), p.refused) {
|
||||
t.Errorf("%s: composed with %v, want refused with %q", p.name, err, p.refused)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("%s: %v", p.name, err)
|
||||
continue
|
||||
}
|
||||
given, err := DecodeContributed(c.Plans["notify"].Provisions[0].Contributed)
|
||||
var api map[string]any
|
||||
if err == nil {
|
||||
err = json.Unmarshal(given.Each[1].Values, &api)
|
||||
}
|
||||
if err != nil || api["port"] != p.port || given.At != "workstation.internal" {
|
||||
t.Errorf("%s: the plan gives %v at %q (%v), want port %v", p.name, api, given.At, err, p.port)
|
||||
}
|
||||
}
|
||||
// Read strictly: a form the SDK does not write is refused, whatever its contributions say.
|
||||
for raw, refused := range map[string]string{
|
||||
`{"v":2,"each":[{"values":{}}]}`: "version 2",
|
||||
`{"v":1,"each":[]}`: "no contribution",
|
||||
`{"v":1,"each":[{"values":{}}],"route":{}}`: "unknown field",
|
||||
`{"v":1,"each":[{"values":{}},{"values":{}}]}`: "twice",
|
||||
`{"v":1,"each":[{"local":"api","values":["a"]}]}`: "not a JSON object",
|
||||
`{"v":1,"each":[{"values":{}}]} {}`: "more than one",
|
||||
`{"v":1,"at":"10.0.0.2","each":[{"values":{"x":true}}]}`: "",
|
||||
} {
|
||||
_, err := DecodeContributed(json.RawMessage(raw))
|
||||
if (refused == "") != (err == nil) || err != nil && !strings.Contains(err.Error(), refused) {
|
||||
t.Errorf("DecodeContributed(%s) = %v, want %q", raw, err, refused)
|
||||
}
|
||||
}
|
||||
// And it hands nothing: Plan.Hands, the one rule every reader of a plan asks, by each part a plan can name.
|
||||
if plan.Hands() {
|
||||
t.Fatalf("a plan naming the route alone hands its module: %+v", plan)
|
||||
}
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
plan Plan
|
||||
hands bool
|
||||
}{
|
||||
{"empty", Plan{}, false},
|
||||
{"provisions alone", Plan{Provisions: plan.Provisions}, false},
|
||||
{"a digest without an archive", Plan{Templates: PlanTemplates{Digest: "sha256:1"}}, false},
|
||||
{"templates alone", Plan{Templates: PlanTemplates{Source: "notify.tar", Digest: "sha256:1"}}, true},
|
||||
{"files alone", Plan{Files: []PlanFile{{ID: "conf", Path: "/etc/notify.conf"}}}, true},
|
||||
{"secrets alone", Plan{Secrets: []PlanSecret{{Name: "admin"}}}, true},
|
||||
{"items alone", Plan{Items: []PlanItem{{Collection: "routes", Template: "route.tmpl"}}}, true},
|
||||
{"secrets and provisions", Plan{Secrets: []PlanSecret{{Name: "admin"}}, Provisions: plan.Provisions}, true},
|
||||
} {
|
||||
if got := c.plan.Hands(); got != c.hands {
|
||||
t.Errorf("%s: Hands() is %v, want %v", c.name, got, c.hands)
|
||||
}
|
||||
}
|
||||
if want := Awaited(consumer, func(p string) []Manifest {
|
||||
return map[string][]Manifest{"route": {proxy}, "postgres-database": {store}}[p]
|
||||
}); !reflect.DeepEqual(want, []string{"route"}) {
|
||||
t.Fatalf("the assigner waits for %v, and the plan keys the route alone", want)
|
||||
}
|
||||
}
|
||||
+20
-5
@@ -27,8 +27,15 @@ type LogDeclaration struct {
|
||||
// MaxMiB is the log's cap in MiB; zero is LogDefaultMiB. When full, the log refuses new entries
|
||||
// and never drops old ones.
|
||||
MaxMiB int `json:"max-mib,omitempty"`
|
||||
// MaxAgeDays is how long an entry is kept, in days; zero is as long as the log (novox/hq ADR 0297). The one way an
|
||||
// entry leaves a log: the server ages it out, never a delete or a purge (novox/hq ADR 0565 §10: the hand-act log
|
||||
// keeps every act 400 days; issue 1953 S-age).
|
||||
MaxAgeDays int `json:"max-age-days,omitempty"`
|
||||
}
|
||||
|
||||
// LogMostAgeDays is the longest a log may keep an entry by age: past it, a log keeps its entries as long as it is.
|
||||
const LogMostAgeDays = 3650
|
||||
|
||||
// Cap is the log's cap in MiB, the default where none is said.
|
||||
func (l LogDeclaration) Cap() int {
|
||||
if l.MaxMiB == 0 {
|
||||
@@ -37,20 +44,21 @@ func (l LogDeclaration) Cap() int {
|
||||
return l.MaxMiB
|
||||
}
|
||||
|
||||
// UnmarshalJSON reads a log as its bare name, or as {name, max-mib}.
|
||||
// UnmarshalJSON reads a log as its bare name, or as {name, max-mib, max-age-days}.
|
||||
func (l *LogDeclaration) UnmarshalJSON(raw []byte) error {
|
||||
trimmed := bytes.TrimSpace(raw)
|
||||
if len(trimmed) > 0 && trimmed[0] == '"' {
|
||||
return json.Unmarshal(trimmed, &l.Name)
|
||||
}
|
||||
var full struct {
|
||||
Name string `json:"name"`
|
||||
MaxMiB *int `json:"max-mib"`
|
||||
Name string `json:"name"`
|
||||
MaxMiB *int `json:"max-mib"`
|
||||
MaxAgeDays *int `json:"max-age-days"`
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(trimmed))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&full); err != nil {
|
||||
return fmt.Errorf("a log is either a name or {name, max-mib}: %w", typedUnknown(err))
|
||||
return fmt.Errorf("a log is either a name or {name, max-mib, max-age-days}: %w", typedUnknown(err))
|
||||
}
|
||||
l.Name = full.Name
|
||||
l.MaxMiB = 0
|
||||
@@ -61,12 +69,19 @@ func (l *LogDeclaration) UnmarshalJSON(raw []byte) error {
|
||||
}
|
||||
l.MaxMiB = *full.MaxMiB
|
||||
}
|
||||
l.MaxAgeDays = 0
|
||||
if full.MaxAgeDays != nil {
|
||||
if *full.MaxAgeDays < 1 || *full.MaxAgeDays > LogMostAgeDays {
|
||||
return fmt.Errorf("log %q: max-age-days is between 1 and %d, not %d", full.Name, LogMostAgeDays, *full.MaxAgeDays)
|
||||
}
|
||||
l.MaxAgeDays = *full.MaxAgeDays
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarshalJSON writes back the short form when there is nothing else to say.
|
||||
func (l LogDeclaration) MarshalJSON() ([]byte, error) {
|
||||
if l.MaxMiB == 0 {
|
||||
if l.MaxMiB == 0 && l.MaxAgeDays == 0 {
|
||||
return json.Marshal(l.Name)
|
||||
}
|
||||
type plain LogDeclaration
|
||||
|
||||
@@ -24,7 +24,15 @@ func TestAManifestMaySayWhatLogsItKeeps(t *testing.T) {
|
||||
if string(out) != `["changes",{"name":"moves","max-mib":2048}]` {
|
||||
t.Fatalf("written back as %s", out)
|
||||
}
|
||||
for _, edge := range []string{`{"name":"a","max-mib":1}`, `{"name":"a","max-mib":8192}`} {
|
||||
// 1953 S-age (ADR 0565 §10): a log may keep its entries for an age, written back with it.
|
||||
aged, err := ParseManifest([]byte(`{"module":"mesh-hand-acts","version":"1","logs":[{"name":"acts","max-age-days":400}]}`))
|
||||
if err != nil || aged.Logs[0].MaxAgeDays != 400 {
|
||||
t.Fatalf("an aged log read as %+v, %v", aged.Logs, err)
|
||||
}
|
||||
if out, _ := json.Marshal(aged.Logs); string(out) != `[{"name":"acts","max-age-days":400}]` {
|
||||
t.Fatalf("an aged log written back as %s", out)
|
||||
}
|
||||
for _, edge := range []string{`{"name":"a","max-mib":1}`, `{"name":"a","max-mib":8192}`, `{"name":"a","max-age-days":3650}`} {
|
||||
if _, err := ParseManifest([]byte(`{"module":"a","version":"1","logs":[` + edge + `]}`)); err != nil {
|
||||
t.Errorf("%s is inside the caps and was refused: %v", edge, err)
|
||||
}
|
||||
@@ -42,7 +50,8 @@ func TestALogIsNamedLocallyAndCapped(t *testing.T) {
|
||||
{`{"module":"a","version":"1","logs":[{"name":"c","max-mib":8193}]}`, `between 1 and 8192 MiB`},
|
||||
{`{"module":"a","version":"1","logs":[{"name":"c","max-mib":-1}]}`, `between 1 and 8192 MiB`},
|
||||
{`{"module":"a","version":"1","logs":[{"name":"c","max-mib":0}]}`, `max-mib is between 1 and 8192, not 0`},
|
||||
{`{"module":"a","version":"1","logs":[{"name":"c","stream":"LOG_x"}]}`, `{name, max-mib}`},
|
||||
{`{"module":"a","version":"1","logs":[{"name":"c","stream":"LOG_x"}]}`, `{name, max-mib, max-age-days}`},
|
||||
{`{"module":"a","version":"1","logs":[{"name":"c","max-age-days":0}]}`, `max-age-days is between 1 and 3650, not 0`},
|
||||
{`{"module":"a.b","version":"1","logs":["c"]}`, `no dot`},
|
||||
} {
|
||||
_, err := ParseManifest([]byte(c.manifest))
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -287,6 +288,10 @@ type OfferCredential struct {
|
||||
Own string `json:"own,omitempty"`
|
||||
IssuedBy string `json:"issued-by,omitempty"`
|
||||
Tool string `json:"tool,omitempty"`
|
||||
// Form says, for an offer that names no own secret and no issuer, which it is: CredentialFormPair, a login of
|
||||
// each consumer's own, or CredentialFormNone, no credential at all (the lead, 2026-10-29, for novox/hq ADR 0570):
|
||||
// never left to a default.
|
||||
Form string `json:"form,omitempty"`
|
||||
}
|
||||
|
||||
// IssuedBySoftware says a provision's credential is issued by its provider's software (novox/hq ADR 0305).
|
||||
@@ -362,8 +367,22 @@ const (
|
||||
// CredentialFormGiven is given by a person (novox/hq ADR 0337): the offer's credential, or the own secret it
|
||||
// shares, is issued outside the mesh; the mesh never makes it.
|
||||
CredentialFormGiven = "given"
|
||||
// CredentialFormNone is an offer whose consumers present no credential: its outcome is the provider's have, and
|
||||
// the vault mints nothing for it.
|
||||
CredentialFormNone = "none"
|
||||
)
|
||||
|
||||
// CredentialForms is the one list of the credential forms a plan's entry may name (issue 774): every part that reads
|
||||
// a plan checks a form here, so a form added above reaches each of them at once. Each part kept its own copy before,
|
||||
// and a copy left without "none" refused every plan naming it.
|
||||
func CredentialForms() []string {
|
||||
return []string{CredentialFormShared, CredentialFormPair, CredentialFormIssued, CredentialFormGiven,
|
||||
CredentialFormNone}
|
||||
}
|
||||
|
||||
// IsCredentialForm is whether form is one of CredentialForms.
|
||||
func IsCredentialForm(form string) bool { return slices.Contains(CredentialForms(), form) }
|
||||
|
||||
// CredentialFormOf is the credential form this module's offer of a provision declares, read from the offer's
|
||||
// `credential` alone (issue 768): no new manifest field, since that field already says whether the provider shares
|
||||
// one of its own secrets, its software issues it, or a person gives it, and saying none is a login per consumer.
|
||||
@@ -375,7 +394,7 @@ func (m Manifest) CredentialFormOf(provision string) string {
|
||||
}
|
||||
switch c := o.Credential; {
|
||||
case c == nil:
|
||||
return CredentialFormPair
|
||||
return "" // undeclared: refused at the catalogue (CredentialFormProblems), never read as a pair
|
||||
case c.IssuedBy == IssuedOutside:
|
||||
return CredentialFormGiven
|
||||
case c.IssuedBy == IssuedBySoftware:
|
||||
@@ -385,11 +404,33 @@ func (m Manifest) CredentialFormOf(provision string) string {
|
||||
case c.Own != "":
|
||||
return CredentialFormShared
|
||||
}
|
||||
return CredentialFormPair
|
||||
return o.Credential.Form
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// CredentialFormProblems refuses an offer that does not say its credential's form (the lead, 2026-10-29): an own
|
||||
// secret, an issuer, or `form` pair or none, and `form` only where neither of the others is said.
|
||||
func CredentialFormProblems(m Manifest) []string {
|
||||
var problems []string
|
||||
for _, o := range m.Provides {
|
||||
c := o.Credential
|
||||
switch {
|
||||
case c == nil || (c.Own == "" && c.IssuedBy == "" && c.Form == ""):
|
||||
problems = append(problems, fmt.Sprintf("%s provides %q and says no credential form: say `credential` with "+
|
||||
"`form` pair (a login of each consumer's own) or none (no credential), or the own secret or issuer it is",
|
||||
m.Module, o.Name))
|
||||
case c.Form != "" && (c.Own != "" || c.IssuedBy != ""):
|
||||
problems = append(problems, fmt.Sprintf("%s provides %q with `form` beside an own secret or an issuer; `form` "+
|
||||
"says only pair or none", m.Module, o.Name))
|
||||
case c.Form != "" && c.Form != CredentialFormPair && c.Form != CredentialFormNone:
|
||||
problems = append(problems, fmt.Sprintf("%s provides %q with the form %q, not %q or %q", m.Module, o.Name,
|
||||
c.Form, CredentialFormPair, CredentialFormNone))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// ProvisionsSharing is every provision of this module whose credential is the named own secret.
|
||||
func (m Manifest) ProvisionsSharing(own string) []string {
|
||||
var out []string
|
||||
@@ -870,6 +911,11 @@ type Manifest struct {
|
||||
// path its manifest leaves to the mesh, a binding). Nil for any other module. Never in a manifest's JSON.
|
||||
OwnsPaths []string `json:"-"`
|
||||
|
||||
// Stubbed marks a module composed as its stub (compose.Stub: held on its node, issue 1126): none of its own
|
||||
// composition is applied, so what it hands the provisioner (a credential the vault keeps for it, its own secrets)
|
||||
// is left out of its plan as it runs, never refused for want of a plan. Never in a manifest's JSON.
|
||||
Stubbed bool `json:"-"`
|
||||
|
||||
// Jails are the fail2ban jails this module declares for its own service (novox/hq to-be 31).
|
||||
// Written into whichever node runs the module, the same way `listens` become that node's rules.
|
||||
Jails []Jail `json:"jails,omitempty"`
|
||||
@@ -1006,6 +1052,8 @@ type Bundle struct {
|
||||
// Binary is the executable a bundle compiled to a binary is, at its root (novox/hq ADR 0193):
|
||||
// what runs it, where an interpreted bundle names an interpreter and an entrypoint.
|
||||
Binary string `json:"binary,omitempty"`
|
||||
// Bus is the artifact's bus account, kept on the resolved manifest so the grant composition reads it (BusAccounts).
|
||||
Bus *ProcessBus `json:"bus,omitempty"`
|
||||
}
|
||||
|
||||
// Build says how to produce this module's artifacts from its source.
|
||||
@@ -1143,6 +1191,10 @@ type Artifact struct {
|
||||
// delivered version. A bundle that carried the package's name was delivered correctly, reported
|
||||
// success, and was invisible to the launcher (novox/hq 04-ISSUES/142).
|
||||
Binary string `json:"binary,omitempty"`
|
||||
// Bus is a tools bundle's own bus account (novox/hq ADR 0564 Limits 4; the lead, 2026-10-29): read as a process's
|
||||
// (ProcessBus), a principal named after the bundle's binary (BusAccounts), its secret broker-<binary>. A tools bundle
|
||||
// is run by the node's tool runtime per call, so it declares its account here and runs no idle service for it.
|
||||
Bus *ProcessBus `json:"bus,omitempty"`
|
||||
|
||||
// URL is where an archive's upstream file is fetched from, for an archive that is a release
|
||||
// somebody else published rather than a directory of this repository (novox/hq ADR 0316 §2, §6).
|
||||
@@ -1764,6 +1816,10 @@ func (m Manifest) marshalFields() ([]byte, error) {
|
||||
return json.Marshal(keys)
|
||||
}
|
||||
|
||||
// CredentialProvisionPrefix starts the name of a provision's credential, the vault's provision beside it (novox/hq ADR
|
||||
// 0570 clause 6): `secret-<provision>`.
|
||||
const CredentialProvisionPrefix = "secret-"
|
||||
|
||||
// SecretFile is one file a module is given a credential in: the local name it goes by inside
|
||||
// the module (empty for the ordinary one-file case, where the requirement's name serves) and where.
|
||||
type SecretFile struct {
|
||||
@@ -1926,6 +1982,12 @@ func parseManifest(raw []byte, predefined PredefinedList) (Manifest, error) {
|
||||
if !name.MatchString(p) {
|
||||
problems = append(problems, fmt.Sprintf("%q is not a usable name to provide", p))
|
||||
}
|
||||
// A provision's credential is a provision of its own, `secret-<provision>` (novox/hq ADR 0570 clause 6, the
|
||||
// lead 2026-10-29): no module provides a name of that form, so the two keys never meet.
|
||||
if strings.HasPrefix(p, CredentialProvisionPrefix) {
|
||||
problems = append(problems, fmt.Sprintf("%s provides %q; a name starting %q is the mesh's for a provision's "+
|
||||
"credential, kept by the vault (novox/hq ADR 0570)", m.Module, p, CredentialProvisionPrefix))
|
||||
}
|
||||
// A bound says what keeps the name, so the refusal it causes can say it (ADR 0225); and it
|
||||
// leaves room for the shortest identity the mesh makes, or it would refuse every consumer.
|
||||
if id := offer.Identity; id != nil && !id.None {
|
||||
@@ -1974,6 +2036,8 @@ func parseManifest(raw []byte, predefined PredefinedList) (Manifest, error) {
|
||||
} else if offer.Credential != nil {
|
||||
own, declared := m.OwnSecrets[offer.Credential.Own]
|
||||
switch {
|
||||
case offer.Credential.Own == "" && offer.Credential.Form != "":
|
||||
// The form alone, pair or none (CredentialFormProblems judges it).
|
||||
case offer.Credential.Own == "":
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s provides %q with a credential that names no own secret", m.Module, p))
|
||||
@@ -2082,6 +2146,7 @@ func parseManifest(raw []byte, predefined PredefinedList) (Manifest, error) {
|
||||
problems = append(problems, declaredSeatProblems(m)...)
|
||||
problems = append(problems, RunsAsProblems(m)...)
|
||||
problems = append(problems, ProcessBusProblems(m)...)
|
||||
problems = append(problems, seatEventProblems(m)...)
|
||||
problems = append(problems, ReadsHolderProblems(m)...)
|
||||
problems = append(problems, FilesHolderProblems(m)...)
|
||||
if m.Computed != "" && len(m.Resources) > 0 {
|
||||
@@ -3313,6 +3378,11 @@ func whileStoppedProblems(m Manifest, r map[string]any, scheduled bool) []string
|
||||
|
||||
// shipsABundle says the module builds a tools bundle, which the node's runtime carries and speaks for.
|
||||
func (m Manifest) shipsABundle() bool {
|
||||
// A built manifest says its bundles in Bundles, its build read away (Resolve; issue 774: nats's built manifest was
|
||||
// refused for the emits its tools bundle says).
|
||||
if len(m.Bundles) > 0 {
|
||||
return true
|
||||
}
|
||||
if m.Build == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -39,7 +39,10 @@ func placementVerbs() []Verb {
|
||||
"history, one node's or one module's changes in order: a revision the state keeps whole, an older one as the " +
|
||||
"proposal's changes from its placement-changed event.",
|
||||
Input: schema(map[string]string{"node": "one node", "module": "one module: the nodes it is placed on",
|
||||
"history": "\"true\": the changes in order, from the stream and the state's revisions"}, nil, "history")},
|
||||
"history": "\"true\": the changes in order, from the stream and the state's revisions",
|
||||
// ADR 0571 clause 4: who holds which seat on which node, from the state seat-holders.
|
||||
"seats": "\"true\": who holds each seat, and on which node (the state seat-holders); with node, that node's"},
|
||||
nil, "history", "seats")},
|
||||
{Name: PlacementVerbProposals, Optional: true, Description: "Every open proposal of placement: its changes, " +
|
||||
"the revision of each key it was laid over, its candidates, every node's answer, the differences computed and " +
|
||||
"answered, its verdict and its ask. Narrowed to one by id.",
|
||||
|
||||
+187
-1
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -29,6 +30,191 @@ type ProcessBus struct {
|
||||
// Invokes are the tools and seat verbs this process calls, each <module>.<tool> or a role's
|
||||
// seat:<seat>.<verb> (as Manifest.Invokes), or * for every tool on the mesh.
|
||||
Invokes []string `json:"invokes,omitempty"`
|
||||
// Removes are what a tools bundle's account may remove from the bus, from a closed set (BusRemovals; novox/hq ADR
|
||||
// 0564 Limits 4): declared alone, by the module holding mesh-broker, never by a process.
|
||||
Removes []string `json:"removes,omitempty"`
|
||||
}
|
||||
|
||||
// What a bus account may remove (novox/hq ADR 0564 Limits 4), each its own grant: the controller's consumer on
|
||||
// DEAD_LETTER_NOTICES (the lease write's step, clause 5), and keys of the node-engine's acts bucket. A further consumer
|
||||
// or bucket is a change of this set, through a decision.
|
||||
const (
|
||||
RemovesControllerDeadLetterConsumer = "DEAD_LETTER_NOTICES.controller"
|
||||
RemovesEngineActs = "mesh-host_acts"
|
||||
// RemovesAssignmentsStream is the stream ASSIGNMENTS, removed whole at the lease write (novox/hq research 072 row 33):
|
||||
// its 350 messages have no consumer, and the placement is the provisioner's from then on.
|
||||
RemovesAssignmentsStream = "ASSIGNMENTS"
|
||||
)
|
||||
|
||||
// BusRemovals is the closed set of what a bus account may remove.
|
||||
var BusRemovals = []string{RemovesControllerDeadLetterConsumer, RemovesEngineActs, RemovesAssignmentsStream}
|
||||
|
||||
// BusAccount is one account of a module's own beside its `broker`: a process's (ADR 0436) or a tools bundle's (ADR 0564
|
||||
// Limits 4), by its id (the process resource's, or the bundle's binary), with what its bus says.
|
||||
type BusAccount struct {
|
||||
ID string
|
||||
Bus ProcessBus
|
||||
// Tools says it is a tools bundle's, run by the node's tool runtime, not a process the node runs.
|
||||
Tools bool
|
||||
}
|
||||
|
||||
// BusAccounts is every own bus account of the module, processes first in resource order, then tools bundles in artifact
|
||||
// order: what the grant composition composes a principal for, each delivered in its own secret broker-<id>. A tools
|
||||
// bundle's is read from the repository's build, or from the resolved manifest's bundles.
|
||||
func (m Manifest) BusAccounts() []BusAccount {
|
||||
var out []BusAccount
|
||||
buses := m.ProcessBuses()
|
||||
for _, r := range m.Resources {
|
||||
id := fmt.Sprint(r["id"])
|
||||
if b, ok := buses[id]; ok && fmt.Sprint(r["type"]) == "process" {
|
||||
out = append(out, BusAccount{ID: id, Bus: b})
|
||||
}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
if m.Build != nil {
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a.Bus != nil && a.Kind == ArtifactBundle && BinaryOf(a) != "" && !seen[BinaryOf(a)] {
|
||||
seen[BinaryOf(a)] = true
|
||||
out = append(out, BusAccount{ID: BinaryOf(a), Bus: *a.Bus, Tools: true})
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, b := range m.Bundles {
|
||||
if b.Bus != nil && b.Binary != "" && !seen[b.Binary] {
|
||||
seen[b.Binary] = true
|
||||
out = append(out, BusAccount{ID: b.Binary, Bus: *b.Bus, Tools: true})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ToolsBusSecret says name is the own secret a tools bundle's bus account is held in: `broker-<binary>` for a bundle
|
||||
// whose bus the manifest declares (ADR 0564 Limits 4). Its file belongs to the runtime's account, which runs the bundle.
|
||||
func ToolsBusSecret(m Manifest, name string) bool {
|
||||
for _, a := range m.BusAccounts() {
|
||||
if a.Tools && name == "broker-"+a.ID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// toolsSecretProblems refuses a tools bundle's bus account secret the runtime's account could not read: it is declared
|
||||
// as an own secret `broker-<binary>`, at `${dir:<id>}/broker-<binary>` for a directory resource every parent of which the
|
||||
// runtime's account may pass (passableDir); the file itself is 0600 and the runtime account's.
|
||||
func toolsSecretProblems(m Manifest) []string {
|
||||
var problems []string
|
||||
for _, a := range m.BusAccounts() {
|
||||
if !a.Tools {
|
||||
continue
|
||||
}
|
||||
name := "broker-" + a.ID
|
||||
own, declared := m.OwnSecrets.Plain()[name]
|
||||
if !declared {
|
||||
problems = append(problems, fmt.Sprintf("%s's tools bundle %s declares bus and no own secret %s to hold its "+
|
||||
"account in (novox/hq ADR 0564 Limits 4)", m.Module, a.ID, name))
|
||||
continue
|
||||
}
|
||||
dir, _, _ := strings.Cut(strings.TrimPrefix(own.Path, "${dir:"), "}")
|
||||
if !strings.HasPrefix(own.Path, "${dir:"+dir+"}/") || !passableDir(m, dir, 0) {
|
||||
problems = append(problems, fmt.Sprintf("%s's own secret %s is at %s, which the node's tool runtime cannot "+
|
||||
"reach: a tools bundle's account lies in a directory resource the runtime's account may pass, every parent "+
|
||||
"included, such as {\"id\":\"tools-secret\",\"type\":\"directory\",\"path\":\"%s/%s-tools\",\"mode\":\"0711\"} "+
|
||||
"with the secret at ${dir:tools-secret}/%s; never under the module's own or mesh place, which are its "+
|
||||
"own (novox/hq ADR 0564 Limits 4)", m.Module, name, own.Path, meshStateRoot, m.Module, name))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// meshStateRoot is the mesh's directory of every module's mesh place on a node of the default data root: root's, mode
|
||||
// 0755, so the runtime's account passes it, while each module's own place beneath it is 0700.
|
||||
const meshStateRoot = defaultDataRoot + "/" + placeMesh
|
||||
|
||||
// passableDir says the runtime's account may pass the directory resource id and every parent the mesh makes for it: its
|
||||
// own mode lets others pass (the last digit odd), and it is either stated directly under meshStateRoot or beneath a
|
||||
// placed directory that is passable in turn. A pathless directory resolves under the module's own root or its mesh
|
||||
// place, which are the module's own, so it never is.
|
||||
func passableDir(m Manifest, id string, depth int) bool {
|
||||
if depth > 4 {
|
||||
return false
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if fmt.Sprint(r["type"]) != "directory" || fmt.Sprint(r["id"]) != id {
|
||||
continue
|
||||
}
|
||||
mode, _ := r["mode"].(string)
|
||||
if mode == "" || !strings.ContainsAny(mode[len(mode)-1:], "1357") {
|
||||
return false
|
||||
}
|
||||
path, _ := r["path"].(string)
|
||||
switch {
|
||||
case strings.HasPrefix(path, "${dir:"):
|
||||
parent, _, _ := strings.Cut(strings.TrimPrefix(path, "${dir:"), "}")
|
||||
return passableDir(m, parent, depth+1)
|
||||
case strings.HasPrefix(path, "/"):
|
||||
return filepathDir(strings.TrimRight(path, "/")) == meshStateRoot
|
||||
}
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func filepathDir(p string) string {
|
||||
if i := strings.LastIndex(p, "/"); i > 0 {
|
||||
return p[:i]
|
||||
}
|
||||
return "/"
|
||||
}
|
||||
|
||||
// toolsBusProblems is what is wrong with a tools bundle's bus (ADR 0564 Limits 4): a bus on anything but a bundle
|
||||
// compiled to a binary the runtime loads, a removal outside the closed set, or a removal beside any other grant or by a
|
||||
// module that does not hold mesh-broker. Every other field is judged as a process's.
|
||||
func toolsBusProblems(m Manifest) []string {
|
||||
var problems []string
|
||||
var artifacts []Artifact
|
||||
if m.Build != nil {
|
||||
artifacts = m.Build.Artifacts
|
||||
}
|
||||
claimsBroker := false
|
||||
for _, c := range m.Claims {
|
||||
claimsBroker = claimsBroker || c.Name == "mesh-broker"
|
||||
}
|
||||
for _, a := range artifacts {
|
||||
if a.Bus == nil {
|
||||
continue
|
||||
}
|
||||
if a.Kind != ArtifactBundle || BinaryOf(a) == "" {
|
||||
problems = append(problems, fmt.Sprintf("%s's artifact %s declares bus, and only a tools bundle compiled to a "+
|
||||
"binary has an account of its own (novox/hq ADR 0564 Limits 4)", m.Module, a.Name))
|
||||
continue
|
||||
}
|
||||
b := *a.Bus
|
||||
if len(b.Queues) > 0 || b.Asks {
|
||||
problems = append(problems, fmt.Sprintf("%s's tools bundle %s's bus consumes a queue or reads asks; a tools "+
|
||||
"bundle runs per call and holds no consumer (novox/hq ADR 0564 Limits 4)", m.Module, a.Name))
|
||||
}
|
||||
for _, r := range b.Removes {
|
||||
if !slices.Contains(BusRemovals, r) {
|
||||
problems = append(problems, fmt.Sprintf("%s's tools bundle %s's bus removes %q, which is not one of %s "+
|
||||
"(novox/hq ADR 0564 Limits 4)", m.Module, a.Name, r, strings.Join(BusRemovals, ", ")))
|
||||
}
|
||||
}
|
||||
if len(b.Removes) > 0 && (!claimsBroker || len(b.Emits)+len(b.State)+len(b.Invokes) > 0) {
|
||||
problems = append(problems, fmt.Sprintf("%s's tools bundle %s's bus removes from the bus; only the bus "+
|
||||
"module's (the holder of mesh-broker) may, and with nothing else (novox/hq ADR 0564 Limits 4)", m.Module, a.Name))
|
||||
}
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if fmt.Sprint(r["type"]) != "process" {
|
||||
continue
|
||||
}
|
||||
if b, present, err := processBusOf(r); present && err == nil && len(b.Removes) > 0 {
|
||||
problems = append(problems, fmt.Sprintf("%s's process %v's bus removes from the bus; only a tools bundle's "+
|
||||
"may (novox/hq ADR 0564 Limits 4)", m.Module, r["id"]))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// UnmarshalJSON reads a process's bus strictly: an unknown key — serves, a seat to hold or claim, or
|
||||
@@ -87,7 +273,7 @@ func (m Manifest) ProcessBuses() map[string]ProcessBus {
|
||||
// §3): the account rule is modelled on RunsAsProblems (seats_declared.go:522-550), and an invoked tool or
|
||||
// seat verb's shape on invokeProblems.
|
||||
func ProcessBusProblems(m Manifest) []string {
|
||||
var problems []string
|
||||
problems := append(toolsBusProblems(m), toolsSecretProblems(m)...)
|
||||
streams := map[string]bool{}
|
||||
for _, s := range m.Streams {
|
||||
streams[s.Name] = true
|
||||
|
||||
@@ -47,6 +47,65 @@ func TestAProcessDeclaresItsBusAndIsAccepted(t *testing.T) {
|
||||
!b.Asks || !strSliceEqual(b.Invokes, []string{"seat:operator-channel.tell"}) {
|
||||
t.Fatalf("the bus read as %+v", b)
|
||||
}
|
||||
|
||||
// 1952 S5 (novox/hq ADR 0564 Limits 4; the lead, 2026-10-29): a tools bundle declares its own bus account, read as a
|
||||
// process's, named after its binary and kept through resolution; a removal is from the closed set, alone, and the
|
||||
// bus module's (mesh-broker) alone.
|
||||
toolsIn := func(claims, bus, mode, where string) string {
|
||||
return `{"module":"nats","version":"1","claims":` + claims + `,"own-secrets":{"broker":"${dir:mesh-state}/broker",` +
|
||||
`"broker-nats-tools":"${dir:tools-secret}/broker-nats-tools"},"resources":[{"id":"mesh-state","type":"directory",` +
|
||||
`"mode":"0700","place":"mesh","unknown":"program"},{"id":"tools-secret","type":"directory","mode":"` + mode +
|
||||
`","path":"` + where + `","unknown":"program"}],"build":{"artifacts":[{"name":"tools","kind":"bundle",` +
|
||||
`"language":"go","system":"arch","from":"cmd/nats-tools","binary":"nats-tools","loads":["nats-tools"],"bus":` + bus + `}]}}`
|
||||
}
|
||||
tools := func(claims, bus string) string { return toolsIn(claims, bus, "0711", "/var/lib/mesh/nats-tools") }
|
||||
broker := `[{"name":"mesh-broker","scope":"mesh"}]`
|
||||
for _, c := range []struct {
|
||||
name, manifest, refused string
|
||||
}{
|
||||
{name: "the bus module's tools removing", manifest: tools(broker, `{"removes":["DEAD_LETTER_NOTICES.controller","mesh-host_acts","ASSIGNMENTS"]}`)},
|
||||
{name: "a removal outside the set", manifest: tools(broker, `{"removes":["EVENTS.a_m"]}`), refused: "not one of"},
|
||||
{name: "a removal beside a grant", manifest: tools(broker, `{"removes":["mesh-host_acts"],"invokes":["nats.nats_streams"]}`), refused: "with nothing else"},
|
||||
{name: "another module's tools removing", manifest: tools(`[]`, `{"removes":["mesh-host_acts"]}`), refused: "only the bus module"},
|
||||
{name: "a tools bundle consuming a queue", manifest: tools(broker, `{"queues":["x"]}`), refused: "holds no consumer"},
|
||||
{name: "a process removing", manifest: mailSenderProcessManifest(t, `{"removes":["mesh-host_acts"]}`), refused: "only a tools bundle"},
|
||||
// The runtime's account reads the account's secret: it lies where others may pass (the host agent, 2026-10-29).
|
||||
{name: "a tools secret the runtime cannot reach", manifest: toolsIn(broker, `{"removes":["mesh-host_acts"]}`, "0700",
|
||||
"/var/lib/mesh/nats-tools"), refused: "cannot reach"},
|
||||
// Its parents too: under the module's mesh place, /var/lib/mesh/nats is 0700 (the lead, read on novox 2026-10-29).
|
||||
{name: "a tools secret beneath the module's mesh place", manifest: toolsIn(broker, `{"removes":["mesh-host_acts"]}`, "0711",
|
||||
"${dir:mesh-state}/tools"), refused: "cannot reach"},
|
||||
} {
|
||||
m, err := ParseManifest([]byte(c.manifest))
|
||||
if c.refused != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), c.refused) {
|
||||
t.Errorf("%s: %v, want refused saying %q", c.name, err, c.refused)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", c.name, err)
|
||||
}
|
||||
accounts := m.BusAccounts()
|
||||
if len(accounts) != 1 || accounts[0].ID != "nats-tools" || !accounts[0].Tools ||
|
||||
!strSliceEqual(accounts[0].Bus.Removes, []string{RemovesControllerDeadLetterConsumer, RemovesEngineActs, RemovesAssignmentsStream}) {
|
||||
t.Errorf("%s: the accounts are %+v", c.name, accounts)
|
||||
}
|
||||
// Its secret's file is the runtime account's, as the runtime's own credential is; the module's other secrets stay
|
||||
// its own.
|
||||
if (Resolution{Account: "ops"}).ownSecretOwner(m, "broker-nats-tools") != "ops" ||
|
||||
(Resolution{Account: "ops"}).ownSecretOwner(m, "broker") != "" || (Resolution{}).ownSecretOwner(m, "broker-nats-tools") != "" {
|
||||
t.Errorf("%s: the tools secret's owner is %q", c.name, (Resolution{Account: "ops"}).ownSecretOwner(m, "broker-nats-tools"))
|
||||
}
|
||||
// Resolved, the build goes and its bundles keep the bus (build.go).
|
||||
resolved := Manifest{Module: "nats", Bundles: []Bundle{{Name: "tools", Binary: "nats-tools", Bus: m.Build.Artifacts[0].Bus}}}
|
||||
if got := resolved.BusAccounts(); len(got) != 1 || got[0].ID != "nats-tools" {
|
||||
t.Errorf("%s: resolved, the accounts are %+v", c.name, got)
|
||||
}
|
||||
}
|
||||
if got := m.BusAccounts(); len(got) != 1 || got[0].ID != "send" || got[0].Tools {
|
||||
t.Errorf("the process's account is %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func strSliceEqual(a, b []string) bool {
|
||||
|
||||
@@ -263,6 +263,10 @@ type Needed struct {
|
||||
// then name `${bound:<provision>:<key>}` and `${secret:<provision>}`, which its machine's client fills from the
|
||||
// provider's entry and the vault. Set by the plan, which reads the provider's manifest.
|
||||
ProviderMoved bool
|
||||
// AnsweredByHelper is set when the providing module's offer says the provider helper answers it (issue 792): a
|
||||
// provision the seat mesh-provisioner keys whether or not its consumer is handed (Keyed). Set by the plan, which
|
||||
// reads the provider's manifest.
|
||||
AnsweredByHelper bool
|
||||
}
|
||||
|
||||
// Refusal is why a set of assignments cannot become a declaration.
|
||||
|
||||
+22
-4
@@ -124,7 +124,10 @@ func (r Resolution) runtimeProcess(with Rendering) (map[string]any, error) {
|
||||
runtime = &r.Modules[i]
|
||||
}
|
||||
}
|
||||
if runtime == nil {
|
||||
if runtime == nil || runtime.Stubbed {
|
||||
// Held (its stand-in, Stubbed): its process is never applied, what runs of it is carried, and its stand-in is
|
||||
// never refused by a rule about its bundle or its broker (issue 774: node-tools held for a build not done, then
|
||||
// "its build produced 0 bundle(s)", refused every module of ace, g14 and shanks).
|
||||
return nil, nil
|
||||
}
|
||||
if len(runtime.Bundles) != 1 {
|
||||
@@ -240,6 +243,16 @@ func (r Resolution) runtimeProcess(with Rendering) (map[string]any, error) {
|
||||
return process, nil
|
||||
}
|
||||
|
||||
// NoRuntimeToRunError is a module of its own account with no runtime program on its node to run it from. Held says the
|
||||
// runtime is there and held (its stand-in, Stubbed): the module cannot compose because the runtime does not, so a
|
||||
// composition that holds the runtime holds it too, never the node (issue 774).
|
||||
type NoRuntimeToRunError struct {
|
||||
Held bool
|
||||
said string
|
||||
}
|
||||
|
||||
func (e *NoRuntimeToRunError) Error() string { return e.said }
|
||||
|
||||
// OwnRuntimeID names the process a module of its own account is served by (novox/hq ADR 0259 §8).
|
||||
func OwnRuntimeID() string { return "own-runtime" }
|
||||
|
||||
@@ -250,7 +263,11 @@ func OwnRuntimeID() string { return "own-runtime" }
|
||||
func (r Resolution) ownRuntimes(with Rendering) ([]map[string]any, error) {
|
||||
var own []Manifest
|
||||
for _, m := range r.Modules {
|
||||
if m.RunsAs != "" && !(with.Adopted && m.Filtering != nil) {
|
||||
// A held module's stand-in (Stubbed) has no runtime of its own composed, and so none of this function's rules
|
||||
// about one: none of its composition is applied, and what runs of it is carried as it runs. Refusing it here
|
||||
// refused its whole node (issue 774, 2026-10-30 21:04: mesh-self-check, held for its broker not made, then
|
||||
// "runs as its own account and declares no own secret broker", failed 74 modules on ace and 79 on novox).
|
||||
if m.RunsAs != "" && !m.Stubbed && !(with.Adopted && m.Filtering != nil) {
|
||||
own = append(own, m)
|
||||
}
|
||||
}
|
||||
@@ -264,8 +281,9 @@ func (r Resolution) ownRuntimes(with Rendering) ([]map[string]any, error) {
|
||||
}
|
||||
}
|
||||
if runtime == nil || len(runtime.Bundles) != 1 || runtime.Bundles[0].Binary == "" {
|
||||
return nil, fmt.Errorf("%s runs as its own account in a runtime of its own, and %s is not here to run it "+
|
||||
"from: assign %s to %s first (novox/hq ADR 0259)", own[0].Module, RuntimeModule, RuntimeModule, r.Node)
|
||||
return nil, &NoRuntimeToRunError{Held: runtime != nil && runtime.Stubbed, said: fmt.Sprintf("%s runs as its own "+
|
||||
"account in a runtime of its own, and %s is not here to run it from: assign %s to %s first (novox/hq ADR 0259)",
|
||||
own[0].Module, RuntimeModule, RuntimeModule, r.Node)}
|
||||
}
|
||||
program := runtime.Bundles[0]
|
||||
var out []map[string]any
|
||||
|
||||
@@ -155,7 +155,9 @@ var stateKeyName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
|
||||
|
||||
// ReadsGranted is the state a holder reads because modules on its machine offer it in pieces of a
|
||||
// kind its seat receives as data (novox/hq ADR 0260), each with the one key the piece shows — the
|
||||
// machine's own when it names none — sorted and once each. The bus grants the holder these keys and
|
||||
// machine's own when it names none — sorted and once each. A module's items of such a kind (design 57 L3) grant
|
||||
// what they show too, always at the machine's own key; an item whose capability the machine lacks grants a key
|
||||
// that is never written, which is no wider than its own machine's. The bus grants the holder these keys and
|
||||
// no others: the holder names no contributor, and reads only what is offered to it where it runs.
|
||||
func ReadsGranted(holder Manifest, onMachine []Manifest, machine string) []StateRead {
|
||||
seen := map[StateRead]bool{}
|
||||
@@ -183,6 +185,21 @@ func ReadsGranted(holder Manifest, onMachine []Manifest, machine string) []State
|
||||
}
|
||||
}
|
||||
}
|
||||
// A moved contributor's items of the kind (design 57 L3): what each shows, read from its attributes as a
|
||||
// contribution's data, and always this machine's own key (ADR 0260), never another's (issue 774:
|
||||
// power's draw block became an item and i3status-rust was issued nothing).
|
||||
for _, it := range m.Items {
|
||||
if it.Collection != "contribution:"+s.Name+":"+r.Kind {
|
||||
continue
|
||||
}
|
||||
for _, sr := range r.Shape.Reads(itemData(it)) {
|
||||
sr.Key = machine
|
||||
if module, _, err := ReadState(sr.Read); err == nil && module == m.Module && module != holder.Module &&
|
||||
stateKeyName.MatchString(sr.Key) {
|
||||
seen[sr] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -199,6 +216,17 @@ func ReadsGranted(holder Manifest, onMachine []Manifest, machine string) []State
|
||||
return out
|
||||
}
|
||||
|
||||
// itemData is an item's attributes in the shape of a contribution's data: each at the top and among the options, where
|
||||
// a shape's Reads looks for what a piece shows (barBlockReads: shows, options.state).
|
||||
func itemData(it Item) map[string]any {
|
||||
data, options := map[string]any{}, map[string]any{}
|
||||
for k, v := range it.Attributes {
|
||||
data[k], options[k] = v, v
|
||||
}
|
||||
data["options"] = options
|
||||
return data
|
||||
}
|
||||
|
||||
// wholeIn is v as a whole number within [lo, hi]; JSON gives every number as a float.
|
||||
func wholeIn(v any, lo, hi int) (int, bool) {
|
||||
f, ok := v.(float64)
|
||||
|
||||
@@ -394,6 +394,16 @@ func TestAStateBlockIsRenderedForThisMachineAndTheHolderIsGrantedToReadIt(t *tes
|
||||
if got := ReadsGranted(barHolder(), []Manifest{barHolder()}, "laptop"); len(got) != 0 {
|
||||
t.Fatalf("a holder alone is granted %v", got)
|
||||
}
|
||||
// Issue 774 (design 57 L3): the block as an item grants the same read, at this machine's own key alone, even where
|
||||
// the item names another machine's.
|
||||
for _, attrs := range []map[string]string{{"shows": "state", "state": "power.draw"}, {"shows": "state", "state": "power.draw", "key": "other"}} {
|
||||
moved := Manifest{Module: "power", State: []StateDeclaration{{Name: "draw"}}, Items: []Item{{
|
||||
Collection: "contribution:node-bar:block", Name: "power-draw", IfCapability: "power-meter", Attributes: attrs}}}
|
||||
if got := ReadsGranted(barHolder(), []Manifest{barHolder(), moved}, "laptop"); len(got) != 1 ||
|
||||
got[0] != (StateRead{Read: "power.draw", Key: "laptop"}) {
|
||||
t.Errorf("the holder is granted %v by the item %v", got, attrs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAModuleOffersItsOwnStateOnly(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A seat verb a module invokes is one some holder serves (issue 774, sweep item 1). messenger invoked
|
||||
// `seat:node-engine.root-free`, which nothing in the mesh answered, so every ask that offers an approval was held on
|
||||
// Telegram, silently. A manifest names what it calls (novox/hq ADR 0152); that a seat by that name exists, promises
|
||||
// that verb, and has a holder is only known over the whole catalogue, so it is judged here, beside the seats' other
|
||||
// rules (CatalogueProblems).
|
||||
|
||||
// UnheldSeat ends the problem of a `seat:<seat>.<verb>` whose seat no module given claims: over the whole catalogue a
|
||||
// refusal, over some manifests alone a seat whose holder was not given (IsUndeclaredSeat says both).
|
||||
const UnheldSeat = "which no module claims, so nothing answers it"
|
||||
|
||||
// SeatInvoke splits an invoke `seat:<seat>.<verb>` into its seat and verb; ok is false for anything else (a module's
|
||||
// tool, `*`).
|
||||
func SeatInvoke(invoke string) (seat, verb string, ok bool) {
|
||||
rest, isSeat := strings.CutPrefix(invoke, "seat:")
|
||||
if !isSeat {
|
||||
return "", "", false
|
||||
}
|
||||
seat, verb, ok = strings.Cut(rest, ".")
|
||||
return seat, verb, ok && seat != "" && verb != ""
|
||||
}
|
||||
|
||||
// seatInvokeProblems are the `seat:<seat>.<verb>` a module or one of its processes invokes that nothing answers: a seat
|
||||
// no module declares and the mesh does not define, a verb the seat does not promise, or a seat with no holder (a
|
||||
// seat a module declares and no module claims; a mesh seat's holder, the mesh itself or a module of any repository,
|
||||
// is not judged).
|
||||
func seatInvokeProblems(shelf Shelf, declared map[string]SeatDeclaration) []string {
|
||||
claimed := map[string]bool{}
|
||||
for _, m := range shelf {
|
||||
for _, c := range m.Claims {
|
||||
claimed[c.Name] = true
|
||||
if s, ok := SeatNamed(c.Name); ok {
|
||||
claimed[s.Name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
var problems []string
|
||||
for _, module := range shelfOrder(shelf) {
|
||||
m := shelf[module]
|
||||
for _, invoke := range invokesOf(m) {
|
||||
seat, verb, ok := SeatInvoke(invoke)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var verbs []Verb
|
||||
held := claimed[seat]
|
||||
if s, isMesh := SeatNamed(seat); isMesh {
|
||||
// A mesh seat is held by the mesh itself or by a module of any repository: one repository's catalogue
|
||||
// cannot say it has no holder, so only its verbs are judged (the lead, 2026-10-30: mesh-control's check
|
||||
// runs over the captured mesh-catalog alone, whose mesh-self-check calls the watchman's seat).
|
||||
verbs, held = s.Serves, true
|
||||
} else if d, isModule := declared[seat]; isModule {
|
||||
verbs = d.Serves
|
||||
} else {
|
||||
problems = append(problems, fmt.Sprintf("%s invokes %s, a verb of the seat %q, "+UndeclaredSeat, module, invoke, seat))
|
||||
continue
|
||||
}
|
||||
if !slices.ContainsFunc(verbs, func(v Verb) bool { return v.Name == verb }) {
|
||||
problems = append(problems, fmt.Sprintf("%s invokes %s, and the seat %s serves no verb %q", module, invoke, seat, verb))
|
||||
continue
|
||||
}
|
||||
if !held {
|
||||
problems = append(problems, fmt.Sprintf("%s invokes %s, a verb of the seat %q, "+UnheldSeat, module, invoke, seat))
|
||||
}
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// invokesOf is everything a module says it invokes: its own and each process's bus, once each.
|
||||
func invokesOf(m Manifest) []string {
|
||||
out := append([]string(nil), m.Invokes...)
|
||||
for _, r := range m.Resources {
|
||||
if fmt.Sprint(r["type"]) != "process" {
|
||||
continue
|
||||
}
|
||||
if _, present := r["bus"]; !present {
|
||||
continue
|
||||
}
|
||||
if b, _, err := processBusOf(r); err == nil {
|
||||
out = append(out, b.Invokes...)
|
||||
}
|
||||
}
|
||||
slices.Sort(out)
|
||||
return slices.Compact(out)
|
||||
}
|
||||
+27
-1
@@ -245,13 +245,25 @@ var defaultSeats = append([]Seat{
|
||||
// The owner of every config value (novox/hq ADR 0348 §9, design 57 K3): its holder alone writes the buckets
|
||||
// mesh-config_values, _resolved, _predefined and _proposals, and serves the one verb `config` (§1, §10).
|
||||
// Optional until the mesh-config that runs serves it (design 33 §7).
|
||||
{Name: "mesh-config", Scope: ScopeMesh, Decision: "novox/hq ADR 0348",
|
||||
{Name: "mesh-config", Scope: ScopeMesh, Decision: "novox/hq ADR 0348, ADR 0565",
|
||||
Serves: []Verb{
|
||||
{Name: "config", Optional: true, Description: "Set, clear, list or read the history of config values: " +
|
||||
"mesh config or node config for a predefined key, or a module's own value on one node, each set " +
|
||||
"with why, validated, and naming each module on each node whose value moves. A trusted value only " +
|
||||
"from the controller's terminal.",
|
||||
Input: configInput()},
|
||||
// The switch of config to its own sources (novox/hq ADR 0565 §4): optional until every mesh-config serves it.
|
||||
{Name: "compare", Optional: true, Description: "Every difference between config's own sources and the " +
|
||||
"controller's records, each with its reason: a module's declared config against the controller's mirror, " +
|
||||
"the indexer's predefined list against the mirror's, and each open pull request's config candidate against " +
|
||||
"what the controller told. The take-over waits for none.",
|
||||
Input: schema(map[string]string{}, nil)},
|
||||
{Name: "take-over", Optional: true, Description: "Ask the operator's Approve to switch config to its own " +
|
||||
"sources (the indexer); refused while compare shows a difference.",
|
||||
Input: schema(map[string]string{"why": "why, in a sentence"}, []string{"why"})},
|
||||
{Name: "hand-back", Optional: true, Description: "Switch config back to the controller's mirrors, until the " +
|
||||
"controller hands its lease away; refused after it.",
|
||||
Input: schema(map[string]string{"why": "why, in a sentence"}, []string{"why"})},
|
||||
}},
|
||||
// The provisioner's server (novox/hq ADR 0349 clause 3, design 57 P4): its holder keeps the registry of which
|
||||
// domain answers which kind of placeholder, and the buckets mesh-provisioner_needs, _applied and _collect,
|
||||
@@ -280,6 +292,20 @@ var defaultSeats = append([]Seat{
|
||||
{Name: PlacementSeat, Scope: ScopeMesh, Serves: placementVerbs(), Decision: "novox/hq ADR 0567"},
|
||||
// The delivery's checker (novox/hq ADR 0547 §10, issue 774): the try is its verb, not the controller's.
|
||||
{Name: CheckerSeat, Scope: ScopeMesh, Serves: checkerVerbs(), Decision: "novox/hq ADR 0547"},
|
||||
// The mesh's health, out of the controller (novox/hq ADR 0564 clause 1, health_seats.go): the condition keeper, the
|
||||
// watchman's watchdogs, the self-check and the healers.
|
||||
{Name: ConditionsSeat, Scope: ScopeMesh, Decision: healthDecision,
|
||||
Emits: []string{"condition-raised", "condition-changed", "condition-cleared"}, Serves: conditionsVerbs()},
|
||||
{Name: WatchdogsSeat, Scope: ScopeMesh, Decision: healthDecision, Serves: watchdogsVerbs()},
|
||||
{Name: SelfCheckSeat, Scope: ScopeMesh, Decision: healthDecision, Emits: []string{"checked"}, Serves: selfCheckVerbs()},
|
||||
{Name: HealersSeat, Scope: ScopeMesh, Decision: healthDecision, Emits: []string{"healer-acted"}, Serves: healersVerbs()},
|
||||
// The control records, out of the controller (novox/hq ADR 0565 clause 1, control_records_seats.go): a person's
|
||||
// policy and the bus step at the assigner, and the hand-act log.
|
||||
{Name: WantsSeat, Scope: ScopeMesh, Decision: controlRecordsDecision, Emits: []string{"policy-changed"}, Serves: wantsVerbs()},
|
||||
{Name: HandActsSeat, Scope: ScopeMesh, Decision: controlRecordsDecision, Emits: []string{"act-recorded"}, Serves: handActsVerbs()},
|
||||
// The artifact sweep (novox/hq ADR 0571 clause 2, artifact_sweep_seat.go).
|
||||
{Name: ArtifactSweepSeat, Scope: ScopeMesh, Decision: "novox/hq ADR 0571", Emits: []string{"artifacts-gone"},
|
||||
Serves: artifactSweepVerbs()},
|
||||
// Deferred renames (novox/hq ADR 0121): these deliver a provision, so renaming them is a
|
||||
// delivering-seat migration with a mesh-wide cascade if a holder stops resolving mid-flight.
|
||||
// They keep their names until that migration is done deliberately, apart from the node-* pass.
|
||||
|
||||
@@ -272,7 +272,9 @@ type Shelf map[string]Manifest
|
||||
const UndeclaredSeat = "which no module declares and the mesh does not define"
|
||||
|
||||
// IsUndeclaredSeat says whether a problem CatalogueProblems gave is a seat no manifest given declares.
|
||||
func IsUndeclaredSeat(problem string) bool { return strings.HasSuffix(problem, UndeclaredSeat) }
|
||||
func IsUndeclaredSeat(problem string) bool {
|
||||
return strings.HasSuffix(problem, UndeclaredSeat) || strings.HasSuffix(problem, UnheldSeat)
|
||||
}
|
||||
|
||||
func CatalogueProblems(shelf Shelf) []string {
|
||||
var problems []string
|
||||
@@ -282,6 +284,12 @@ func CatalogueProblems(shelf Shelf) []string {
|
||||
problems = append(problems, RestrictedHearingProblems(shelf)...)
|
||||
// A work-queue stream is never asked for by another module's consumes (novox/hq ADR 0436 §6).
|
||||
problems = append(problems, WorkQueueConsumedElsewhereProblems(shelf)...)
|
||||
// Every offer says its credential's form, never left to a default (the lead, 2026-10-29, for novox/hq ADR 0570).
|
||||
for _, module := range shelfOrder(shelf) {
|
||||
problems = append(problems, CredentialFormProblems(shelf[module])...)
|
||||
// And its data in the bus's predefined forms, a stream of its own refused (the lead, 2026-10-29).
|
||||
problems = append(problems, PredefinedFormProblems(shelf[module])...)
|
||||
}
|
||||
|
||||
// Who declares what, and who declared it first.
|
||||
declaredBy := map[string]string{}
|
||||
@@ -343,6 +351,9 @@ func CatalogueProblems(shelf Shelf) []string {
|
||||
}
|
||||
}
|
||||
|
||||
// A seat verb a module invokes is one some holder serves (issue 774).
|
||||
problems = append(problems, seatInvokeProblems(shelf, declared)...)
|
||||
|
||||
// Which seats name each path (novox/hq ADR 0287): a module's seat naming one another seat names loses.
|
||||
named, pathProblems := pathSeats(shelfOrder(shelf), shelf, declaredBy)
|
||||
problems = append(problems, pathProblems...)
|
||||
|
||||
@@ -51,12 +51,43 @@ func TestTwoModulesCannotDeclareTheSameSeat(t *testing.T) {
|
||||
}
|
||||
|
||||
// Where ADR 0110's guarantee lands under a derived set: a typo is refused, not resolved to
|
||||
// nothing at runtime.
|
||||
// nothing at runtime. And a seat verb a module invokes is one a holder serves (issue 774, sweep item 1: messenger
|
||||
// invoked a verb nothing answered, and Telegram held every ask silently).
|
||||
func TestUsingASeatNobodyDeclaresIsRefused(t *testing.T) {
|
||||
shop := Manifest{Module: "shop", Uses: []string{"telegram-sendr"}}
|
||||
got := problemsFor(t, Shelf{"telegram": telegram(), "shop": shop})
|
||||
if !strings.Contains(got, "telegram-sendr") || !strings.Contains(got, "no module declares") {
|
||||
t.Fatalf("a misspelled seat was accepted: %s", got)
|
||||
unclaimed := telegram()
|
||||
unclaimed.Claims = nil
|
||||
for _, row := range []struct {
|
||||
name, want string
|
||||
shop Manifest
|
||||
seat Manifest
|
||||
note bool // over some manifests alone it is a note (IsUndeclaredSeat)
|
||||
}{
|
||||
{"a misspelled seat used", `uses the seat "telegram-sendr", ` + UndeclaredSeat,
|
||||
Manifest{Module: "shop", Uses: []string{"telegram-sendr"}}, telegram(), true},
|
||||
{"a declared verb invoked of a held seat", "", Manifest{Module: "shop", Invokes: []string{"seat:telegram-sender.status"}}, telegram(), false},
|
||||
{"a verb the seat does not promise", `the seat telegram-sender serves no verb "resend"`,
|
||||
Manifest{Module: "shop", Invokes: []string{"seat:telegram-sender.resend"}}, telegram(), false},
|
||||
{"a verb of a misspelled seat", `a verb of the seat "telegram-sendr", ` + UndeclaredSeat,
|
||||
Manifest{Module: "shop", Invokes: []string{"seat:telegram-sendr.status"}}, telegram(), true},
|
||||
{"a verb of a declared seat nobody claims", `a verb of the seat "telegram-sender", ` + UnheldSeat,
|
||||
Manifest{Module: "shop", Invokes: []string{"seat:telegram-sender.status"}}, unclaimed, true},
|
||||
{"a verb of a mesh seat whose holder lives in another repository", "",
|
||||
Manifest{Module: "shop", Invokes: []string{"seat:mesh-watchdogs.signals"}}, telegram(), false},
|
||||
{"a verb a mesh seat does not promise", `the seat mesh-controller serves no verb "root-freed"`,
|
||||
Manifest{Module: "shop", Invokes: []string{"seat:mesh-controller.root-freed"}}, telegram(), false},
|
||||
{"the controller's verb: the controller answers its seat", "",
|
||||
Manifest{Module: "shop", Invokes: []string{"seat:mesh-controller.root-free"}}, telegram(), false},
|
||||
{"the engine's verb: every node's engine holds its seat", "",
|
||||
Manifest{Module: "shop", Invokes: []string{"seat:node-engine.root-free"}}, telegram(), false},
|
||||
{"a verb the engine's seat does not promise", `the seat node-engine serves no verb "root-freed"`,
|
||||
Manifest{Module: "shop", Invokes: []string{"seat:node-engine.root-freed"}}, telegram(), false},
|
||||
} {
|
||||
got := CatalogueProblems(Shelf{"telegram": row.seat, "shop": row.shop})
|
||||
joined := strings.Join(got, "; ")
|
||||
if row.want == "" && joined != "" || row.want != "" && (len(got) != 1 || !strings.Contains(joined, row.want) ||
|
||||
IsUndeclaredSeat(got[0]) != row.note) {
|
||||
t.Errorf("%s: %s", row.name, joined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +140,7 @@ func TestAClaimMustMatchTheDeclaredScope(t *testing.T) {
|
||||
func TestTheMeshsOwnSeatsAreStillClaimable(t *testing.T) {
|
||||
// It delivers the bus, so its holder provides the bus — the rule this check now enforces.
|
||||
m := Manifest{Module: "nats",
|
||||
Provides: []Offer{{Name: "mesh-bus", Scope: ScopeMesh}},
|
||||
Provides: []Offer{{Name: "mesh-bus", Scope: ScopeMesh, Credential: &OfferCredential{Form: CredentialFormNone}}},
|
||||
Claims: []Claim{{Name: "mesh-broker", Scope: ScopeMesh}}}
|
||||
if got := problemsFor(t, Shelf{"nats": m}); got != "" {
|
||||
t.Fatalf("a mesh seat was refused by the derived check: %s", got)
|
||||
@@ -186,7 +217,7 @@ func TestTheParserDoesNotJudgeWhatOnlyTheStoreKnows(t *testing.T) {
|
||||
// A store whose bus seat delivers something this module does provide.
|
||||
UseSeats([]Seat{{Name: "mesh-broker", Scope: ScopeMesh, Delivers: "mesh-bus", Decision: "test"}})
|
||||
raw := []byte(`{"module":"a-bus","version":"1",` +
|
||||
`"provides":[{"name":"mesh-bus","scope":"mesh"}],` +
|
||||
`"provides":[{"name":"mesh-bus","scope":"mesh","credential":{"form":"none"}}],` +
|
||||
`"claims":[{"name":"mesh-broker","scope":"mesh"}]}`)
|
||||
|
||||
m, err := ParseManifest(raw)
|
||||
|
||||
@@ -64,6 +64,27 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
|
||||
if strings.Join(checkerVerbs, ",") != "try,try-read,try-end" {
|
||||
t.Errorf("the checker seat serves %v", checkerVerbs)
|
||||
}
|
||||
// The four health seats (novox/hq ADR 0564 clause 1): at mesh scope, each held once, each naming ADR 0564, with the
|
||||
// verbs and events the record names; the events keep the controller's names (clause 3).
|
||||
for _, c := range []struct{ seat, verbs, emits string }{
|
||||
{ConditionsSeat, "conditions,silence,compare,take-over,hand-back,resync", "condition-raised,condition-changed,condition-cleared"},
|
||||
{WatchdogsSeat, "signals,stuck,presence,dead-letters,resync", ""},
|
||||
{SelfCheckSeat, "doctor,probes", "checked"},
|
||||
{HealersSeat, "healers,heal,drill", "healer-acted"},
|
||||
// And mesh-config's switch (ADR 0565 §4), beside its config.
|
||||
{"mesh-config", "config,compare,take-over,hand-back", ""},
|
||||
// And ADR 0565 clause 1's two.
|
||||
{WantsSeat, "upgrade,bus", "policy-changed"},
|
||||
{HandActsSeat, "hand-act,hand-acts,warranted", "act-recorded"},
|
||||
// ADR 0571 clause 2: the artifact store's sweep.
|
||||
{ArtifactSweepSeat, "artifacts,held,collect,mirrors,images", "artifacts-gone"},
|
||||
} {
|
||||
s, ok := SeatNamed(c.seat)
|
||||
if !ok || s.Scope != ScopeMesh || s.Replicated || s.Delivers != "" || !strings.Contains(s.Decision, "ADR 05") ||
|
||||
strings.Join(VerbNames(s.Serves), ",") != c.verbs || strings.Join(s.Emits, ",") != c.emits {
|
||||
t.Errorf("the health seat %s is %v, %+v", c.seat, ok, s)
|
||||
}
|
||||
}
|
||||
// The node seat node-engine is the node's engine's (novox/hq ADR 0563 §1): at node scope, delivering nothing, its six
|
||||
// verbs and three events, held by no assignment, and a module's claim of it refused.
|
||||
if engine, ok := SeatNamed(EngineSeat); !ok || engine.Scope != ScopeNode || engine.Delivers != "" || !engine.HeldByTheEngine {
|
||||
@@ -74,10 +95,10 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
|
||||
verbs = append(verbs, v.Name)
|
||||
}
|
||||
// acts reads one act by its id too (ADR 0563 §9: the seat's tools are its command line).
|
||||
if props, _ := engine.Serves[len(engine.Serves)-1].Input["properties"].(map[string]any); props["id"] == nil {
|
||||
t.Errorf("acts takes no id: %v", engine.Serves[len(engine.Serves)-1].Input)
|
||||
if props, _ := engine.Serves[5].Input["properties"].(map[string]any); props["id"] == nil {
|
||||
t.Errorf("acts takes no id: %v", engine.Serves[5].Input)
|
||||
}
|
||||
if strings.Join(verbs, ",") != "hand-over,access-act,judge-directories,setuid-search,root-free,acts" ||
|
||||
if strings.Join(verbs, ",") != "hand-over,access-act,judge-directories,setuid-search,root-free,acts,packages,composition,compose" ||
|
||||
strings.Join(engine.Emits, ",") != "asked,done,refused" {
|
||||
t.Errorf("node-engine serves %v and emits %v", verbs, engine.Emits)
|
||||
}
|
||||
@@ -99,9 +120,11 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
|
||||
// node-container-runtime (ADR 0207); nineteen with node-environment and node-login-shell (ADR 0203,
|
||||
// ADR 0204); seventeen with node-build-agent (ADR 0190). One fewer once the retired
|
||||
// mesh-build-machine row goes, when no registered manifest claims it.
|
||||
// Forty-eight with mesh-delivery-checker (novox/hq ADR 0547 §10, issue 774); forty-nine with node-engine (novox/hq ADR 0563 §1).
|
||||
if len(Seats()) != 49 {
|
||||
t.Errorf("the mesh defines %d seats rather than 49; the set is closed, so a change here is "+
|
||||
// Forty-eight with mesh-delivery-checker (novox/hq ADR 0547 §10, issue 774); forty-nine with node-engine (novox/hq ADR 0563 §1);
|
||||
// fifty-three with the four health seats (novox/hq ADR 0564 clause 1); fifty-five with mesh-wants and mesh-hand-acts
|
||||
// (novox/hq ADR 0565 clause 1); fifty-six with mesh-artifact-sweep (novox/hq ADR 0571 clause 2).
|
||||
if len(Seats()) != 56 {
|
||||
t.Errorf("the mesh defines %d seats rather than 56; the set is closed, so a change here is "+
|
||||
"a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames())
|
||||
}
|
||||
}
|
||||
@@ -127,7 +150,7 @@ func TestTheUplinkIsANodeSeatThatDeliversNothing(t *testing.T) {
|
||||
}
|
||||
|
||||
func claimed(claims string) []byte {
|
||||
return []byte(`{"module":"thing","version":"1","provides":[{"name":"npm-package-registry","scope":"mesh"}],"claims":` + claims + `}`)
|
||||
return []byte(`{"module":"thing","version":"1","provides":[{"name":"npm-package-registry","scope":"mesh","credential":{"form":"none"}}],"claims":` + claims + `}`)
|
||||
}
|
||||
|
||||
// **The refusal moved, it did not go** (novox/hq ADR 0118, superseding 0110). A module may now
|
||||
@@ -165,6 +188,18 @@ func TestAClaimOnASeatAnotherModuleDeclaresIsAccepted(t *testing.T) {
|
||||
if problems := CatalogueProblems(Shelf{claimant.Module: claimant, "someone": declarer}); len(problems) != 0 {
|
||||
t.Fatalf("a claim on a declared seat was refused: %v", problems)
|
||||
}
|
||||
// The second of a kind (issue 1952; the lead, 2026-10-29): a holder that lists its seat's event among its own emits
|
||||
// would say it where no consumer hears it, so the manifest is refused, naming the seat's subject; the seat's
|
||||
// holder saying nothing of its own is accepted.
|
||||
holder := func(emits string) []byte {
|
||||
return []byte(`{"module":"mesh-self-check","version":"1","emits":` + emits + `,"claims":[{"name":"mesh-self-check","scope":"mesh","serves":["doctor","probes"]}]}`)
|
||||
}
|
||||
if _, err := ParseManifest(holder(`["checked"]`)); err == nil || !strings.Contains(err.Error(), "mesh.seat.mesh-self-check.event.checked") {
|
||||
t.Errorf("a seat's event listed as the module's own: %v", err)
|
||||
}
|
||||
if _, err := ParseManifest(holder(`[]`)); err != nil {
|
||||
t.Errorf("a holder saying its seat's event on the seat was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A module may define its own seat and claim it — the mesh enforces exclusivity without knowing
|
||||
|
||||
@@ -59,6 +59,10 @@ func sealedFor(m Manifest, needs []Needed, with Rendering) (map[string]string, e
|
||||
for name := range m.OwnSecrets.Plain() {
|
||||
if value := with.Needed[m.Module][name]; value != "" {
|
||||
sealed[name] = value
|
||||
} else if with.Foreseen[m.Module][name] {
|
||||
// A stand-in where its own file has one (the Foreseeing and Planning modes, issue 774): a file naming it
|
||||
// composes as its own file does, never refused for a value no such composition reads.
|
||||
sealed[name] = ForeseenSealed
|
||||
}
|
||||
}
|
||||
for _, to := range m.SecretRequirements() {
|
||||
|
||||
@@ -21,34 +21,42 @@ func fileNamed(out []map[string]any, id string) map[string]any {
|
||||
return nil
|
||||
}
|
||||
|
||||
// A module's own credential, put into a file it wrote.
|
||||
// A module's own credential, put into a file it wrote: the value sealed to the node, or, in a composition asked ahead of
|
||||
// the send or for a plan alone, the stand-in its own file has (issue 774: baserow's config.json refused ace's plans).
|
||||
func TestAFileGetsTheSecretItsContentAsksFor(t *testing.T) {
|
||||
r := Resolution{Node: "anchor", Modules: []Manifest{{
|
||||
Module: "gitea",
|
||||
OwnSecrets: OwnSecrets{"admin": {Path: "/var/lib/gitea/admin.env"}},
|
||||
Resources: []map[string]any{{
|
||||
"id": "conf", "type": "file", "path": "/etc/gitea/app.ini",
|
||||
"content": "[security]\nSECRET_KEY = ${secret:admin}\n",
|
||||
}},
|
||||
}}}
|
||||
out, err := r.Declaration(Rendering{
|
||||
Needed: map[string]map[string]string{"gitea": {"admin": "sealed-admin"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file := fileNamed(out, "gitea.conf")
|
||||
if file == nil {
|
||||
t.Fatalf("no file in %v", out)
|
||||
}
|
||||
got, _ := file["secrets"].(map[string]any)
|
||||
if got["admin"] != "sealed-admin" {
|
||||
t.Errorf("the file was sent without its secret: %v", file)
|
||||
}
|
||||
// The hole is still a hole on the wire. The mesh does not fill it in; the host does, on the
|
||||
// machine, which is the only place both halves exist.
|
||||
if !strings.Contains(file["content"].(string), "${secret:admin}") {
|
||||
t.Errorf("the mesh substituted it itself: %v", file["content"])
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
with Rendering
|
||||
want string
|
||||
}{
|
||||
{"sealed", Rendering{Needed: map[string]map[string]string{"gitea": {"admin": "sealed-admin"}}}, "sealed-admin"},
|
||||
{"foreseen", Rendering{Foreseen: map[string]map[string]bool{"gitea": {"admin": true}}}, ForeseenSealed},
|
||||
} {
|
||||
r := Resolution{Node: "anchor", Modules: []Manifest{{
|
||||
Module: "gitea",
|
||||
OwnSecrets: OwnSecrets{"admin": {Path: "/var/lib/gitea/admin.env"}},
|
||||
Resources: []map[string]any{{
|
||||
"id": "conf", "type": "file", "path": "/etc/gitea/app.ini",
|
||||
"content": "[security]\nSECRET_KEY = ${secret:admin}\n",
|
||||
}},
|
||||
}}}
|
||||
out, err := r.Declaration(row.with)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", row.name, err)
|
||||
}
|
||||
file := fileNamed(out, "gitea.conf")
|
||||
if file == nil {
|
||||
t.Fatalf("%s: no file in %v", row.name, out)
|
||||
}
|
||||
got, _ := file["secrets"].(map[string]any)
|
||||
if got["admin"] != row.want {
|
||||
t.Errorf("%s: the file was sent without its secret: %v", row.name, file)
|
||||
}
|
||||
// The hole is still a hole on the wire. The mesh does not fill it in; the host does, on the
|
||||
// machine, which is the only place both halves exist.
|
||||
if !strings.Contains(file["content"].(string), "${secret:admin}") {
|
||||
t.Errorf("%s: the mesh substituted it itself: %v", row.name, file["content"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+29
-1
@@ -48,7 +48,7 @@ var ReservedStreamPrefixes = []string{"SEAT_", "LOG_", "KV_", "OBJ_", "DEAD_"}
|
||||
|
||||
// MeshStreamNames are the mesh's own streams by name. Written here as well as in the broker, which this
|
||||
// package cannot import; a test keeps them agreeing.
|
||||
var MeshStreamNames = []string{"CONTROL", "NODES", "ASSIGNMENTS", "EVENTS", "DEAD_LETTER_NOTICES", "DEAD_LETTERS", "PROV_APPLIED", "CHECKS_READY", "CHECKS_OTHER"}
|
||||
var MeshStreamNames = []string{"CONTROL", "NODES", "EVENTS", "DEAD_LETTER_NOTICES", "DEAD_LETTERS", "PROV_APPLIED", "CHECKS_READY", "CHECKS_OTHER"}
|
||||
|
||||
// StreamDeclaration is one stream a module owns.
|
||||
type StreamDeclaration struct {
|
||||
@@ -630,3 +630,31 @@ func uncovered(patterns []string) []string {
|
||||
slices.Sort(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// **A module keeps its data in the bus's predefined forms** (the lead and the operator, 2026-10-29; the operator's rule of
|
||||
// 2026-10-22, "data storage is done using our predefined nats bus options"): a state, a log or an object store, which the
|
||||
// runtime reads and writes for its tools and other modules read by a declared `reads`. A stream of its own is outside
|
||||
// them and refused, naming it. StreamsKeptUntilMoved are the streams declared before this check, each allowed by name
|
||||
// until its module moves it; the list only shrinks.
|
||||
var StreamsKeptUntilMoved = map[string][]string{
|
||||
"brother": {"device-asks"},
|
||||
"mesh-build-go": {"toolchains-go"},
|
||||
"mesh-build-node": {"toolchains-node"},
|
||||
"mesh-mail": {"mail", "mail-sends"},
|
||||
"only-office": {"convert-asks"},
|
||||
"mesh-delivery-assigner": {"placement"},
|
||||
"mesh-delivery-checker": {"checks"},
|
||||
"mesh-delivery-forge-bridge": {"forge"},
|
||||
}
|
||||
|
||||
// PredefinedFormProblems refuses each stream a module declares that StreamsKeptUntilMoved does not name for it.
|
||||
func PredefinedFormProblems(m Manifest) []string {
|
||||
var out []string
|
||||
for _, s := range m.Streams {
|
||||
if !slices.Contains(StreamsKeptUntilMoved[m.Module], s.Name) {
|
||||
out = append(out, fmt.Sprintf("%s keeps the stream %q, outside the bus's predefined forms: keep it as a state, "+
|
||||
"a log or an object store, which the runtime reads for the module's tools and others read by `reads`", m.Module, s.Name))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -96,6 +96,14 @@ func TestTwoModulesCannotDeclareOneStream(t *testing.T) {
|
||||
if got := StreamNameProblems(Shelf{"a": a}); len(got) != 0 {
|
||||
t.Fatalf("one owner refused: %v", got)
|
||||
}
|
||||
// And a stream of its own is outside the bus's predefined forms (the lead, 2026-10-29): refused, naming it, unless it
|
||||
// is one kept by name until its module moves it.
|
||||
if !strings.Contains(got, `a keeps the stream "mail", outside the bus's predefined forms`) {
|
||||
t.Fatalf("a stream of its own was not refused: %s", got)
|
||||
}
|
||||
if got := PredefinedFormProblems(Manifest{Module: "mesh-mail", Streams: a.Streams}); len(got) != 0 {
|
||||
t.Fatalf("a stream kept until it moves was refused: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The setting's value names who hears each key: a module wherever it runs, or on one machine (ADR 0363 §4).
|
||||
|
||||
@@ -3,6 +3,7 @@ package catalogue
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// What the mesh does when a module's build moves (novox/hq ADR 0236, extending ADR 0162 §3 and ADR
|
||||
@@ -118,3 +119,46 @@ func (m Manifest) irreplaceable() string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// PersonPolicy is a person's choice of a module's upgrade policy, as the assigner keeps it in its state `policies`, one
|
||||
// key per module (novox/hq ADR 0565 clause 7, issue 1953 L5-S2): the policy, why, who as the runtime proved the caller,
|
||||
// and when. Set and taken back through `mesh-wants.upgrade`, each recorded as a hand act.
|
||||
type PersonPolicy struct {
|
||||
V int `json:"v"`
|
||||
Module string `json:"module"`
|
||||
Policy string `json:"policy"`
|
||||
Why string `json:"why"`
|
||||
By string `json:"by"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// PersonPolicyVersion is the version of a person's policy's layout.
|
||||
const PersonPolicyVersion = 1
|
||||
|
||||
// Check refuses a choice the assigner could not keep: a policy that is not roll, together or record, one other than
|
||||
// roll without why, or one with no person.
|
||||
func (p PersonPolicy) Check() error {
|
||||
switch {
|
||||
case p.Module == "":
|
||||
return fmt.Errorf("a person's policy names its module")
|
||||
case p.Policy != PolicyRoll && p.Policy != PolicyTogether && p.Policy != PolicyRecord:
|
||||
return fmt.Errorf("%s: a policy is %q, %q or %q, not %q", p.Module, PolicyRoll, PolicyTogether, PolicyRecord, p.Policy)
|
||||
case strings.TrimSpace(p.Why) == "":
|
||||
return fmt.Errorf("%s: a person's policy says why", p.Module)
|
||||
case strings.TrimSpace(p.By) == "":
|
||||
return fmt.Errorf("%s: a person's policy names who chose it", p.Module)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Upgrade is a module's policy and where it comes from (ADR 0565 clause 7): the one derivation every reader takes,
|
||||
// `mesh-wants.upgrade` and each node agent's judgement of a move alike. A person's choice over the module's word, its
|
||||
// irreplaceable data and the default; the bus is never rolled, whoever says so (ADR 0236). By names the person when one
|
||||
// chose.
|
||||
func Upgrade(m Manifest, chosen *PersonPolicy) (policy, from, why, by string) {
|
||||
policy, from, why = DerivedUpgrade(m)
|
||||
if from == FromBus || chosen == nil {
|
||||
return policy, from, why, ""
|
||||
}
|
||||
return chosen.Policy, FromPerson, chosen.Why, chosen.By
|
||||
}
|
||||
|
||||
@@ -32,11 +32,28 @@ func TestWhereAModulesUpgradePolicyComesFrom(t *testing.T) {
|
||||
if policy != c.policy || from != c.from {
|
||||
t.Errorf("%s: %s from %s, want %s from %s", c.name, policy, from, c.policy, c.from)
|
||||
}
|
||||
// ADR 0565 clause 7 (issue 1953 L5-S3): a person's choice wins over each source but the bus; none leaves the
|
||||
// derivation as it was.
|
||||
person := &PersonPolicy{V: PersonPolicyVersion, Module: c.m.Module, Policy: PolicyTogether, Why: "one zone", By: "person.jochen"}
|
||||
policy, from, _, by := Upgrade(c.m, person)
|
||||
wantPolicy, wantFrom, wantBy := PolicyTogether, FromPerson, "person.jochen"
|
||||
if c.from == FromBus {
|
||||
wantPolicy, wantFrom, wantBy = c.policy, FromBus, ""
|
||||
}
|
||||
if policy != wantPolicy || from != wantFrom || by != wantBy {
|
||||
t.Errorf("%s with a person's choice: %s from %s by %q", c.name, policy, from, by)
|
||||
}
|
||||
if policy, from, _, _ := Upgrade(c.m, nil); policy != c.policy || from != c.from {
|
||||
t.Errorf("%s with no choice: %s from %s", c.name, policy, from)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A policy other than roll says why, and an unknown one is refused.
|
||||
func TestAnUpgradePolicySaysWhy(t *testing.T) {
|
||||
for _, p := range []PersonPolicy{{Module: "m", Policy: "sometimes", Why: "x", By: "p"}, {Module: "m", Policy: PolicyRecord, By: "p"},
|
||||
{Module: "m", Policy: PolicyRecord, Why: "x"}} {
|
||||
if p.Check() == nil {
|
||||
t.Errorf("%+v was accepted", p)
|
||||
}
|
||||
}
|
||||
// A policy other than roll says why, and an unknown one is refused.
|
||||
for _, u := range []UpgradePolicy{{Policy: PolicyRecord}, {Policy: PolicyTogether}, {Policy: "sometimes", Why: "x"}} {
|
||||
if problems := (Manifest{Module: "m", Upgrade: &u}).upgradeProblems(); len(problems) == 0 {
|
||||
t.Errorf("%+v was accepted", u)
|
||||
|
||||
@@ -32,6 +32,7 @@ import (
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/kvlimit"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -190,7 +191,7 @@ func writeIt(url, creds, node, bucket, key string, encoded []byte, digest string
|
||||
if err != nil {
|
||||
return fmt.Errorf("the bucket %s: %w", bucket, err)
|
||||
}
|
||||
if _, err := composed.Put(ctx, key, encoded); err != nil {
|
||||
if _, err := kvlimit.Put(ctx, composed, key, encoded); err != nil {
|
||||
return fmt.Errorf("%s in %s: %w", key, bucket, err)
|
||||
}
|
||||
value, err := delivery.EncodeComposedDigest(delivery.ComposedDigest{Digest: digest, Judgement: delivery.JudgedCannot,
|
||||
@@ -202,7 +203,7 @@ func writeIt(url, creds, node, bucket, key string, encoded []byte, digest string
|
||||
if err != nil {
|
||||
return fmt.Errorf("the bucket %s: %w", delivery.ControllerComposedBucket, err)
|
||||
}
|
||||
if _, err := digests.Put(ctx, key, value); err != nil {
|
||||
if _, err := kvlimit.Put(ctx, digests, key, value); err != nil {
|
||||
return fmt.Errorf("%s in %s: %w", key, delivery.ControllerComposedBucket, err)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -130,6 +130,22 @@ func compositions(in Input, node string, fps map[string]string, mode Mode, appli
|
||||
n.Kept = kept
|
||||
}
|
||||
}
|
||||
// A module composed as what runs of it contributes to the runtime what the runtime serves of it as it runs
|
||||
// (Node.RunsTools); one of its own account is served by a runtime of its own, from its manifest as before.
|
||||
for i := range reading.Nodes {
|
||||
n := &reading.Nodes[i]
|
||||
if n.Name != node || n.RunsTools == nil {
|
||||
continue
|
||||
}
|
||||
kept := make(map[string]catalogue.Manifest, len(n.Kept))
|
||||
for m, man := range n.Kept {
|
||||
if man.Stubbed && man.RunsAs == "" {
|
||||
man.Bundles = runningBundles(n.RunsTools[m], reading.Catalogue[m])
|
||||
}
|
||||
kept[m] = man
|
||||
}
|
||||
n.Kept = kept
|
||||
}
|
||||
isCarried := func(module, id string) bool {
|
||||
if module == "" {
|
||||
module, _ = declaration.ModuleGuess(id)
|
||||
@@ -293,6 +309,106 @@ func RunsAnyOf(runs *delivery.Composed, module string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// ToolsRunning is, by module, the tools bundles that run on the node (Node.RunsTools): each bundle archive a module's
|
||||
// resource unpacks at its BundlePath, with what the runtime's own process loads of it and the words it hands it
|
||||
// (catalogue.RuntimeToolModules, catalogue.RuntimeToolEnv, the mesh's own words left out). A bundle the runtime loads
|
||||
// nothing of has no Loads: the runtime dropped it, and the composition reads its loads again from the module's build
|
||||
// (issue 1126, review: shanks had already applied the drop of systemd-resolved). Nil when running holds no runtime process.
|
||||
func ToolsRunning(running []delivery.Composed) map[string][]catalogue.Bundle {
|
||||
runtimeID := catalogue.RuntimeModule + "." + catalogue.RuntimeProcessID()
|
||||
type resource struct {
|
||||
ID string `json:"id"`
|
||||
Module string `json:"module"`
|
||||
Type string `json:"type"`
|
||||
Path string `json:"path"`
|
||||
Source string `json:"source"`
|
||||
Digest string `json:"digest"`
|
||||
Env map[string]string `json:"env"`
|
||||
}
|
||||
var env map[string]string
|
||||
out := map[string][]catalogue.Bundle{}
|
||||
for _, c := range running {
|
||||
for _, raw := range c.Resources {
|
||||
var r resource
|
||||
if json.Unmarshal(raw, &r) != nil {
|
||||
continue
|
||||
}
|
||||
owner := r.Module
|
||||
if owner == "" {
|
||||
owner, _ = declaration.ModuleGuess(r.ID)
|
||||
}
|
||||
switch {
|
||||
case r.Type == "process" && r.ID == runtimeID && owner == catalogue.RuntimeModule:
|
||||
env = r.Env
|
||||
case r.Type == "archive" && owner != "" && strings.HasPrefix(r.ID, owner+"."+catalogue.BundleID("")):
|
||||
name := strings.TrimPrefix(r.ID, owner+"."+catalogue.BundleID(""))
|
||||
if r.Path == catalogue.BundlePath(owner, name) {
|
||||
out[owner] = append(out[owner], catalogue.Bundle{Name: name, Source: catalogue.Recorded(r.Source), Digest: r.Digest})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if env == nil {
|
||||
return nil
|
||||
}
|
||||
var words map[string]map[string]string
|
||||
_ = json.Unmarshal([]byte(env[catalogue.RuntimeToolEnv]), &words)
|
||||
for _, entry := range strings.Split(env[catalogue.RuntimeToolModules], ",") {
|
||||
module, path, ok := strings.Cut(entry, "=")
|
||||
if !ok || !strings.HasPrefix(path, catalogue.BundleRoot+"/"+module+"/") {
|
||||
continue
|
||||
}
|
||||
name, load, ok := strings.Cut(strings.TrimPrefix(path, catalogue.BundleRoot+"/"+module+"/"), "/")
|
||||
bundles := out[module]
|
||||
i := slices.IndexFunc(bundles, func(b catalogue.Bundle) bool { return b.Name == name })
|
||||
if !ok || i < 0 {
|
||||
continue // no archive of it runs: nothing to load
|
||||
}
|
||||
var own map[string]string
|
||||
for w, v := range words[module] {
|
||||
if w == catalogue.FilesOwnedPaths || w == catalogue.ReadsSecretPlaces {
|
||||
continue // the mesh's to say, composed again
|
||||
}
|
||||
if own == nil {
|
||||
own = map[string]string{}
|
||||
}
|
||||
own[w] = v
|
||||
}
|
||||
bundles[i].Loads = append(bundles[i].Loads, load)
|
||||
bundles[i].Env = own
|
||||
}
|
||||
for _, bundles := range out {
|
||||
sort.Slice(bundles, func(i, j int) bool { return bundles[i].Name < bundles[j].Name })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// runningBundles is the bundles of a module composed as what runs of it (Node.RunsTools): each that runs, with what the
|
||||
// runtime loads of it and its words; one the runtime loads nothing of takes its loads and words from the module's build
|
||||
// (man: its built bundles, else its build's artifacts, catalogue.BundleLoads), so a drop already applied is undone.
|
||||
func runningBundles(runs []catalogue.Bundle, man catalogue.Manifest) []catalogue.Bundle {
|
||||
var out []catalogue.Bundle
|
||||
for _, b := range runs {
|
||||
if len(b.Loads) == 0 {
|
||||
for _, built := range man.Bundles {
|
||||
if built.Name == b.Name {
|
||||
b.Loads, b.Env, b.Launchers, b.Binary = built.Loads, built.Env, built.Launchers, built.Binary
|
||||
}
|
||||
}
|
||||
if man.Build != nil {
|
||||
run := catalogue.RunByAResource(man)
|
||||
for _, a := range man.Build.Artifacts {
|
||||
if a.Kind == catalogue.ArtifactBundle && a.Name == b.Name {
|
||||
b.Loads, b.Env, b.Binary = catalogue.BundleLoads(man, a, run), a.Env, catalogue.BinaryOf(a)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// NamesBuild says a manifest names what a build makes: its build, or a resource naming an artifact. A node cannot
|
||||
// compose such a module folded into its own composition: the build of the old form it runs is held by the controller
|
||||
// alone (issue 1126).
|
||||
@@ -318,6 +434,8 @@ func Stub(m catalogue.Manifest) catalogue.Manifest {
|
||||
m.OwnsPaths = catalogue.DeclaredPaths(m)
|
||||
}
|
||||
m.Build = nil
|
||||
// What it hands the provisioner is left out as it runs, not refused for want of a plan (catalogue.Manifest.Stubbed).
|
||||
m.Stubbed = true
|
||||
// Nor is a template its build kept for its own files handed: a stub carries none of them (issue 1126: novox's node
|
||||
// refused "postgres: its build kept a template for consumers, which it does not declare" with postgres held). Its
|
||||
// items stay, and the templates they render through: a held module's contributions to other modules' collections
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/overlay"
|
||||
)
|
||||
|
||||
func fpFor(c byte) string { return delivery.SourcePrefix2 + strings.Repeat(string(c), 64) }
|
||||
@@ -96,6 +97,91 @@ func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) {
|
||||
if _, _, err := Compositions(in, "nowhere", fps, Foreseeing); err == nil {
|
||||
t.Errorf("a node the mesh does not have was composed")
|
||||
}
|
||||
|
||||
// Issue 1126, 2026-10-29: a module composed as what runs of it (folded at a build of the old form, or held) still
|
||||
// contributes to the runtime what the runtime serves of it as it runs (Node.RunsTools): its manifest at the
|
||||
// indexer's commit has no bundles (shanks's runtime lost the folded systemd-resolved, novox's the folded nats), and a
|
||||
// held module's new build's bundles are not delivered.
|
||||
dg := func(c byte) string { return "sha256:" + strings.Repeat(string(c), 64) }
|
||||
runtime := catalogue.Manifest{Module: catalogue.RuntimeModule, Version: "1",
|
||||
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh/node-tools/broker"}},
|
||||
Bundles: []catalogue.Bundle{{Name: "runtime", Source: catalogue.ArtifactStoreScheme + "node-tools/runtime/blobs/" + dg('a'),
|
||||
Digest: dg('a'), Language: "go", Binary: "node-tools"}}}
|
||||
store := catalogue.Manifest{Module: "artifacts", Version: "1",
|
||||
Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5100}}}
|
||||
lamp := catalogue.Manifest{Module: "lamp", Version: "1", Tools: []string{"on"},
|
||||
Resources: []map[string]any{{"id": "state", "type": "directory", "path": "/var/lib/lamp", "mode": "0700"}},
|
||||
Build: &catalogue.Build{Artifacts: []catalogue.Artifact{{Name: "tools", Kind: catalogue.ArtifactBundle,
|
||||
Language: "go", System: "arch", From: "cmd/lamp-tools", Env: map[string]string{"LAMP_CONF": "/var/lib/lamp/conf"}}}}}
|
||||
newLamp, err := lamp.Resolve([]catalogue.Built{{Name: "tools", Kind: catalogue.ArtifactBundle,
|
||||
Reference: catalogue.ArtifactStoreScheme + "lamp/tools/blobs/" + dg('c'), Digest: dg('c')}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
served := "lamp=" + catalogue.BundlePath("lamp", "tools") + "/lamp-tools"
|
||||
runtimeRuns := func(dropped bool) delivery.Composed {
|
||||
env := `"MESH_TOOL_MODULES":"` + served + `","MESH_TOOL_ENV":"{\"lamp\":{\"LAMP_CONF\":\"/var/lib/lamp/conf\"}}"`
|
||||
if dropped {
|
||||
env = `"MESH_TOOL_MODULES":""` // the drop already applied (shanks, 2026-10-29)
|
||||
}
|
||||
return delivery.Composed{Node: "anchor", Module: catalogue.RuntimeModule, Resources: []json.RawMessage{json.RawMessage(
|
||||
`{"id":"node-tools.runtime","module":"node-tools","type":"process","name":"node-tools","env":{` + env + `}}`)}}
|
||||
}
|
||||
lampRuns := `{"id":"lamp.bundle-tools","type":"archive","source":"` + catalogue.ArtifactStoreScheme + `lamp/tools/blobs/` + dg('b') +
|
||||
`","digest":"` + dg('b') + `","path":"` + catalogue.BundlePath("lamp", "tools") + `"}`
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
set func(n *Node) delivery.Composed // and what runs of lamp
|
||||
fps map[string]string
|
||||
dropped bool
|
||||
}{
|
||||
{"folded at a build of the old form", func(n *Node) delivery.Composed {
|
||||
n.InOwn = map[string]bool{"lamp": true}
|
||||
n.RunsOwn = &delivery.Composed{Node: "anchor", Resources: []json.RawMessage{json.RawMessage(lampRuns)}}
|
||||
return *n.RunsOwn
|
||||
}, map[string]string{}, false},
|
||||
{"folded, the runtime already dropped it", func(n *Node) delivery.Composed {
|
||||
n.InOwn = map[string]bool{"lamp": true}
|
||||
n.RunsOwn = &delivery.Composed{Node: "anchor", Resources: []json.RawMessage{json.RawMessage(lampRuns)}}
|
||||
return *n.RunsOwn
|
||||
}, map[string]string{}, true},
|
||||
{"held, its new build not delivered", func(n *Node) delivery.Composed {
|
||||
n.Held = map[string]bool{"lamp": true}
|
||||
lr := delivery.Composed{Node: "anchor", Module: "lamp", Resources: []json.RawMessage{json.RawMessage(
|
||||
strings.Replace(lampRuns, `"type"`, `"module":"lamp","type"`, 1))}}
|
||||
n.Kept = map[string]catalogue.Manifest{"lamp": Carried(newLamp, "lamp", &lr)}
|
||||
return lr
|
||||
}, map[string]string{"lamp": fpFor('1')}, false},
|
||||
} {
|
||||
tools := aMesh(t)
|
||||
tools.Catalogue[catalogue.RuntimeModule], tools.Catalogue["artifacts"], tools.Catalogue["lamp"] = runtime, store, lamp
|
||||
anchor := nodeOf(t, &tools, "anchor")
|
||||
anchor.Assigned = append(anchor.Assigned, "artifacts", catalogue.RuntimeModule, "lamp")
|
||||
tools.BusMinted = []string{"anchor." + catalogue.RuntimeModule}
|
||||
anchor.SealingKey = keyOf(t, "anchor")
|
||||
anchor.RunsTools = ToolsRunning([]delivery.Composed{runtimeRuns(row.dropped), row.set(anchor)})
|
||||
row.fps[overlay.Name], row.fps["artifacts"], row.fps[catalogue.RuntimeModule] = fpFor('a'), fpFor('5'), fpFor('e')
|
||||
out, _, _, err := CompositionsIn(tools, "anchor", row.fps, Foreseeing)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", row.name, err)
|
||||
}
|
||||
var env map[string]any
|
||||
for _, c := range out {
|
||||
for _, raw := range c.Resources {
|
||||
var r struct {
|
||||
ID string `json:"id"`
|
||||
Env map[string]any `json:"env"`
|
||||
}
|
||||
if json.Unmarshal(raw, &r) == nil && r.ID == "node-tools.runtime" {
|
||||
env = r.Env
|
||||
}
|
||||
}
|
||||
}
|
||||
if env[catalogue.RuntimeToolModules] != served || env[catalogue.RuntimeToolEnv] != `{"lamp":{"LAMP_CONF":"/var/lib/lamp/conf"}}` {
|
||||
t.Errorf("%s: the runtime serves %v with %v, want what runs of lamp, %s", row.name,
|
||||
env[catalogue.RuntimeToolModules], env[catalogue.RuntimeToolEnv], served)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1943/AC1: the upgrade policies and sources a composition's value may name are the catalogue's own words.
|
||||
|
||||
+12
-26
@@ -23,6 +23,10 @@ const (
|
||||
// Foreseeing composes an own secret the next send would make with a stand-in and names it foreseen, while one the send would
|
||||
// refuse (a bus credential nobody issued, a given secret under an old key) is refused here, in the same words.
|
||||
Foreseeing
|
||||
// Planning composes for a provisioning plan alone (novox/hq ADR 0570 clause 2): every own secret the Input holds none
|
||||
// for stands in, named foreseen, and nothing is refused for a value, since a plan names secrets and never carries one.
|
||||
// The seat mesh-provisioner's holder composes so, reading no secret; a rendering in this mode is never applied.
|
||||
Planning
|
||||
)
|
||||
|
||||
// ForeseenCertificate and ForeseenBusUsers stand for what the Input does not carry, in a composition asked ahead of the send.
|
||||
@@ -253,28 +257,6 @@ func (c *composer) wouldMakeSecretForModule(node Node, module, name string) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
// busCredentialIssued refuses an own secret called `broker` whose bus account nobody issued (novox/hq issue 203): the broker
|
||||
// credential is never invented, an account on the bus minted by `module issue`.
|
||||
func (c *composer) busCredentialIssued(node, module, name string) error {
|
||||
var user string
|
||||
switch name {
|
||||
case "broker":
|
||||
user = node + "." + module
|
||||
case ProvisionerSecret:
|
||||
user = "provisioner." + node
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
if !slices.Contains(c.in.BusMinted, user) {
|
||||
return fmt.Errorf(
|
||||
"%s on %s has no bus credential: nothing was issued for %s, and a push "+
|
||||
"would seal a placeholder its process cannot read (novox/hq issue 203). "+
|
||||
"`module issue %s --node %s`, then push again",
|
||||
module, node, user, module, node)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// givenUnderAnOldKey is the refusal for a value a person gave sealed to a key the machine no longer holds.
|
||||
func givenUnderAnOldKey(module, node, name string) error {
|
||||
return fmt.Errorf("%s on %s holds %q, which was given to the mesh rather than made by it, and %s has "+
|
||||
@@ -344,12 +326,16 @@ func (c *composer) ownSecretsFor(node Node, plan catalogue.Resolution, handing c
|
||||
continue
|
||||
}
|
||||
sealed, held := c.moduleSecretIfIssued(node, m.Module, name)
|
||||
if !held && mode == Planning {
|
||||
mark(m.Module, name)
|
||||
continue
|
||||
}
|
||||
if !held && mode == Foreseeing {
|
||||
// Asked ahead of the send: what the send would do about it, by the send's own rules. Made by it -- a stand-in,
|
||||
// named; refused by it -- refused here, the same words (novox/hq issue 275).
|
||||
if err := c.busCredentialIssued(node.Name, m.Module, name); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// named; refused by it -- refused here, the same words (novox/hq issue 275). A bus account not yet issued is
|
||||
// foreseen, never refused: the vault mints it from the grants part's wanted list once the placement is written
|
||||
// on the operator's Approve (ADR 0561 §1, ADR 0567 §5), so a composition asked ahead of that cannot hold it
|
||||
// (issue 774, 2026-10-30: every proposal placing mesh-conditions, mesh-self-check or mesh-healers refused).
|
||||
if err := c.wouldMakeSecretForModule(node, m.Module, name); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
@@ -116,34 +116,32 @@ func TestAnOutsideSecretIsPlacedOnlyOnceAPersonGaveIt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 1364/AC3 (the send's refusals, novox/hq issue 203 and 275): in the Foreseeing mode a bus credential nobody issued and a given secret
|
||||
// under an old key are refused in the controller's words; with the credential issued, the secret is foreseen. No secret value is in
|
||||
// an error.
|
||||
// 1364/AC3 (the send's refusals, novox/hq issue 275): in the Foreseeing mode a given secret under an old key is refused in the
|
||||
// controller's words. No secret value is in an error. A bus account not yet issued is foreseen, issued or not (issue 774: the vault
|
||||
// mints it once the placement is written on the Approve, ADR 0561 §1, ADR 0567 §5; mesh-self-check's proposal on ace was refused
|
||||
// "has no bus credential: nothing was issued for ace.mesh-self-check").
|
||||
func TestTheSendsRefusalsAreRefusedInTheSendsWords(t *testing.T) {
|
||||
broker := catalogue.Manifest{Module: "gate", Version: "1",
|
||||
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh/gate/broker", Taken: catalogue.TakenAtStart}}}
|
||||
broker := catalogue.Manifest{Module: "mesh-self-check", Version: "1", RunsAs: "mesh-self-check",
|
||||
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh/mesh-self-check/broker", Taken: catalogue.TakenAtStart}}}
|
||||
in := withAssistant(t)
|
||||
in.Catalogue["gate"] = broker
|
||||
in.Catalogue["mesh-self-check"] = broker
|
||||
laptop := nodeOf(t, &in, "laptop")
|
||||
laptop.Assigned = append(laptop.Assigned, "gate")
|
||||
_, err := Render(in, "laptop", Foreseeing)
|
||||
want := "gate on laptop has no bus credential: nothing was issued for laptop.gate, and a push would seal a placeholder " +
|
||||
"its process cannot read (novox/hq issue 203). `module issue gate --node laptop`, then push again"
|
||||
if err == nil || err.Error() != want {
|
||||
t.Fatalf("got %v, want %q", err, want)
|
||||
}
|
||||
// Reading refuses nothing for it: the machine is not running it.
|
||||
if _, err := Render(in, "laptop", Reading); err != nil {
|
||||
t.Fatalf("Reading refused a secret the machine is not running: %v", err)
|
||||
}
|
||||
in.BusMinted = []string{"laptop.gate"}
|
||||
if got := rendered(t, in, "laptop", Foreseeing); !got.Rendering.Foreseen["gate"]["broker"] {
|
||||
t.Errorf("an issued bus credential is not foreseen: %v", got.Rendering.Foreseen)
|
||||
laptop.Assigned = append(laptop.Assigned, "mesh-self-check")
|
||||
for _, minted := range [][]string{nil, {"laptop.mesh-self-check"}} {
|
||||
in.BusMinted = minted
|
||||
got, err := Render(in, "laptop", Foreseeing)
|
||||
if err != nil || !got.Rendering.Foreseen["mesh-self-check"]["broker"] {
|
||||
t.Fatalf("minted %v: the bus account is not foreseen: %v", minted, err)
|
||||
}
|
||||
// Reading refuses nothing for it: the machine is not running it.
|
||||
if _, err := Render(in, "laptop", Reading); err != nil {
|
||||
t.Fatalf("Reading refused a secret the machine is not running: %v", err)
|
||||
}
|
||||
}
|
||||
// A given secret under an old key.
|
||||
in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "password", Origin: "accepted", SealedTo: "an-old-key", Sealed: secretValue},
|
||||
{Node: "laptop", Module: "gate", Name: "broker", Origin: "made", SealedTo: laptop.SealingKey, Sealed: secretValue}}
|
||||
_, err = Render(in, "laptop", Foreseeing)
|
||||
{Node: "laptop", Module: "mesh-self-check", Name: "broker", Origin: "made", SealedTo: laptop.SealingKey, Sealed: secretValue}}
|
||||
_, err := Render(in, "laptop", Foreseeing)
|
||||
wantOld := "assistant on laptop holds \"password\", which was given to the mesh rather than made by it, and laptop has since generated a new " +
|
||||
"sealing key. The mesh cannot make another; issue it again"
|
||||
if err == nil || err.Error() != wantOld {
|
||||
|
||||
+167
-229
@@ -2,8 +2,9 @@ package frombus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
@@ -11,7 +12,6 @@ import (
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
@@ -42,7 +42,7 @@ func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]st
|
||||
// Every module it cannot compose is named, not the first only (issue 1126): a build it cannot read or take in, and
|
||||
// below, a module the composition refuses, each composed as a stub so the rest is composed and compared.
|
||||
failed := map[string]string{}
|
||||
in, err := input(ctx, r, mesh, s, func(module string, err error) { failed[module] = err.Error() })
|
||||
in, err := input(ctx, r, mesh, s, func(module string, err error) { failed[module] = err.Error() }, nil)
|
||||
if err != nil {
|
||||
return refuse(err)
|
||||
}
|
||||
@@ -72,7 +72,7 @@ func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]st
|
||||
return refuse(fmt.Errorf("frombus: the credentials of %s: %w", s.Node, err))
|
||||
}
|
||||
}
|
||||
composed, skipped, stood, err := composeEach(in, s.Node, fps, false, s.Running, failed)
|
||||
composed, skipped, stood, err := composeEach(in, s.Node, fps, false, s.Running, failed, ranOf(ctx, r, mesh, s.Running, fps))
|
||||
if err != nil {
|
||||
// Not one module's: the node's own (a value served elsewhere, the mesh's network), said once as the node's.
|
||||
if len(failed) == 0 {
|
||||
@@ -209,59 +209,24 @@ func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]st
|
||||
return a
|
||||
}
|
||||
|
||||
// heldBehind is why a module is held with a held one it reads (composeEach's dependents).
|
||||
func heldBehind(m string) string {
|
||||
return "it reads what " + m + " contributes or listens on, and " + m + " is held"
|
||||
}
|
||||
|
||||
// behind is the held module a refusal's words say it is held behind, or "".
|
||||
func behind(why string) string {
|
||||
rest, ok := strings.CutPrefix(why, "it reads what ")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
m, _, ok := strings.Cut(rest, " contributes or listens on, and ")
|
||||
if !ok || why != heldBehind(m) {
|
||||
return ""
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// heldAsToday moves into Held each refusal of a module the node keeps today as it runs (Scope.HeldToday, with how:
|
||||
// held, failed, running a build of the old form), and of each module held only behind one of those: the candidate keeps
|
||||
// them as the node does, which is no change (issue 1126: a seed reproduces today, and today keeps them so). Each says
|
||||
// how it stands today (ModuleRefused.Today).
|
||||
// held, failed, running a build of the old form): the candidate keeps it as the node does, which is no change (issue
|
||||
// 1126: a seed reproduces today, and today keeps them so). Each says how it stands today (ModuleRefused.Today). No
|
||||
// module is held behind another (issue 1960): a module that reads from a held one composes with what it last offered.
|
||||
func heldAsToday(a *delivery.CandidateAnswer, today map[string]string) {
|
||||
if len(today) == 0 {
|
||||
return
|
||||
}
|
||||
how := map[string]string{}
|
||||
for m, w := range today {
|
||||
how[m] = w
|
||||
}
|
||||
for moved := true; moved; {
|
||||
moved = false
|
||||
kept := a.Refusals[:0]
|
||||
for _, r := range a.Refusals {
|
||||
w, keep := how[r.Module]
|
||||
if !keep {
|
||||
if m := behind(r.Why); m != "" {
|
||||
if _, behindKept := how[m]; behindKept {
|
||||
w, keep = "held behind "+m, true
|
||||
}
|
||||
}
|
||||
}
|
||||
if keep {
|
||||
r.Today = w
|
||||
how[r.Module] = w
|
||||
a.Held = append(a.Held, r)
|
||||
moved = true
|
||||
} else {
|
||||
kept = append(kept, r)
|
||||
}
|
||||
kept := a.Refusals[:0]
|
||||
for _, r := range a.Refusals {
|
||||
if w, keep := today[r.Module]; keep {
|
||||
r.Today = w
|
||||
a.Held = append(a.Held, r)
|
||||
} else {
|
||||
kept = append(kept, r)
|
||||
}
|
||||
a.Refusals = kept
|
||||
}
|
||||
a.Refusals = kept
|
||||
if len(a.Refusals) == 0 {
|
||||
a.Refusals = nil
|
||||
}
|
||||
@@ -275,7 +240,9 @@ const MaxShown = 256 << 10
|
||||
|
||||
var (
|
||||
grantStandIn = regexp.MustCompile(`^the grant of \S+ for (\S+) on (\S+)$`)
|
||||
needsStandIn = regexp.MustCompile(`^(\S+) on (\S+) needs `)
|
||||
// grantOfStandIn is the provision a grant's stand-in is of.
|
||||
grantOfStandIn = regexp.MustCompile(`^the grant of (\S+) for \S+ on \S+$`)
|
||||
needsStandIn = regexp.MustCompile(`^(\S+) on (\S+) needs `)
|
||||
)
|
||||
|
||||
// standsInFor is the node and module a stand-in of compose's words is for: an own secret `module/name`, a port
|
||||
@@ -312,12 +279,18 @@ func standsInFor(x, node string) (string, string) {
|
||||
// **A held module is what runs of it** (review of the apply, round 1): composed from what running holds of it
|
||||
// (compose.Carried of its own composition there; the node's own carries it wherever it runs there), so what the others
|
||||
// read of it (the machine ports its containers publish, the routes reaching it) is what runs, and none of its own
|
||||
// composition is in out: the node keeps what it last applied. **What depends on how it is held is held too:** a module
|
||||
// whose composition differs when the held ones are composed as their stubs instead is held with them, and a node's own
|
||||
// composition that differs (but for the held modules' own resources) refuses the node. And since a held module is
|
||||
// composed from its new manifest, which may not be the build's that runs, what reads that manifest is held with it
|
||||
// (dependents): the providers of what it contributes to (the proxy its routes), and, when it listens, the filter.
|
||||
func composeEach(in compose.Input, node string, fps map[string]string, applied bool, running []delivery.Composed, failed map[string]string) (out map[string]delivery.Composed, skipped, stood []string, err error) {
|
||||
// composition is in out: the node keeps what it last applied.
|
||||
//
|
||||
// **A hold never spreads** (issue 1126, issue 1960: de-spiegel's hold for an unset value held mailu, which held
|
||||
// route-proxy, nftables and postgres on novox). No module is held because a module it reads from is held: a consumer,
|
||||
// a collector, the provider of what the held one contributes to and the filter where it listens each compose with what
|
||||
// the held module last offered. That offer (what it contributes, contributes many, listens on) is its manifest at the
|
||||
// build it runs where one is known (ran: a composition of its own running at another build than it is composed at),
|
||||
// else the manifest it is composed from. Only the part of the offer that cannot compose is left out, by provision
|
||||
// (culprit): de-spiegel's routes, its names unset, leave route-proxy without them while mailu keeps its smtp account,
|
||||
// and the hold says so.
|
||||
func composeEach(in compose.Input, node string, fps map[string]string, applied bool, running []delivery.Composed, failed map[string]string,
|
||||
ran func(module string) (catalogue.Manifest, bool)) (out map[string]delivery.Composed, skipped, stood []string, err error) {
|
||||
mode := compose.Foreseeing
|
||||
if applied {
|
||||
mode = compose.Reading
|
||||
@@ -328,43 +301,46 @@ func composeEach(in compose.Input, node string, fps map[string]string, applied b
|
||||
runs[m] = &running[i]
|
||||
}
|
||||
}
|
||||
src := map[string]catalogue.Manifest{} // each held module's manifest before it was held
|
||||
bareOf := map[string]bool{}
|
||||
var hold func(m, why string, bare bool)
|
||||
hold = func(m, why string, bare bool) {
|
||||
src := map[string]catalogue.Manifest{} // each held module's manifest before it was held, offering what it last offered
|
||||
left := map[string]map[string]bool{} // by held module, what of its offer is left out (leaveOut)
|
||||
hold := func(m, why, leave string) {
|
||||
if _, done := failed[m]; !done {
|
||||
failed[m] = why // a held module tried bare keeps the words it was first held for
|
||||
failed[m] = why // a held module tried again keeps the words it was first held for
|
||||
}
|
||||
first := false
|
||||
if _, ok := src[m]; !ok {
|
||||
man, ok := manifestOn(in, node, m)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
src[m], first = man, true
|
||||
if ran != nil {
|
||||
if was, ok := ran(m); ok {
|
||||
man.Contributes, man.ContributesMany, man.Listens = was.Contributes, was.ContributesMany, was.Listens
|
||||
}
|
||||
}
|
||||
src[m] = man
|
||||
}
|
||||
bareOf[m] = bareOf[m] || bare
|
||||
in = heldAs(in, node, m, src[m], bareOf[m], runs[m])
|
||||
if !first {
|
||||
return
|
||||
}
|
||||
// What the others read of its manifest (what it contributes, what it listens on) is its new manifest's, not
|
||||
// necessarily the build's that runs: what reads it is held with it (review of the apply, round 2).
|
||||
for _, r := range dependents(in, node, src[m], failed) {
|
||||
hold(r, heldBehind(m), false)
|
||||
if leave != "" {
|
||||
if left[m] == nil {
|
||||
left[m] = map[string]bool{}
|
||||
}
|
||||
left[m][leave] = true
|
||||
if leave != leaveAll && leave != leaveOwn {
|
||||
failed[m] += "; composed without what it contributes to " + leave + ", which does not compose"
|
||||
}
|
||||
}
|
||||
in = heldAs(in, node, m, src[m], left[m], runs[m])
|
||||
}
|
||||
for _, m := range sortedKeys(failed) {
|
||||
hold(m, failed[m], false)
|
||||
hold(m, failed[m], "")
|
||||
}
|
||||
for {
|
||||
out, skipped, stood, err = compose.CompositionsIn(in, node, fps, mode)
|
||||
if err != nil {
|
||||
module, bare := culprit(in, node, fps, mode, failed, err)
|
||||
module, leave := culprit(in, node, fps, mode, failed, err)
|
||||
if module == "" {
|
||||
return nil, nil, nil, err
|
||||
return nil, nil, nil, firstReason(err, failed)
|
||||
}
|
||||
hold(module, err.Error(), bare)
|
||||
hold(module, err.Error(), leave)
|
||||
continue
|
||||
}
|
||||
again := false
|
||||
@@ -384,25 +360,24 @@ func composeEach(in compose.Input, node string, fps map[string]string, applied b
|
||||
}
|
||||
for _, m := range sortedKeys(of) {
|
||||
if _, done := failed[m]; !done {
|
||||
hold(m, "stands in for "+strings.Join(of[m], ", "), false)
|
||||
hold(m, "stands in for "+strings.Join(of[m], ", "), "")
|
||||
again = true
|
||||
continue
|
||||
}
|
||||
// Already held, and still a stand-in in another module's composition: a grant not yet issued for what
|
||||
// it contributes (a module held for its own secret keeps its contributions, leaveOwn). Its contribution
|
||||
// to that provision is left out, so the provider never applies a stand-in (issue 774, review round 1).
|
||||
for _, x := range of[m] {
|
||||
if g := grantOfStandIn.FindStringSubmatch(x); g != nil && !left[m][g[1]] {
|
||||
hold(m, failed[m], g[1])
|
||||
again = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !again && len(nodes) > 0 {
|
||||
return nil, nil, nil, fmt.Errorf("%w on %s: %s", compose.ErrStoodIn, node, strings.Join(nodes, ", "))
|
||||
}
|
||||
}
|
||||
if !again && len(failed) > 0 {
|
||||
dependent, own := dependsOnHeld(in, node, fps, mode, out, src, bareOf, failed)
|
||||
if own {
|
||||
return nil, nil, nil, fmt.Errorf("frombus: %s's own composition depends on how %s, held, is composed, and is not applied",
|
||||
node, strings.Join(sortedKeys(failed), ", "))
|
||||
}
|
||||
for _, m := range dependent {
|
||||
hold(m, "what it composes of "+strings.Join(sortedKeys(failed), ", ")+", held, is not what runs of them", false)
|
||||
again = true
|
||||
}
|
||||
}
|
||||
if !again {
|
||||
break
|
||||
}
|
||||
@@ -411,117 +386,46 @@ func composeEach(in compose.Input, node string, fps map[string]string, applied b
|
||||
return out, skipped, stood, nil
|
||||
}
|
||||
|
||||
// dependsOnHeld composes in again with each held module as its stub rather than as what runs of it, and answers the
|
||||
// modules, not held, whose composition differs between the two (sorted), and own when the node's own composition does
|
||||
// but for the held modules' own resources. A composition of stubs that fails says nothing more: what runs composed.
|
||||
func dependsOnHeld(in compose.Input, node string, fps map[string]string, mode compose.Mode, asRuns map[string]delivery.Composed,
|
||||
src map[string]catalogue.Manifest, bareOf map[string]bool, failed map[string]string) (dependent []string, own bool) {
|
||||
stubs := in
|
||||
// firstReason is err, a refusal no culprit is found for, led by the first reason of each held module it names: a held
|
||||
// module's stand-in refused later never replaces why it was held (issue 774, 2026-10-30: mesh-self-check on ace held for
|
||||
// "has no bus credential", then refused as "runs as its own account and declares no own secret broker", its bare stub's
|
||||
// words, which were all the proposal's refusal said).
|
||||
func firstReason(err error, failed map[string]string) error {
|
||||
var named []string
|
||||
for _, m := range sortedKeys(failed) {
|
||||
if man, ok := src[m]; ok {
|
||||
stubs = heldAs(stubs, node, m, man, bareOf[m], nil)
|
||||
if strings.Contains(err.Error(), m) {
|
||||
named = append(named, m+" is held: "+failed[m])
|
||||
}
|
||||
}
|
||||
asStubs, _, _, err := compose.CompositionsIn(stubs, node, fps, mode)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
if len(named) == 0 {
|
||||
return err
|
||||
}
|
||||
isHeld := func(m string) bool { _, ok := failed[m]; return ok }
|
||||
keys := map[string]bool{}
|
||||
for k := range asRuns {
|
||||
keys[k] = true
|
||||
}
|
||||
for k := range asStubs {
|
||||
keys[k] = true
|
||||
}
|
||||
for _, k := range sortedKeys(keys) {
|
||||
a, inA := asRuns[k]
|
||||
b, inB := asStubs[k]
|
||||
m := a.Module
|
||||
if !inA {
|
||||
m = b.Module
|
||||
}
|
||||
if m != "" && isHeld(m) {
|
||||
continue
|
||||
}
|
||||
if m == "" {
|
||||
a, b = withoutHeld(a, isHeld), withoutHeld(b, isHeld)
|
||||
}
|
||||
da, errA := delivery.ComposedDigestOf(a)
|
||||
db, errB := delivery.ComposedDigestOf(b)
|
||||
if inA == inB && errA == nil && errB == nil && da == db {
|
||||
continue
|
||||
}
|
||||
if m == "" {
|
||||
own = true
|
||||
continue
|
||||
}
|
||||
dependent = append(dependent, m)
|
||||
}
|
||||
return dependent, own
|
||||
return fmt.Errorf("%s; its stand-in is then refused: %w", strings.Join(named, "; "), err)
|
||||
}
|
||||
|
||||
// withoutHeld is the node's own composition c without the resources of a held module (its id naming it) and without
|
||||
// its filled paths, for comparing the rest.
|
||||
func withoutHeld(c delivery.Composed, isHeld func(string) bool) delivery.Composed {
|
||||
out := c
|
||||
out.Filled = nil
|
||||
out.Resources = nil
|
||||
for _, raw := range c.Resources {
|
||||
var head struct {
|
||||
ID string `json:"id"`
|
||||
Module string `json:"module"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &head)
|
||||
owner := head.Module
|
||||
if owner == "" {
|
||||
owner, _ = declaration.ModuleGuess(head.ID)
|
||||
}
|
||||
if owner != "" && isHeld(owner) {
|
||||
continue
|
||||
}
|
||||
out.Resources = append(out.Resources, raw)
|
||||
// blames says the composition with m's stub (again) makes m the culprit of err: it composes, or fails otherwise. The
|
||||
// runtime's stub is never a runtime (no build is taken in, so it carries no bundle) and always fails otherwise: it is
|
||||
// the culprit only where its stub composes (issue 774, 2026-10-30 19:30: node-tools held for any error naming it).
|
||||
func blames(m string, again, err error) bool {
|
||||
if m == catalogue.RuntimeModule {
|
||||
// Or where its stub fails only for the modules of their own account it would run (issue 774): they are held
|
||||
// after it, each for its own words, and the node composes the rest.
|
||||
var none *catalogue.NoRuntimeToRunError
|
||||
return again == nil || errors.As(again, &none) && none.Held
|
||||
}
|
||||
return out
|
||||
return again == nil || again.Error() != err.Error()
|
||||
}
|
||||
|
||||
// dependents is, of the modules placed on node and not held, those that read what man says to them: each that provides
|
||||
// what man contributes to (the proxy its routes), and, when man listens on a port, each that holds the filter (the
|
||||
// openings).
|
||||
func dependents(in compose.Input, node string, man catalogue.Manifest, failed map[string]string) []string {
|
||||
to := map[string]bool{}
|
||||
for p := range man.Contributes {
|
||||
to[p] = true
|
||||
}
|
||||
for p := range man.ContributesMany {
|
||||
to[p] = true
|
||||
}
|
||||
listens := len(man.Listens) > 0
|
||||
if len(to) == 0 && !listens {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, m := range assignedOn(in, node) {
|
||||
if _, held := failed[m]; held || m == man.Module {
|
||||
continue
|
||||
}
|
||||
pm, ok := manifestOn(in, node, m)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if listens && pm.Filtering != nil {
|
||||
out = append(out, m)
|
||||
continue
|
||||
}
|
||||
for _, o := range pm.Provides {
|
||||
if to[o.Name] {
|
||||
out = append(out, m)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
// leaveAll leaves out the whole of a held module's offer and its own secrets (a bare stub); leaveOwn its own secrets
|
||||
// alone, its offer kept; any other word leaves out what it contributes to that provision alone.
|
||||
//
|
||||
// leaveOwn is tried before leaveAll: a module held for an own secret never made (nextcloud's admin, issue 774,
|
||||
// 2026-10-30) keeps what it contributes, so its providers' received files keep it and their helpers never retire its
|
||||
// login while it is held.
|
||||
const (
|
||||
leaveAll = "*"
|
||||
leaveOwn = "own-secrets"
|
||||
)
|
||||
|
||||
// assignedOn is the modules placed on node, in placement order.
|
||||
func assignedOn(in compose.Input, node string) []string {
|
||||
@@ -546,18 +450,14 @@ func manifestOn(in compose.Input, node, module string) (catalogue.Manifest, bool
|
||||
return man, ok
|
||||
}
|
||||
|
||||
// culprit is the module placed on node whose composition the error is of: the first, in placement order, whose stub
|
||||
// (compose.Stub) makes the composition in mode succeed or fail otherwise; empty when none does (the error is not one
|
||||
// module's). Modules already refused are passed over. A module whose stub still fails the same is tried bare (its stub
|
||||
// contributing nothing, bare true): a refusal of what it contributes is its own, which the stub keeps (issue 1126,
|
||||
// seed-8: de-spiegel's route names on novox, an unset required value, refused the whole node).
|
||||
func culprit(in compose.Input, node string, fps map[string]string, mode compose.Mode, failed map[string]string, err error) (string, bool) {
|
||||
var assigned []string
|
||||
for _, n := range in.Nodes {
|
||||
if n.Name == node {
|
||||
assigned = n.Assigned
|
||||
}
|
||||
}
|
||||
// culprit is the module placed on node whose composition the error is of, and what of its offer to leave out: the
|
||||
// first, in placement order, whose stub (compose.Stub) makes the composition in mode succeed or fail otherwise (leave
|
||||
// ""); else the first whose stub without what it contributes to one provision does (leave that provision: de-spiegel's
|
||||
// routes, its names unset, and not its smtp account, issue 1960); else the first whose bare stub does (leaveAll: its
|
||||
// stub contributing nothing, its own secret never issued, issue 1126 seed-8). Empty when none does (the error is not
|
||||
// one module's). Modules already refused are passed over for their stub.
|
||||
func culprit(in compose.Input, node string, fps map[string]string, mode compose.Mode, failed map[string]string, err error) (string, string) {
|
||||
assigned := assignedOn(in, node)
|
||||
// Only the modules the error names: the words of a module's refusal name it. Each try is a whole composition, so a
|
||||
// refusal naming none is no module's, answered at once rather than after a composition per module (issue 1126: ace's
|
||||
// answer took 7m46s).
|
||||
@@ -567,65 +467,103 @@ func culprit(in compose.Input, node string, fps map[string]string, mode compose.
|
||||
order = append(order, m)
|
||||
}
|
||||
}
|
||||
changes := func(m string, bare bool) bool {
|
||||
_, _, _, again := compose.CompositionsIn(stubbed(in, node, m, bare), node, fps, mode)
|
||||
return again == nil || again.Error() != err.Error()
|
||||
changes := func(m, leave string) bool {
|
||||
_, _, _, again := compose.CompositionsIn(stubbed(in, node, m, leave), node, fps, mode)
|
||||
return blames(m, again, err)
|
||||
}
|
||||
for _, m := range order {
|
||||
if _, done := failed[m]; done {
|
||||
continue
|
||||
}
|
||||
if changes(m, false) {
|
||||
return m, false
|
||||
}
|
||||
}
|
||||
var kept map[string]catalogue.Manifest
|
||||
for _, n := range in.Nodes {
|
||||
if n.Name == node {
|
||||
kept = n.Kept
|
||||
if changes(m, "") {
|
||||
return m, ""
|
||||
}
|
||||
}
|
||||
for _, m := range order {
|
||||
// One already held is tried bare too, once: held for its build, its stub may still refuse (its own secret
|
||||
// never issued, issue 1126).
|
||||
if _, done := failed[m]; done && isBare(kept[m]) {
|
||||
man, ok := manifestOn(in, node, m)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if changes(m, true) {
|
||||
return m, true
|
||||
// One already held is tried again too: held for its build, its stub may still refuse (its own secret never
|
||||
// issued, issue 1126). A provision named in the error is tried first, each at one more composition.
|
||||
var to []string
|
||||
for p := range man.Contributes {
|
||||
to = append(to, p)
|
||||
}
|
||||
for p := range man.ContributesMany {
|
||||
if _, both := man.Contributes[p]; !both {
|
||||
to = append(to, p)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(to, func(i, j int) bool {
|
||||
return strings.Contains(err.Error(), " "+to[i]) && !strings.Contains(err.Error(), " "+to[j])
|
||||
})
|
||||
for _, p := range to {
|
||||
if changes(m, p) {
|
||||
return m, p
|
||||
}
|
||||
}
|
||||
if isBare(man) {
|
||||
continue
|
||||
}
|
||||
if len(man.OwnSecrets) > 0 && changes(m, leaveOwn) {
|
||||
return m, leaveOwn
|
||||
}
|
||||
if changes(m, leaveAll) {
|
||||
return m, leaveAll
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// isBare says a stub contributes nothing and declares no own secret (stubbed, bare).
|
||||
// isBare says a stub contributes nothing and declares no own secret (stubbed, leaveAll).
|
||||
func isBare(m catalogue.Manifest) bool {
|
||||
return len(m.Contributes) == 0 && len(m.ContributesMany) == 0 && len(m.OwnSecrets) == 0
|
||||
}
|
||||
|
||||
// stubbed is in with module held on node as its stub alone (heldAs, nothing of it running), from the manifest node
|
||||
// composes it from: a culprit's try.
|
||||
func stubbed(in compose.Input, node, module string, bare bool) compose.Input {
|
||||
// composes it from, leave left out of its offer: a culprit's try.
|
||||
func stubbed(in compose.Input, node, module, leave string) compose.Input {
|
||||
man, ok := manifestOn(in, node, module)
|
||||
if !ok {
|
||||
return in
|
||||
}
|
||||
return heldAs(in, node, module, man, bare, nil)
|
||||
var left map[string]bool
|
||||
if leave != "" {
|
||||
left = map[string]bool{leave: true}
|
||||
}
|
||||
return heldAs(in, node, module, man, left, nil)
|
||||
}
|
||||
|
||||
// heldAs is in with module held on node (compose.Node.Held): composed from man as what runs of it (compose.Carried of
|
||||
// runs; its stub, compose.Stub, when runs is nil or holds nothing of it), as node's Kept; bare, contributing nothing to
|
||||
// what it requires and declaring no own secret: none of a held module's own composition is applied, so what it has not
|
||||
// been issued (restic's repository on a node it never ran on) is no refusal of the rest.
|
||||
func heldAs(in compose.Input, node, module string, man catalogue.Manifest, bare bool, runs *delivery.Composed) compose.Input {
|
||||
// runs; its stub, compose.Stub, when runs is nil or holds nothing of it), as node's Kept; with what left names out of
|
||||
// its offer: a provision's contributions, with leaveOwn its own secrets alone, or with leaveAll everything it contributes
|
||||
// and its own secrets, none of a held module's own composition being applied, so what it has not been issued (restic's
|
||||
// repository on a node it never ran on) is no refusal of the rest.
|
||||
func heldAs(in compose.Input, node, module string, man catalogue.Manifest, left map[string]bool, runs *delivery.Composed) compose.Input {
|
||||
out := in
|
||||
out.Nodes = append([]compose.Node(nil), in.Nodes...)
|
||||
for i := range out.Nodes {
|
||||
if out.Nodes[i].Name != node {
|
||||
continue
|
||||
}
|
||||
if bare {
|
||||
if left[leaveAll] {
|
||||
man.Contributes, man.ContributesMany, man.OwnSecrets = nil, nil, nil
|
||||
} else if len(left) > 0 {
|
||||
if left[leaveOwn] {
|
||||
man.OwnSecrets = nil
|
||||
}
|
||||
contributes := map[string]map[string]any{}
|
||||
for p, v := range man.Contributes {
|
||||
if !left[p] {
|
||||
contributes[p] = v
|
||||
}
|
||||
}
|
||||
many := maps.Clone(man.ContributesMany)
|
||||
for p := range left {
|
||||
delete(many, p)
|
||||
}
|
||||
man.Contributes, man.ContributesMany = contributes, many
|
||||
}
|
||||
kept := make(map[string]catalogue.Manifest, len(out.Nodes[i].Kept)+1)
|
||||
for k, v := range out.Nodes[i].Kept {
|
||||
|
||||
@@ -104,12 +104,12 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
|
||||
if part.Verdict != delivery.CandidateRefused || len(part.Refusals) != 2 || len(part.Held) != 1 || part.Held[0].Module != "api" {
|
||||
t.Errorf("held as today in part: %+v", part)
|
||||
}
|
||||
// A module held only behind one kept today is kept as today too, saying behind which (novox's mailu behind de-spiegel).
|
||||
// No module is held behind one kept today (issue 1960): mailu's own refusal stays a refusal beside de-spiegel's hold.
|
||||
if a := (delivery.CandidateAnswer{Refusals: []delivery.ModuleRefused{{Module: "de-spiegel", Why: "a value is unset"},
|
||||
{Module: "mailu", Why: heldBehind("de-spiegel")}, {Module: "proxy", Why: heldBehind("mailu")}, {Module: "web", Why: "no build"}}}); true {
|
||||
{Module: "mailu", Why: "no build"}}}); true {
|
||||
heldAsToday(&a, map[string]string{"de-spiegel": "runs a build of the old form"})
|
||||
if len(a.Refusals) != 1 || a.Refusals[0].Module != "web" || len(a.Held) != 3 || a.Held[2].Today != "held behind mailu" {
|
||||
t.Errorf("held behind a module kept today: %+v", a)
|
||||
if len(a.Refusals) != 1 || a.Refusals[0].Module != "mailu" || len(a.Held) != 1 {
|
||||
t.Errorf("held as today spreads: %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,23 +330,26 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
|
||||
// de-spiegel folded, and its manifest at the indexer's commit (e25e1beb, captured) asks four required config values
|
||||
// for its route names that nothing sets. novox answered the whole candidate refused ("de-spiegel contributing
|
||||
// praktijk to route says ${setting:praktijk-name} ...": its stub still contributed the route). Only de-spiegel is
|
||||
// refused, in the config form's words, and proxy, which receives its routes (what it would compose lacks them: review
|
||||
// of the apply, round 1); the rest is judged.
|
||||
// refused, in the config form's words; the rest is judged. Issue 1960, novox's chain on 2026-10-29: the hold spread
|
||||
// to mailu (its smtp provider) and proxy (its route provider), and from them on. Neither is held now: proxy composes
|
||||
// without de-spiegel's routes, which do not compose, and mailu with its smtp account, which does.
|
||||
spiegel, err := os.ReadFile("testdata/de-spiegel-e25e1beb.module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
proxy := []byte(`{"module":"proxy","version":"1","capabilities":["systemd"],"provides":[{"name":"route","scope":"mesh"},{"name":"smtp","scope":"mesh"}],` +
|
||||
proxy := []byte(`{"module":"proxy","version":"1","capabilities":["systemd"],"provides":[{"name":"route","scope":"mesh"}],` +
|
||||
`"resources":[{"id":"service","type":"service","unit":"proxy.service","state":"running","boot":"enabled"}]}`)
|
||||
b = mk("lemurs", overlay.Name, "proxy", "de-spiegel")
|
||||
for m, raw := range map[string][]byte{"de-spiegel": spiegel, "proxy": proxy} {
|
||||
mailu := []byte(`{"module":"mailu","version":"1","capabilities":["systemd"],"provides":[{"name":"smtp","scope":"mesh"}],` +
|
||||
`"resources":[{"id":"service","type":"service","unit":"mailu.service","state":"running","boot":"enabled"}]}`)
|
||||
b = mk("lemurs", overlay.Name, "mailu", "proxy", "de-spiegel")
|
||||
for m, raw := range map[string][]byte{"de-spiegel": spiegel, "proxy": proxy, "mailu": mailu} {
|
||||
b.manifests[delivery.ManifestDigest(raw)] = raw
|
||||
b.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)}
|
||||
}
|
||||
p = b.placements["n1"]
|
||||
p.Decided.Endpoint = "n1.example:51820"
|
||||
b.placements["n1"] = p
|
||||
fps = map[string]string{"lemurs": fp("e"), "proxy": fp("f")}
|
||||
fps = map[string]string{"lemurs": fp("e"), "proxy": fp("f"), "mailu": fp("d")}
|
||||
a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"de-spiegel"}}, fps, nil, "p-h1", 3)
|
||||
refused = nil
|
||||
for _, r := range a.Refusals {
|
||||
@@ -355,11 +358,13 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
|
||||
t.Errorf("de-spiegel is refused in other words than its config form's: %s", r.Why)
|
||||
}
|
||||
}
|
||||
if a.Refused != "" || !reflect.DeepEqual(refused, []string{"de-spiegel", "proxy"}) {
|
||||
t.Errorf("a module's own refusal: %+v, want de-spiegel refused with proxy, which receives its routes, the node not", a)
|
||||
if a.Refused != "" || !reflect.DeepEqual(refused, []string{"de-spiegel"}) {
|
||||
t.Errorf("a module's own refusal: %+v, want de-spiegel refused alone, the node, mailu and proxy not", a)
|
||||
}
|
||||
if !slices.ContainsFunc(a.Added, func(m delivery.ModuleAdded) bool { return m.Module == "lemurs" }) {
|
||||
t.Errorf("lemurs, which composes, is not judged beside de-spiegel's refusal: %+v", a)
|
||||
for _, m := range []string{"lemurs", "mailu", "proxy"} {
|
||||
if !slices.ContainsFunc(a.Added, func(x delivery.ModuleAdded) bool { return x.Module == m }) {
|
||||
t.Errorf("%s, which composes, is not judged beside de-spiegel's refusal: %+v", m, a)
|
||||
}
|
||||
}
|
||||
|
||||
// (E) Issue 1126, the applying path (review of the seed): the composition novox applies follows the same rule. It
|
||||
@@ -393,16 +398,13 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("de-spiegel's own refusal refuses the node's whole composition: %v", err)
|
||||
}
|
||||
if len(held) != 2 || !strings.Contains(held["de-spiegel"], "${config:praktijk-name}") || !strings.Contains(held["proxy"], "de-spiegel") {
|
||||
t.Errorf("held %v, want de-spiegel in its config form's words, and proxy for its routes", held)
|
||||
if len(held) != 1 || !strings.Contains(held["de-spiegel"], "${config:praktijk-name}") || !strings.Contains(held["de-spiegel"], "without what it contributes to route") {
|
||||
t.Errorf("held %v, want de-spiegel alone, in its config form's words, composed without its routes", held)
|
||||
}
|
||||
lemursKey, _ := delivery.ComposedKey("n1", "lemurs", fps["lemurs"])
|
||||
proxyKey, _ := delivery.ComposedKey("n1", "proxy", fps["proxy"])
|
||||
if _, ok := got[lemursKey]; !ok {
|
||||
t.Errorf("lemurs, which composes, is not composed beside de-spiegel's refusal: %v", sortedKeys(got))
|
||||
}
|
||||
if _, ok := got[proxyKey]; ok {
|
||||
t.Errorf("proxy is composed without the routes of held de-spiegel: %v", sortedKeys(got))
|
||||
for _, m := range []string{"lemurs", "mailu", "proxy"} {
|
||||
if key, _ := delivery.ComposedKey("n1", m, fps[m]); got[key].Module != m {
|
||||
t.Errorf("%s is not composed beside de-spiegel's hold: %v", m, sortedKeys(got))
|
||||
}
|
||||
}
|
||||
carries := func(c delivery.Composed, what string) {
|
||||
t.Helper()
|
||||
@@ -433,8 +435,8 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
|
||||
|
||||
// (G) A held module that runs a composition of its own (review of the apply, round 1, finding 2): what the others
|
||||
// compose of it is what runs, never its stub's. site listens on 8080, placed at the machine's 20005, and its own
|
||||
// secret was never issued here: held, the filter must not open 8080 and drop the 20005 that runs. A module whose
|
||||
// composition depends on how site is held (here the filter) is held with it.
|
||||
// secret was never issued here: held, the filter must not open 8080 and drop the 20005 that runs. The filter, which
|
||||
// reads what site listens on, composes with it (issue 1960: a hold never spreads).
|
||||
site := []byte(`{"module":"site","version":"1","capabilities":["container-runtime"],` +
|
||||
`"listens":[{"port":8080,"from":"mesh","why":"the site"}],"own-secrets":{"key":"${dir:state}/key"},` +
|
||||
`"resources":[{"id":"state","type":"directory","path":"/var/lib/site","mode":"0700"},` +
|
||||
@@ -460,8 +462,12 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
|
||||
json.RawMessage(`{"id":"site.state","module":"site","type":"directory","path":"/var/lib/site","mode":"0700"}`),
|
||||
json.RawMessage(`{"id":"site.server","module":"site","type":"container","name":"site","image":"registry.example/site@sha256:` + strings.Repeat("c", 64) + `","ports":["10.77.0.1:20005:8080"]}`)}}
|
||||
got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", Running: []delivery.Composed{siteRuns}}, fps)
|
||||
if err != nil || held["site"] == "" || held["nft"] == "" {
|
||||
t.Fatalf("site, its own secret never issued: %v, held %v; want site held, and the filter, which depends on it", err, held)
|
||||
nftKey, _ := delivery.ComposedKey("n1", "nft", fps["nft"])
|
||||
if err != nil || held["site"] == "" || held["nft"] != "" || got[nftKey].Module != "nft" {
|
||||
t.Fatalf("site, its own secret never issued: %v, held %v; want site held alone, and the filter composed", err, held)
|
||||
}
|
||||
if rules, _ := json.Marshal(got[nftKey].Resources); !strings.Contains(string(rules), "dport 20005") {
|
||||
t.Errorf("the filter drops held site's opening where it runs (20005): %s", rules)
|
||||
}
|
||||
for key, c := range got {
|
||||
for _, raw := range c.Resources {
|
||||
@@ -470,10 +476,56 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// (G2) Issue 1126, the spreading hold: a held module is no dependence of its neighbours. site, held, provides page, and
|
||||
// app requires and binds it: app composes against what runs of site and is not held (novox's seed held every postgres
|
||||
// consumer behind de-spiegel's hold, "what it composes of …, held, is not what runs of them").
|
||||
provider := []byte(strings.Replace(string(site), `"listens":`, `"provides":[{"name":"page","scope":"node"}],"serves":{"page":{"port":8080}},"listens":`, 1))
|
||||
app := []byte(`{"module":"app","version":"1","capabilities":["systemd"],"requires":["page"],"binds":{"page":"/etc/app/page.json"},` +
|
||||
`"resources":[{"id":"service","type":"service","unit":"app.service","state":"running","boot":"enabled"}]}`)
|
||||
b2 := mk("lemurs", overlay.Name, "site", "nft", "app")
|
||||
for m, raw := range map[string][]byte{"site": provider, "nft": nft, "app": app} {
|
||||
b2.manifests[delivery.ManifestDigest(raw)] = raw
|
||||
b2.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)}
|
||||
}
|
||||
p2 := b2.placements["n1"]
|
||||
p2.Decided.Endpoint = "n1.example:51820"
|
||||
for i := range p2.Modules {
|
||||
if p2.Modules[i].Module == "site" {
|
||||
p2.Modules[i].Ports = map[string]int{"8080": 20005}
|
||||
}
|
||||
}
|
||||
b2.placements["n1"] = p2
|
||||
b2.records["n1"] = rec
|
||||
b2.held = true
|
||||
fps["app"] = fp("9")
|
||||
got, _, held, err = Compose(ctx, b2, Mesh{}, Scope{Node: "n1", Running: []delivery.Composed{siteRuns}}, fps)
|
||||
appKey, _ := delivery.ComposedKey("n1", "app", fps["app"])
|
||||
if _, composed := got[appKey]; err != nil || held["site"] == "" || held["app"] != "" || !composed {
|
||||
t.Errorf("a consumer of held site: %v, held %v; want site held, and app composed against what runs of it", err, held)
|
||||
}
|
||||
if bound, _ := json.Marshal(got[appKey].Resources); !strings.Contains(string(bound), "20005") {
|
||||
t.Errorf("app's binding does not reach held site where it runs (20005): %s", bound)
|
||||
}
|
||||
delete(fps, "app")
|
||||
// A held module's offer is left out by provision, never whole when one part composes (issue 1960): de-spiegel's
|
||||
// routes, its names unset, leave the proxy without them, and its smtp account stays with mailu.
|
||||
withProxy := compose.Input{Catalogue: map[string]catalogue.Manifest{},
|
||||
Nodes: []compose.Node{{Name: "n1", Assigned: []string{"feeder"}}}}
|
||||
feeder := catalogue.Manifest{Module: "feeder", Contributes: map[string]map[string]any{"route": {"name": "x"}, "smtp": {"account": "feeder"}},
|
||||
OwnSecrets: catalogue.OwnSecrets{"key": {Path: "/k"}}}
|
||||
for _, row := range []struct {
|
||||
left map[string]bool
|
||||
route, smtp, own bool
|
||||
}{{nil, true, true, true}, {map[string]bool{"route": true}, false, true, true}, {map[string]bool{leaveOwn: true}, true, true, false}, {map[string]bool{leaveAll: true}, false, false, false}} {
|
||||
k := heldAs(withProxy, "n1", "feeder", feeder, row.left, nil).Nodes[0].Kept["feeder"]
|
||||
if _, r := k.Contributes["route"]; r != row.route || (k.Contributes["smtp"] != nil) != row.smtp || (len(k.OwnSecrets) > 0) != row.own {
|
||||
t.Errorf("held leaving out %v offers %+v, own secrets %v", row.left, k.Contributes, k.OwnSecrets)
|
||||
}
|
||||
}
|
||||
// (H) Review of the apply, round 2: a held module is composed from its new manifest, so what the others read of its
|
||||
// manifest (its listens, what it contributes) may not be what runs. site's new manifest moves it to 9090 while the
|
||||
// build that runs listens on 8080: the filter, the holder of what site listens on, is held with it, and nothing
|
||||
// applied opens 9090.
|
||||
// build that runs (7) listens on 8080. The filter composes with what site last offered, its manifest at the build
|
||||
// that runs (issue 1960: a hold never spreads), so it opens 8080 and nothing applied opens 9090.
|
||||
moved := []byte(strings.Replace(string(site), `"port":8080`, `"port":9090`, 1))
|
||||
b.manifests[delivery.ManifestDigest(moved)] = moved
|
||||
b.modules["site"] = delivery.Module{Manifest: delivery.ManifestDigest(moved)}
|
||||
@@ -482,9 +534,15 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
|
||||
}
|
||||
b.placements["n1"] = p
|
||||
siteRuns.Resources[1] = json.RawMessage(`{"id":"site.server","module":"site","type":"container","name":"site","image":"registry.example/site@sha256:` + strings.Repeat("c", 64) + `","ports":["8080"]}`)
|
||||
siteRuns.FP = fp("7")
|
||||
b.manifests[delivery.ManifestDigest(site)] = site
|
||||
b.builds["site "+fp("7")] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: delivery.ManifestDigest(site)}
|
||||
got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", Running: []delivery.Composed{siteRuns}}, fps)
|
||||
if err != nil || held["site"] == "" || held["nft"] == "" {
|
||||
t.Errorf("site moved to 9090 in its new manifest: %v, held %v; want site held, and the filter with it", err, held)
|
||||
if err != nil || held["site"] == "" || held["nft"] != "" {
|
||||
t.Errorf("site moved to 9090 in its new manifest: %v, held %v; want site held alone", err, held)
|
||||
}
|
||||
if rules, _ := json.Marshal(got[nftKey].Resources); !strings.Contains(string(rules), "dport 8080") {
|
||||
t.Errorf("the filter drops what held site last offered (8080): %s", rules)
|
||||
}
|
||||
for key, c := range got {
|
||||
for _, raw := range c.Resources {
|
||||
|
||||
+259
-21
@@ -34,6 +34,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"sort"
|
||||
@@ -93,6 +94,15 @@ type SignersReader interface {
|
||||
VaultAuthority(ctx context.Context) string
|
||||
}
|
||||
|
||||
// HandedReader is a Reader that also says, of the composing node's modules, which the vault judged handed to the
|
||||
// provisioner when it answered OwnSecrets (the vault's `own`, its `handed`: catalogue.AssignmentHands of the plan in
|
||||
// each module's assignment). The node's composition then hands exactly those (compose.Node.Handed, issue 774), so the
|
||||
// vault and the node never judge one module two ways. Nil, or a Reader without it (a vault older than the field),
|
||||
// composes by the manifest and the machine, as before.
|
||||
type HandedReader interface {
|
||||
Handed(ctx context.Context, node string) map[string]bool
|
||||
}
|
||||
|
||||
// Mesh is what is true of the whole mesh and is no part's state on the bus yet: the private network's range and the
|
||||
// internal names' suffix as genesis was told them, and the mesh's authority certificate (public). Empty fields take
|
||||
// compose's defaults.
|
||||
@@ -177,13 +187,15 @@ func InOwn(running []delivery.Composed, p delivery.Placement) []string {
|
||||
|
||||
// Input is the composition input for s, read through r.
|
||||
func Input(ctx context.Context, r Reader, mesh Mesh, s Scope) (compose.Input, error) {
|
||||
return input(ctx, r, mesh, s, nil)
|
||||
return input(ctx, r, mesh, s, nil, nil)
|
||||
}
|
||||
|
||||
// input is Input; with refuse set, a module placed on the composing node whose build cannot be read or taken in is
|
||||
// handed to refuse and composed as a stub (compose.Stub, kept for that node alone) rather than ending the read, so a
|
||||
// node's answer names every module it cannot compose (issue 1126).
|
||||
func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module string, err error)) (compose.Input, error) {
|
||||
// input is Input; with refuse set, a module placed on the composing node whose build cannot be read or taken in, or whose
|
||||
// manifest the mesh refuses, is handed to refuse and composed as a stub (compose.Stub, kept for that node alone)
|
||||
// rather than ending the read, so a node's answer names every module it cannot compose (issue 1126, issue 774); a
|
||||
// module placed elsewhere whose manifest the mesh refuses is read for its offer alone (unusable). A manifest the bus does
|
||||
// not answer for still ends the read: that is the bus's, not the module's.
|
||||
func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module string, err error), wanted map[string]string) (compose.Input, error) {
|
||||
placements, err := r.Placements(ctx)
|
||||
if err != nil {
|
||||
return compose.Input{}, fmt.Errorf("frombus: placement: %w", err)
|
||||
@@ -240,12 +252,7 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
|
||||
in.Authority = delivery.AuthorityBundle(in.Authority, vault)
|
||||
}
|
||||
}
|
||||
parse := func(raw []byte) (catalogue.Manifest, error) {
|
||||
if mesh.Predefined != nil {
|
||||
return catalogue.ParseManifestWith(raw, mesh.Predefined)
|
||||
}
|
||||
return catalogue.ParseManifestForInputs(raw)
|
||||
}
|
||||
parse := parser(mesh)
|
||||
read := func(module, digest string) (catalogue.Manifest, error) {
|
||||
raw, err := r.Manifest(ctx, digest)
|
||||
if err != nil {
|
||||
@@ -253,14 +260,33 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
|
||||
}
|
||||
return parse(raw)
|
||||
}
|
||||
ownPlaced := map[string]bool{}
|
||||
for _, pm := range placements[s.Node].Modules {
|
||||
ownPlaced[pm.Module] = true
|
||||
}
|
||||
for _, name := range sortedKeys(modules) {
|
||||
m := modules[name]
|
||||
if m.Manifest == "" {
|
||||
continue // a registered module the indexer has not read: nothing to compose it from
|
||||
}
|
||||
man, err := read(name, m.Manifest)
|
||||
raw, err := r.Manifest(ctx, m.Manifest)
|
||||
if err != nil {
|
||||
return compose.Input{}, err
|
||||
// Not read is no refusal of the module: a bus that does not answer is waited out by the caller.
|
||||
return compose.Input{}, fmt.Errorf("frombus: the manifest of %s (digest %s): %w", name, m.Manifest, err)
|
||||
}
|
||||
man, err := parse(raw)
|
||||
if err != nil {
|
||||
if refuse == nil {
|
||||
return compose.Input{}, err
|
||||
}
|
||||
// One module's manifest the mesh refuses refuses that module alone, by the rule a build that cannot be read
|
||||
// follows (issue 774: power's bar item refused every node's composition, 2026-10-30). It stands in as its
|
||||
// unjudged stub, so what it provides and requires still shapes the rest; it is held on the composing node
|
||||
// where it is placed there, and elsewhere only its offer is read.
|
||||
man = unusable(name, raw)
|
||||
if ownPlaced[name] {
|
||||
refuse(name, err)
|
||||
}
|
||||
}
|
||||
in.Catalogue[name] = man
|
||||
}
|
||||
@@ -313,6 +339,8 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
|
||||
}
|
||||
}
|
||||
if node == s.Node {
|
||||
// What its runtime serves as it runs: what a module composed as what runs of it contributes there (issue 1126).
|
||||
n.RunsTools = compose.ToolsRunning(s.Running)
|
||||
for _, module := range s.InOwn {
|
||||
if n.InOwn == nil {
|
||||
n.InOwn = map[string]bool{}
|
||||
@@ -379,7 +407,7 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
|
||||
if _, ok := records[s.Node]; !ok {
|
||||
return compose.Input{}, fmt.Errorf("frombus: %s has written no record of itself yet", s.Node)
|
||||
}
|
||||
if err := fromBuilds(ctx, r, &in, s, placements[s.Node], modules, read, parse, refuse); err != nil {
|
||||
if err := fromBuilds(ctx, r, &in, s, placements[s.Node], modules, read, parse, refuse, wanted); err != nil {
|
||||
return compose.Input{}, err
|
||||
}
|
||||
// Its own modules' secrets, sealed to its own key by the vault: a composition carries them sealed (ADR 0550 §5).
|
||||
@@ -389,6 +417,16 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
|
||||
if in.OwnSecrets, err = r.OwnSecrets(ctx, s.Node); err != nil {
|
||||
return compose.Input{}, fmt.Errorf("frombus: the own secrets of %s's modules: %w", s.Node, err)
|
||||
}
|
||||
// And which of its modules the vault left out of that answer as handed: the node hands exactly those.
|
||||
if hr, ok := r.(HandedReader); ok {
|
||||
if handed := hr.Handed(ctx, s.Node); handed != nil {
|
||||
for i := range in.Nodes {
|
||||
if in.Nodes[i].Name == s.Node {
|
||||
in.Nodes[i].Handed = handed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// The core's credentials travel as the vault's box sealed to the machine (ADR 0349 §16): the same values, which
|
||||
// the composition reads for a core module as its kept boxes.
|
||||
@@ -411,7 +449,18 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
|
||||
// elsewhere, a stand-in for the node's own) refuses the node, err.
|
||||
func Compose(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]string) (out map[string]delivery.Composed, skipped []string, held map[string]string, err error) {
|
||||
held = map[string]string{}
|
||||
in, err := input(ctx, r, mesh, s, func(module string, err error) { held[module] = err.Error() })
|
||||
// A node refused whole says what it held on the way, since the refusal can be of a held module's stub (issue 774,
|
||||
// 2026-10-30 19:30: node-tools held, then "its build produced 0 bundle(s)" with its own refusal unsaid).
|
||||
defer func() {
|
||||
if err != nil && len(held) > 0 {
|
||||
var said []string
|
||||
for _, m := range sortedKeys(held) {
|
||||
said = append(said, m+": "+held[m])
|
||||
}
|
||||
err = fmt.Errorf("%w (held before: %s)", err, strings.Join(said, "; "))
|
||||
}
|
||||
}()
|
||||
in, err := input(ctx, r, mesh, s, func(module string, err error) { held[module] = err.Error() }, fps)
|
||||
if err != nil {
|
||||
return nil, nil, held, err
|
||||
}
|
||||
@@ -425,17 +474,171 @@ func Compose(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]s
|
||||
return nil, nil, held, fmt.Errorf("frombus: the credentials of %s: %w", s.Node, err)
|
||||
}
|
||||
}
|
||||
out, skipped, _, err = composeEach(in, s.Node, fps, true, s.Running, held)
|
||||
out, skipped, _, err = composeEach(in, s.Node, fps, true, s.Running, held, ranOf(ctx, r, mesh, s.Running, fps))
|
||||
return out, skipped, held, err
|
||||
}
|
||||
|
||||
// Plans is the provisioning plan of each module handed to the provisioner on the scope's node, composed from the bus
|
||||
// alone (novox/hq ADR 0570 clause 2): the controller's handedPlans over the same input (mesh-control
|
||||
// cmd/mesh-controller/compose_sdk.go), its rendering in the Foreseeing mode with the mesh's generators. A plan names
|
||||
// templates, files, own secrets by name, items, collections and provisions, never a value, so no value is read for it:
|
||||
// the seat mesh-provisioner's holder composes every node's plans with a Reader that reads no secret.
|
||||
func Plans(ctx context.Context, r Reader, mesh Mesh, s Scope) (map[string]catalogue.Plan, error) {
|
||||
in, err := Input(ctx, r, mesh, s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return PlansOf(in, s.Node)
|
||||
}
|
||||
|
||||
// PlansHeld is Plans composed module by module (issue 774: one bad module must not drop its node): a module placed on
|
||||
// the scope's node whose build cannot be read or taken in, or whose plan refuses the node's, is held, by the one rule
|
||||
// Compose follows (composeEach, culprit): composed as its stub so what it provides and requires still shapes the rest,
|
||||
// named in held with why, and none of its plan in plans. Only a refusal that is no single module's refuses the node,
|
||||
// err. The seat mesh-provisioner's holder composes so: a node with one bad manifest still has every other module's plan.
|
||||
func PlansHeld(ctx context.Context, r Reader, mesh Mesh, s Scope) (plans map[string]catalogue.Plan, held map[string]string, err error) {
|
||||
held = map[string]string{}
|
||||
in, err := input(ctx, r, mesh, s, func(module string, err error) { held[module] = err.Error() }, nil)
|
||||
if err != nil {
|
||||
return nil, held, err
|
||||
}
|
||||
plans, err = plansHeldOf(in, s.Node, held)
|
||||
return plans, held, err
|
||||
}
|
||||
|
||||
// plansHeldOf is the plans of node over in, every module in held held, and each module a refusal is of held too until
|
||||
// the plans compose (culprit's rule over the Planning mode: its stub first, then its bare stub).
|
||||
func plansHeldOf(in compose.Input, node string, held map[string]string) (map[string]catalogue.Plan, error) {
|
||||
for _, m := range sortedKeys(held) {
|
||||
in = stubbed(in, node, m, "")
|
||||
}
|
||||
compose1 := func(in compose.Input) (map[string]catalogue.Plan, error) { return plansIn(in, node, compose.Planning) }
|
||||
// Each try holds one module more or leaves out all of one held module's offer: bounded by twice the modules placed.
|
||||
for tries := 0; tries <= 2*len(assignedOn(in, node))+1; tries++ {
|
||||
plans, err := compose1(in)
|
||||
if err == nil {
|
||||
for m := range held {
|
||||
delete(plans, m)
|
||||
}
|
||||
return plans, nil
|
||||
}
|
||||
var order []string
|
||||
for _, m := range assignedOn(in, node) {
|
||||
if strings.Contains(err.Error(), m) && len(order) < maxCulprits {
|
||||
order = append(order, m)
|
||||
}
|
||||
}
|
||||
module, leave := "", ""
|
||||
find:
|
||||
for _, l := range []string{"", leaveAll} {
|
||||
for _, m := range order {
|
||||
if _, done := held[m]; done && l == "" {
|
||||
continue
|
||||
}
|
||||
if _, again := compose1(stubbed(in, node, m, l)); again == nil || again.Error() != err.Error() {
|
||||
module, leave = m, l
|
||||
break find
|
||||
}
|
||||
}
|
||||
}
|
||||
if module == "" {
|
||||
return nil, err
|
||||
}
|
||||
if _, done := held[module]; !done {
|
||||
held[module] = err.Error()
|
||||
}
|
||||
in = stubbed(in, node, module, leave)
|
||||
}
|
||||
return nil, fmt.Errorf("frombus: the plans of %s do not compose with %d module(s) held: %s", node, len(held),
|
||||
strings.Join(sortedKeys(held), ", "))
|
||||
}
|
||||
|
||||
// PlansOf is Plans over an input already filled, rendered in the Planning mode (compose.Planning): every own secret the
|
||||
// input holds none for stands in, so no value and no record of one is needed.
|
||||
func PlansOf(in compose.Input, node string) (map[string]catalogue.Plan, error) {
|
||||
return plansIn(in, node, compose.Planning)
|
||||
}
|
||||
|
||||
// plansIn is the controller's handedPlans in a mode: Foreseeing is the controller's own.
|
||||
func plansIn(in compose.Input, node string, mode compose.Mode) (map[string]catalogue.Plan, error) {
|
||||
resolved, err := compose.Resolve(in, node)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("frombus: the plans of %s: %w", node, err)
|
||||
}
|
||||
rendered, err := compose.Render(in, node, mode)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("frombus: the plans of %s: %w", node, err)
|
||||
}
|
||||
gens, err := compose.Generators(in)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("frombus: the plans of %s: %w", node, err)
|
||||
}
|
||||
with := rendered.Rendering
|
||||
with.Generators = gens
|
||||
composed, err := resolved.Plan.Compose(with)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("frombus: the plans of %s: %w", node, err)
|
||||
}
|
||||
return composed.Plans, nil
|
||||
}
|
||||
|
||||
// unusable is what stands in for a module's manifest the mesh refuses: its stub (compose.Stub)
|
||||
// read from its JSON unjudged, so what it provides, requires and serves still shapes the rest, and without its items,
|
||||
// since nothing judged what they would put into another module's collection. Bytes that are no manifest of the module
|
||||
// stand in as a stub that offers nothing.
|
||||
func unusable(module string, raw []byte) catalogue.Manifest {
|
||||
var m catalogue.Manifest
|
||||
if json.Unmarshal(raw, &m) != nil || m.Module != module {
|
||||
m = catalogue.Manifest{Module: module}
|
||||
}
|
||||
m.Items = nil
|
||||
return compose.Stub(m)
|
||||
}
|
||||
|
||||
// parser reads a manifest as the indexer does, against the mesh's predefined keys when given.
|
||||
func parser(mesh Mesh) func([]byte) (catalogue.Manifest, error) {
|
||||
return func(raw []byte) (catalogue.Manifest, error) {
|
||||
if mesh.Predefined != nil {
|
||||
return catalogue.ParseManifestWith(raw, mesh.Predefined)
|
||||
}
|
||||
return catalogue.ParseManifestForInputs(raw)
|
||||
}
|
||||
}
|
||||
|
||||
// ranOf is what a module last offered the others, read when it is held (composeEach, issue 1960): its manifest at the
|
||||
// build its own composition runs at (running), where that is another build than the one it is composed at (fps) and
|
||||
// the builder holds it done. False where nothing is known of it (it runs no composition of its own, it runs the build
|
||||
// it is composed at, or its build is unread): the manifest it is composed from is its offer then. Only what it says to
|
||||
// others is taken of it, which needs no take-in of its artifacts.
|
||||
func ranOf(ctx context.Context, r Reader, mesh Mesh, running []delivery.Composed, fps map[string]string) func(string) (catalogue.Manifest, bool) {
|
||||
parse := parser(mesh)
|
||||
return func(module string) (catalogue.Manifest, bool) {
|
||||
for _, c := range running {
|
||||
if c.Module != module || c.FP == "" || c.FP == fps[module] {
|
||||
continue
|
||||
}
|
||||
b, err := r.Build(ctx, module, c.FP)
|
||||
if err != nil || b == nil || b.State != delivery.BuildDone || b.Manifest == "" {
|
||||
return catalogue.Manifest{}, false
|
||||
}
|
||||
raw, err := r.Manifest(ctx, b.Manifest)
|
||||
if err != nil {
|
||||
return catalogue.Manifest{}, false
|
||||
}
|
||||
man, err := parse(raw)
|
||||
return man, err == nil
|
||||
}
|
||||
return catalogue.Manifest{}, false
|
||||
}
|
||||
}
|
||||
|
||||
// fromBuilds puts in the built manifest of each module placed on the composing node, at the trunk's FP2, and of each
|
||||
// module it keeps at another build (Scope.KeptAt, into its node's Kept), read from the builder's done key and resolved
|
||||
// by the one take-in rule (takein.Manifest), a templates archive read in the artifact store as the node reaches it
|
||||
// (issue 1126).
|
||||
func fromBuilds(ctx context.Context, r Reader, in *compose.Input, s Scope, p delivery.Placement, modules map[string]delivery.Module,
|
||||
read func(module, digest string) (catalogue.Manifest, error), parse func([]byte) (catalogue.Manifest, error),
|
||||
refuse func(module string, err error)) error {
|
||||
refuse func(module string, err error), wanted map[string]string) error {
|
||||
// stub keeps a refused module in the composition as a stub for this node alone (compose.Stub): what it provides
|
||||
// and requires still shapes the rest.
|
||||
stub := func(module string, err error) {
|
||||
@@ -495,9 +698,27 @@ func fromBuilds(ctx context.Context, r Reader, in *compose.Input, s Scope, p del
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
// A module wanted at another build than the trunk's is composed from the build its want names (issue 774), as one
|
||||
// the scope keeps at another build is.
|
||||
keptAt := maps.Clone(s.KeptAt)
|
||||
for _, pm := range p.Modules {
|
||||
if m, ok := modules[pm.Module]; ok && m.FP2 != "" && wanted[pm.Module] != "" && wanted[pm.Module] != m.FP2 &&
|
||||
keptAt[pm.Module] == "" && !slices.Contains(s.InOwn, pm.Module) {
|
||||
if keptAt == nil {
|
||||
keptAt = map[string]string{}
|
||||
}
|
||||
keptAt[pm.Module] = wanted[pm.Module]
|
||||
}
|
||||
}
|
||||
runs := map[string]bool{}
|
||||
for _, c := range s.Running {
|
||||
if c.Module != "" {
|
||||
runs[c.Module] = true
|
||||
}
|
||||
}
|
||||
for _, pm := range p.Modules {
|
||||
m, ok := modules[pm.Module]
|
||||
if !ok || m.FP2 == "" || s.KeptAt[pm.Module] != "" || slices.Contains(s.InOwn, pm.Module) {
|
||||
if !ok || m.FP2 == "" || keptAt[pm.Module] != "" || slices.Contains(s.InOwn, pm.Module) {
|
||||
// A module it runs inside its own composition is composed there as before: no build of the new form is what
|
||||
// it runs (Scope.InOwn).
|
||||
continue
|
||||
@@ -515,7 +736,14 @@ func fromBuilds(ctx context.Context, r Reader, in *compose.Input, s Scope, p del
|
||||
continue
|
||||
}
|
||||
if b == nil || b.State != delivery.BuildDone {
|
||||
continue // no done build at the trunk's: composed from the indexer's manifest, as before
|
||||
if refuse != nil && runs[pm.Module] {
|
||||
// A module that runs is never composed from the indexer's manifest, which names no build's artifacts (no
|
||||
// runtime's bundle): it is held, keeping what runs, until its build lands (issue 774, 2026-10-30 19:30:
|
||||
// node-tools on ace, g14 and shanks, "its build produced 0 bundle(s)", and every module refused).
|
||||
stub(pm.Module, fmt.Errorf("frombus: %s is wanted at %s, of which the builder holds no done build yet; it "+
|
||||
"keeps running what it runs", pm.Module, m.FP2))
|
||||
}
|
||||
continue // nothing of it runs: composed from the indexer's manifest, as before
|
||||
}
|
||||
resolved, err := built(pm.Module, m.FP2, b)
|
||||
if err != nil {
|
||||
@@ -531,8 +759,8 @@ func fromBuilds(ctx context.Context, r Reader, in *compose.Input, s Scope, p del
|
||||
if in.Nodes[i].Name != s.Node {
|
||||
continue
|
||||
}
|
||||
for _, module := range sortedKeys(s.KeptAt) {
|
||||
fp := s.KeptAt[module]
|
||||
for _, module := range sortedKeys(keptAt) {
|
||||
fp := keptAt[module]
|
||||
b, err := r.Build(ctx, module, fp)
|
||||
switch {
|
||||
case err != nil:
|
||||
@@ -777,3 +1005,13 @@ func membershipOf(own delivery.Composed) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// DataItems is every data item the modules placed on scope's node declare, placed there, composed from the bus as Plans
|
||||
// composes (novox/hq ADR 0571 clause 3): disk-load measures these.
|
||||
func DataItems(ctx context.Context, r Reader, mesh Mesh, s Scope) (map[string][]catalogue.PlacedDataItem, []string, error) {
|
||||
in, err := Input(ctx, r, mesh, s)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return compose.DataItems(in, s.Node)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"os"
|
||||
"reflect"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -26,6 +27,7 @@ import (
|
||||
// bus is a Reader over values held in memory, counting whose config values were read.
|
||||
type bus struct {
|
||||
vaultAuthority string
|
||||
handed map[string]bool
|
||||
placements map[string]delivery.Placement
|
||||
records map[string]delivery.NodeRecord
|
||||
modules map[string]delivery.Module
|
||||
@@ -45,6 +47,8 @@ type bus struct {
|
||||
served map[string]map[string]any
|
||||
servedRead []string
|
||||
buildsRead []string
|
||||
// held answers every credential asked as held, sealed to the consumer.
|
||||
held bool
|
||||
}
|
||||
|
||||
func (b *bus) Build(_ context.Context, module, fp string) (*delivery.BuilderBuild, error) {
|
||||
@@ -76,6 +80,7 @@ func (b *bus) Placements(context.Context) (map[string]delivery.Placement, error)
|
||||
return b.placements, nil
|
||||
}
|
||||
func (b *bus) VaultAuthority(context.Context) string { return b.vaultAuthority }
|
||||
func (b *bus) Handed(context.Context, string) map[string]bool { return b.handed }
|
||||
func (b *bus) Records(context.Context) (map[string]delivery.NodeRecord, error) { return b.records, nil }
|
||||
func (b *bus) Modules(context.Context) (map[string]delivery.Module, error) { return b.modules, nil }
|
||||
func (b *bus) Manifest(_ context.Context, digest string) ([]byte, error) {
|
||||
@@ -89,7 +94,7 @@ func (b *bus) OwnSecrets(context.Context, string) ([]compose.OwnSecretRow, error
|
||||
return b.own, b.ownErr
|
||||
}
|
||||
|
||||
func (b *bus) Credentials(_ context.Context, node string, asConsumer, _ []compose.CredentialKey) ([]compose.Credential, []compose.Issued, error) {
|
||||
func (b *bus) Credentials(_ context.Context, node string, asConsumer, asProvider []compose.CredentialKey) ([]compose.Credential, []compose.Issued, error) {
|
||||
b.asked = append(b.asked, asConsumer...)
|
||||
for _, k := range asConsumer {
|
||||
placed := false
|
||||
@@ -100,7 +105,17 @@ func (b *bus) Credentials(_ context.Context, node string, asConsumer, _ []compos
|
||||
return nil, nil, fmt.Errorf("%s is not on %s: neither assigned there nor required by a module there", k.ConsumerModule, node)
|
||||
}
|
||||
}
|
||||
return nil, nil, nil
|
||||
var out []compose.Credential
|
||||
var issued []compose.Issued
|
||||
for _, k := range append(append([]compose.CredentialKey{}, asConsumer...), asProvider...) {
|
||||
if b.held {
|
||||
out = append(out, compose.Credential{Provision: k.Provision, Consumer: k.Consumer, ConsumerModule: k.ConsumerModule,
|
||||
Provider: k.Provider, Local: k.Local, State: compose.CredentialHeld, Sealed: `"box"`})
|
||||
issued = append(issued, compose.Issued{Provision: k.Provision, Consumer: k.Consumer, ConsumerModule: k.ConsumerModule,
|
||||
Provider: k.Provider, Local: k.Local, ForProvider: `"box"`})
|
||||
}
|
||||
}
|
||||
return out, issued, nil
|
||||
}
|
||||
|
||||
func (b *bus) Values(_ context.Context, node, module string) (map[string]any, error) {
|
||||
@@ -125,6 +140,12 @@ func TestInput(t *testing.T) {
|
||||
// saying why (answer_rows_test.go).
|
||||
t.Run("every refusal, and an old build left to the node", answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode)
|
||||
t.Run("no store reached says why", noStoreReachedSaysWhy)
|
||||
// Issue 774: a node refused whole says what it held on the way.
|
||||
t.Run("a node refused whole says what it held", aNodeRefusedWholeSaysWhatItHeld)
|
||||
t.Run("the runtime is blamed only where its stub composes", theRuntimeIsBlamedOnlyWhereItsStubComposes)
|
||||
t.Run("a running module waits for its build", aRunningModuleWaitsForItsBuild)
|
||||
t.Run("a held module's first reason leads", aHeldModulesFirstReasonLeads)
|
||||
t.Run("a held module of its own account never refuses the node", aHeldModuleOfItsOwnAccountNeverRefusesTheNode)
|
||||
t.Run("another node not resolved is named", otherNodeNotResolvedIsNamed)
|
||||
ctx := context.Background()
|
||||
xterm, err := os.ReadFile("../../catalogue/testdata/catalogue-835bf22b-xterm.module.json")
|
||||
@@ -226,6 +247,13 @@ func TestInput(t *testing.T) {
|
||||
bundled.Authority != "the mesh's certificate\nthe vault's certificate\n" {
|
||||
t.Errorf("the authority with the vault's published: %q, %v", bundled.Authority, err)
|
||||
}
|
||||
// Issue 774: the modules the vault judged handed when it answered own are the node's to hand, and no other node's.
|
||||
judged := mk()
|
||||
judged.handed = map[string]bool{"media": true, "xterm": false}
|
||||
if in, err := Input(ctx, judged, Mesh{}, Scope{Node: "laptop"}); err != nil ||
|
||||
!reflect.DeepEqual(in.Nodes[1].Handed, judged.handed) || in.Nodes[0].Handed != nil {
|
||||
t.Errorf("the vault's handed verdicts: %+v, %v", in.Nodes, err)
|
||||
}
|
||||
if !reflect.DeepEqual(b.valuesRead, []string{"laptop." + overlay.Name, "laptop.xterm", "laptop.media"}) {
|
||||
t.Errorf("laptop read config values %v; a node reads its own slice alone", b.valuesRead)
|
||||
}
|
||||
@@ -682,6 +710,47 @@ func TestInput(t *testing.T) {
|
||||
wj, _ := json.MarshalIndent(fromStoreMoved, "", " ")
|
||||
t.Errorf("composed from the build differs from the controller's take-in:\n got %s\nwant %s", gj, wj)
|
||||
}
|
||||
// ADR 0570 clause 2 (issue 1954 L6-P1): the seat mesh-provisioner's plans, composed from the bus alone and with no
|
||||
// secret read (the Planning mode), are the controller's handedPlans over its own input, which holds its secrets
|
||||
// (compose_sdk.go: Resolve, Render in Foreseeing, Generators, Plan.Compose), and restic, handed, has one.
|
||||
noSecrets := movedMesh(true)
|
||||
noSecrets.own = nil
|
||||
seat, err := Plans(ctx, noSecrets, Mesh{}, Scope{Node: "n1"})
|
||||
if err != nil {
|
||||
t.Fatalf("the plans from the bus: %v", err)
|
||||
}
|
||||
controllers, err := plansIn(storeIn, "n1", compose.Foreseeing)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, handed := seat["restic"]; !handed || !reflect.DeepEqual(seat, controllers) {
|
||||
gj, _ := json.MarshalIndent(seat, "", " ")
|
||||
wj, _ := json.MarshalIndent(controllers, "", " ")
|
||||
t.Errorf("the seat's plans differ from the controller's:\n got %s\nwant %s", gj, wj)
|
||||
}
|
||||
// ADR 0571 clause 3: the data items of n1's modules, placed, from the bus alone: what disk-load measures.
|
||||
items, unplaced, err := DataItems(ctx, noSecrets, Mesh{}, Scope{Node: "n1"})
|
||||
if err != nil || len(unplaced) > 0 || len(items) == 0 {
|
||||
t.Errorf("the data items from the bus are %v, unplaced %v: %v", items, unplaced, err)
|
||||
}
|
||||
for module, its := range items {
|
||||
for _, it := range its {
|
||||
if it.Module != module || !strings.HasPrefix(it.Path, "/") || it.Measure == "" {
|
||||
t.Errorf("%s's item %+v is not placed", module, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
// And the same through StatesReader, over the fake's states as a module's runtime gives them (one reader for
|
||||
// mesh-provisioner and disk-load, the lead 2026-10-29).
|
||||
viaStates, _, err := DataItems(ctx, StatesReader(statesOf(t, ctx, noSecrets), func(_ context.Context, url string) ([]byte, error) {
|
||||
for _, body := range noSecrets.archives {
|
||||
return body, nil
|
||||
}
|
||||
return nil, errors.New("no archive")
|
||||
}), Mesh{}, Scope{Node: "n1"})
|
||||
if err != nil || !reflect.DeepEqual(viaStates, items) {
|
||||
t.Errorf("through the states the data items are %v, %v; through the reader %v", viaStates, err, items)
|
||||
}
|
||||
|
||||
// And what a provider on another node serves from its settings (mailu's smtp domain, issue 1126: shanks refused
|
||||
// '${setting:domain} ... nothing sets "domain"'): read for that provider's served keys alone, through Served.
|
||||
@@ -727,3 +796,73 @@ func TestInput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mapStates are read states by name, key to value, as a module's runtime gives them.
|
||||
type mapStates map[string]map[string][]byte
|
||||
|
||||
func (m mapStates) Get(state, key string) ([]byte, bool, error) {
|
||||
v, ok := m[state][key]
|
||||
return v, ok, nil
|
||||
}
|
||||
|
||||
func (m mapStates) Keys(state string) ([]string, error) {
|
||||
var out []string
|
||||
for k := range m[state] {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// statesOf is b laid out as the states StatesReader reads.
|
||||
func statesOf(t *testing.T, ctx context.Context, b *bus) mapStates {
|
||||
t.Helper()
|
||||
out := mapStates{}
|
||||
put := func(state, key string, raw []byte, err error) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out[state] == nil {
|
||||
out[state] = map[string][]byte{}
|
||||
}
|
||||
out[state][key] = raw
|
||||
}
|
||||
placements, _ := b.Placements(ctx)
|
||||
for _, p := range placements {
|
||||
values, err := delivery.PlacementValues(p)
|
||||
for k, raw := range values {
|
||||
put(PlacementRead, k, raw, err)
|
||||
}
|
||||
}
|
||||
records, _ := b.Records(ctx)
|
||||
for node, r := range records {
|
||||
k, _ := delivery.HaveNodeKey(node)
|
||||
rec := r
|
||||
raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Record: &rec})
|
||||
put(HaveRead, k, raw, err)
|
||||
}
|
||||
modules, _ := b.Modules(ctx)
|
||||
for name, m := range modules {
|
||||
raw, err := delivery.EncodeModule(m)
|
||||
put(ModulesRead, name, raw, err)
|
||||
var mf struct{ Module string }
|
||||
_ = json.Unmarshal(b.manifests[m.Manifest], &mf)
|
||||
k, _ := delivery.ManifestsKey(m.Manifest)
|
||||
raw, err = delivery.EncodeManifest(delivery.Manifest{Module: mf.Module, JSON: string(b.manifests[m.Manifest])})
|
||||
put(ManifestsRead, k, raw, err)
|
||||
}
|
||||
for key, build := range b.builds {
|
||||
module, fp, _ := strings.Cut(key, " ")
|
||||
k, _ := delivery.BuilderBuildKey(module, fp)
|
||||
raw, err := delivery.EncodeBuilderBuild(*build)
|
||||
put(BuildsRead, k, raw, err)
|
||||
}
|
||||
for nm, values := range b.values {
|
||||
for k, v := range values {
|
||||
value, _ := json.Marshal(v)
|
||||
raw, err := json.Marshal(map[string]any{"value": json.RawMessage(value), "source": catalogue.SourceModule})
|
||||
put(ResolvedRead, nm+"."+k, raw, err)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
// Package frombustest is a bus in memory for frombus's Reader, and a mesh on it whose composition hands a module to the
|
||||
// provisioner with a provision to make, so a module that composes plans from the bus (mesh-provisioner, novox/hq ADR
|
||||
// 0570 clause 2) tests against a real plan rather than one written by hand. The manifests are the catalogue's (mesh-catalog
|
||||
// c05aa5e6: restic and the provisioner's client) as frombus's own tests read them, restic given one requirement more.
|
||||
package frombustest
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose/frombus"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
//go:embed testdata
|
||||
var testdata embed.FS
|
||||
|
||||
// Bus is a frombus.Reader over values held in memory. A credential asked is answered held when Held is set.
|
||||
type Bus struct {
|
||||
Placement map[string]delivery.Placement
|
||||
Record map[string]delivery.NodeRecord
|
||||
Module map[string]delivery.Module
|
||||
Manifests map[string][]byte
|
||||
Value map[string]map[string]any // by node.module
|
||||
Own []compose.OwnSecretRow
|
||||
Builds map[string]*delivery.BuilderBuild // by "<module> <fp>"
|
||||
Archives map[string][]byte // by digest
|
||||
Serves map[string]map[string]any // another node's values, by node.module
|
||||
Held bool
|
||||
}
|
||||
|
||||
var _ frombus.Reader = (*Bus)(nil)
|
||||
|
||||
func (b *Bus) Placements(context.Context) (map[string]delivery.Placement, error) {
|
||||
return b.Placement, nil
|
||||
}
|
||||
func (b *Bus) Records(context.Context) (map[string]delivery.NodeRecord, error) { return b.Record, nil }
|
||||
func (b *Bus) Modules(context.Context) (map[string]delivery.Module, error) { return b.Module, nil }
|
||||
func (b *Bus) VaultAuthority(context.Context) string { return "" }
|
||||
|
||||
func (b *Bus) Manifest(_ context.Context, digest string) ([]byte, error) {
|
||||
raw, ok := b.Manifests[digest]
|
||||
if !ok {
|
||||
return nil, errors.New("no such manifest")
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func (b *Bus) Values(_ context.Context, node, module string) (map[string]any, error) {
|
||||
return b.Value[node+"."+module], nil
|
||||
}
|
||||
|
||||
func (b *Bus) OwnSecrets(context.Context, string) ([]compose.OwnSecretRow, error) { return b.Own, nil }
|
||||
|
||||
func (b *Bus) Credentials(_ context.Context, _ string, asConsumer, asProvider []compose.CredentialKey) ([]compose.Credential, []compose.Issued, error) {
|
||||
var out []compose.Credential
|
||||
var issued []compose.Issued
|
||||
for _, k := range append(append([]compose.CredentialKey{}, asConsumer...), asProvider...) {
|
||||
if b.Held {
|
||||
out = append(out, compose.Credential{Provision: k.Provision, Consumer: k.Consumer, ConsumerModule: k.ConsumerModule,
|
||||
Provider: k.Provider, Local: k.Local, State: compose.CredentialHeld, Sealed: `"box"`})
|
||||
issued = append(issued, compose.Issued{Provision: k.Provision, Consumer: k.Consumer, ConsumerModule: k.ConsumerModule,
|
||||
Provider: k.Provider, Local: k.Local, ForProvider: `"box"`})
|
||||
}
|
||||
}
|
||||
return out, issued, nil
|
||||
}
|
||||
|
||||
func (b *Bus) Build(_ context.Context, module, fp string) (*delivery.BuilderBuild, error) {
|
||||
return b.Builds[module+" "+fp], nil
|
||||
}
|
||||
|
||||
func (b *Bus) Archive(_ context.Context, _, digest string) ([]byte, error) {
|
||||
body, ok := b.Archives[digest]
|
||||
if !ok {
|
||||
return nil, errors.New("the store holds no such blob")
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func (b *Bus) Served(_ context.Context, node, module string, keys []string) (map[string]any, error) {
|
||||
out := map[string]any{}
|
||||
for _, k := range keys {
|
||||
if v, ok := b.Serves[node+"."+module][k]; ok {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Node is the one node of the mesh Provisioned composes.
|
||||
const Node = "n1"
|
||||
|
||||
// Provisioned is one node, n1, that runs the artifact store, a postgres providing postgres-database, the provisioner's
|
||||
// client and restic: restic built and handed to the provisioner, and requiring postgres-database. Its plans (frombus.Plans
|
||||
// with Scope{Node: Node}) hand restic, whose plan names that provision with postgres on n1 as its provider.
|
||||
func Provisioned() (*Bus, error) {
|
||||
read := func(name string) ([]byte, error) { return testdata.ReadFile("testdata/" + name) }
|
||||
resticRaw, err := read("catalogue-c05aa5e6-restic.module.json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var restic map[string]any
|
||||
if err := json.Unmarshal(resticRaw, &restic); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
restic["requires"] = []string{"postgres-database"}
|
||||
if resticRaw, err = json.Marshal(restic); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clientRaw, err := read("catalogue-c05aa5e6-mesh-provisioner-client.module.json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
store := []byte(`{"module":"store","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],` +
|
||||
`"serves":{"artifact-store":{"port":5000}}}`)
|
||||
postgres := []byte(`{"module":"postgres","version":"1","provides":[{"name":"postgres-database","scope":"mesh"}],` +
|
||||
`"serves":{"postgres-database":{"port":5432}}}`)
|
||||
|
||||
var packed bytes.Buffer
|
||||
zw := gzip.NewWriter(&packed)
|
||||
tw := tar.NewWriter(zw)
|
||||
for _, f := range []string{"backups.conf", "data.conf", "retention.conf"} {
|
||||
text, err := read("catalogue-c05aa5e6-restic.files/" + f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tw.WriteHeader(&tar.Header{Name: "./" + f, Mode: 0o644, Size: int64(len(text)), Typeflag: tar.TypeReg}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := tw.Write(text); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
archive := delivery.ManifestDigest(packed.Bytes())
|
||||
digestOf := func(c string) string { return "sha256:" + strings.Repeat(c, 64) }
|
||||
fps := FPs()
|
||||
b := &Bus{
|
||||
Placement: map[string]delivery.Placement{Node: {Node: Node,
|
||||
Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1", Account: "operator", AccountHome: "/home/operator"},
|
||||
Modules: []delivery.PlacedModule{{Module: "store"}, {Module: "postgres"}, {Module: "mesh-provisioner-client"}, {Module: "restic"}}}},
|
||||
Record: map[string]delivery.NodeRecord{Node: {Node: Node, TunnelKey: "dGVzdGtleXRlc3RrZXl0ZXN0a2V5dGVzdGtleXRlc3Q=",
|
||||
SealingKey: "sk", Contract: compose.HandedContract, Capabilities: []string{"systemd", "package-manager"}}},
|
||||
Module: map[string]delivery.Module{}, Manifests: map[string][]byte{},
|
||||
Own: []compose.OwnSecretRow{{Node: Node, Module: "mesh-provisioner-client", Name: "provisioner-broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}},
|
||||
Builds: map[string]*delivery.BuilderBuild{
|
||||
"restic " + fps["restic"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(resticRaw), Artifacts: []delivery.BuiltArtifact{
|
||||
{Name: "tools", Kind: "bundle", Reference: "artifact-store://restic/tools/blobs/" + digestOf("6"), Digest: digestOf("6")},
|
||||
{Name: "files", Kind: "archive", Reference: "artifact-store://restic/files/blobs/" + archive, Digest: archive}}},
|
||||
"mesh-provisioner-client " + fps["mesh-provisioner-client"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(clientRaw),
|
||||
Artifacts: []delivery.BuiltArtifact{{Name: "client", Kind: "bundle",
|
||||
Reference: "artifact-store://mesh-provisioner-client/client/blobs/" + digestOf("7"), Digest: digestOf("7")}}},
|
||||
"store " + fps["store"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(store)},
|
||||
"postgres " + fps["postgres"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(postgres)},
|
||||
},
|
||||
Archives: map[string][]byte{archive: packed.Bytes()},
|
||||
Held: true,
|
||||
}
|
||||
for module, raw := range map[string][]byte{"restic": resticRaw, "mesh-provisioner-client": clientRaw, "store": store, "postgres": postgres} {
|
||||
b.Module[module] = delivery.Module{FP2: fps[module], Manifest: delivery.ManifestDigest(raw)}
|
||||
b.Manifests[delivery.ManifestDigest(raw)] = raw
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// FPs are the fingerprints Provisioned's modules are built at.
|
||||
func FPs() map[string]string {
|
||||
return map[string]string{"restic": "src2:" + strings.Repeat("3", 64), "mesh-provisioner-client": "src2:" + strings.Repeat("4", 64),
|
||||
"store": "src2:" + strings.Repeat("5", 64), "postgres": "src2:" + strings.Repeat("8", 64)}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package frombustest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/compose/frombus"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// ADR 0570 clause 2 (issue 1954; a3's H5): the in-memory mesh composes a plan from the bus that hands restic to the
|
||||
// provisioner and names its postgres-database, provided by postgres on n1. And one bad module must not drop its node
|
||||
// (issue 774): with a module placed beside it whose built manifest cannot be used, or whose plan refuses, PlansHeld
|
||||
// still hands restic's plan and names the bad one with why, where Plans refuses the whole node. A manifest the indexer
|
||||
// holds that the mesh refuses (power's bar item with attributes its bar places by none of, mesh-catalog ca8d6f33,
|
||||
// 2026-10-30) holds that module alone on its node, in the plans and in the node's composition (Compose), and on a node
|
||||
// it is not placed on holds nothing.
|
||||
func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) {
|
||||
fp := "src2:" + strings.Repeat("9", 64)
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
bad string // the indexer's manifest of a module "backup" placed on n1; empty places none
|
||||
// built is the manifest backup's done build names; empty gives it no build
|
||||
built string
|
||||
why string // words backup's hold says
|
||||
// elsewhere leaves backup in the indexer's modules and places it on no node: nothing is held.
|
||||
elsewhere bool
|
||||
}{
|
||||
{name: "every module composes"},
|
||||
{name: "a built manifest that cannot be used",
|
||||
bad: `{"module":"backup","version":"1","build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`,
|
||||
built: `{"module":"backup","version":"1","requires":["amqp"]}`, why: "backup"},
|
||||
{name: "a plan that refuses",
|
||||
bad: `{"module":"backup","version":"1","resources":[{"id":"conf","type":"file","path":"/etc/backup.conf",` +
|
||||
`"mode":"0600","content":"key=${secret:nope}"}]}`, why: "${secret:nope}"},
|
||||
{name: "an indexer's manifest the mesh refuses", bad: unusableItem, why: `"shows" is none of them`},
|
||||
{name: "an indexer's manifest the mesh refuses, placed elsewhere", bad: unusableItem, elsewhere: true},
|
||||
} {
|
||||
b, err := Provisioned()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if row.bad != "" {
|
||||
if !row.elsewhere {
|
||||
p := b.Placement[Node]
|
||||
p.Modules = append(p.Modules, delivery.PlacedModule{Module: "backup"})
|
||||
b.Placement[Node] = p
|
||||
}
|
||||
digest := delivery.ManifestDigest([]byte(row.bad))
|
||||
b.Manifests[digest] = []byte(row.bad)
|
||||
b.Module["backup"] = delivery.Module{FP2: fp, Manifest: digest}
|
||||
if row.built != "" {
|
||||
builtDigest := delivery.ManifestDigest([]byte(row.built))
|
||||
b.Manifests[builtDigest] = []byte(row.built)
|
||||
b.Builds["backup "+fp] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: builtDigest,
|
||||
Artifacts: []delivery.BuiltArtifact{{Name: "files", Kind: "archive", Reference: "artifact-store://backup/files/blobs/x", Digest: "sha256:x"}}}
|
||||
}
|
||||
if _, err := frombus.Plans(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node}); err == nil && !row.elsewhere {
|
||||
t.Errorf("%s: Plans composed the node; the row does not refuse it", row.name)
|
||||
}
|
||||
}
|
||||
if row.why != "" || row.bad == "" {
|
||||
// The node's own composition holds by the same rule: every other module composed, the bad one named.
|
||||
fps := FPs()
|
||||
if row.bad != "" {
|
||||
fps["backup"] = fp
|
||||
}
|
||||
out, _, held, err := frombus.Compose(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node}, fps)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: the node's composition: %v", row.name, err)
|
||||
}
|
||||
if key, _ := delivery.ComposedKey(Node, "restic", fps["restic"]); out[key].Module != "restic" {
|
||||
t.Errorf("%s: restic is not composed (held %v)", row.name, held)
|
||||
}
|
||||
if why, ok := held["backup"]; (row.bad == "" && len(held) > 0) || (row.bad != "" && (!ok || !strings.Contains(why, row.why) || len(held) != 1)) {
|
||||
t.Errorf("%s: the node's composition held %v, want backup alone, saying %q", row.name, held, row.why)
|
||||
}
|
||||
}
|
||||
plans, held, err := frombus.PlansHeld(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node})
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", row.name, err)
|
||||
}
|
||||
restic, handed := plans["restic"]
|
||||
if !handed || len(restic.Provisions) != 1 || restic.Provisions[0].Provision != "postgres-database" ||
|
||||
restic.Provisions[0].Provider != Node || restic.Provisions[0].ProviderModule != "postgres" {
|
||||
t.Fatalf("%s: the plans are %+v (held %v)", row.name, plans, held)
|
||||
}
|
||||
if row.bad == "" || row.elsewhere {
|
||||
if len(held) > 0 {
|
||||
t.Errorf("%s: held %v", row.name, held)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if why, ok := held["backup"]; !ok || !strings.Contains(why, row.why) || len(held) != 1 {
|
||||
t.Errorf("%s: held %v, want backup alone, saying %q", row.name, held, row.why)
|
||||
}
|
||||
if _, planned := plans["backup"]; planned {
|
||||
t.Errorf("%s: the held module has a plan", row.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// unusableItem is a manifest the mesh refuses as the node-engine did power's at mesh-catalog ca8d6f33: a bar block item
|
||||
// with attributes its bar places by none of.
|
||||
const unusableItem = `{"module":"backup","version":"1","moved":true,` +
|
||||
`"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},` +
|
||||
`"items":[{"collection":"contribution:node-bar:block",` +
|
||||
`"attributes":{"bar":"bottom","place":"status","shows":"state","state":"power.draw"},"template":"files/bar"}]}`
|
||||
Vendored
+60
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"module": "mesh-provisioner-client",
|
||||
"version": "1",
|
||||
"claims": [
|
||||
{
|
||||
"name": "node-provisioner",
|
||||
"scope": "node"
|
||||
}
|
||||
],
|
||||
"own-secrets": {
|
||||
"provisioner-broker": "${dir:state}/broker"
|
||||
},
|
||||
"secrets-owner": "mesh-provisioner-client",
|
||||
"resources": [
|
||||
{
|
||||
"id": "account",
|
||||
"type": "user",
|
||||
"name": "mesh-provisioner-client",
|
||||
"shell": "/usr/bin/nologin",
|
||||
"home": "/var/lib/mesh-provisioner-client"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"owner": "mesh-provisioner-client",
|
||||
"place": ".",
|
||||
"unknown": "program"
|
||||
},
|
||||
{
|
||||
"id": "client",
|
||||
"type": "process",
|
||||
"name": "mesh-provisioner-client",
|
||||
"artifact": "client",
|
||||
"run": [
|
||||
"./mesh-provisioner-client"
|
||||
],
|
||||
"env": {
|
||||
"MESH_PROVISIONER_CREDENTIAL": "${dir:state}/broker",
|
||||
"MESH_PROVISIONER_STATE": "${dir:state}"
|
||||
},
|
||||
"user": "mesh-provisioner-client",
|
||||
"health": {
|
||||
"kind": "unit"
|
||||
}
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "client",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/mesh-provisioner-client",
|
||||
"binary": "mesh-provisioner-client"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
# What the modules on this machine back up, composed by the mesh (novox/hq to-be 43). Do not edit.
|
||||
${backup:lines}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
# The data the modules on this machine declare, composed by the mesh (novox/hq ADR 0233). Do not edit.
|
||||
${backup:data}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# How many restore points the night keeps, written by the mesh from this machine's settings keep-daily,
|
||||
# keep-weekly, keep-monthly (novox/hq issue 416) and keep-hand-taken-days (novox/hq issue 453). Replaced on every push; change the settings, never this file.
|
||||
daily=${config:keep-daily}
|
||||
weekly=${config:keep-weekly}
|
||||
monthly=${config:keep-monthly}
|
||||
hand=${config:keep-hand-taken-days}
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
{
|
||||
"module": "restic",
|
||||
"version": "1",
|
||||
"moved": true,
|
||||
"claims": [
|
||||
{
|
||||
"name": "node-backup",
|
||||
"scope": "node",
|
||||
"serves": [
|
||||
"backed-up",
|
||||
"now",
|
||||
"restore"
|
||||
]
|
||||
}
|
||||
],
|
||||
"emits": [
|
||||
"backed-up"
|
||||
],
|
||||
"tools": [
|
||||
"backup_health",
|
||||
"backup_delete_retired",
|
||||
"backup_deleted"
|
||||
],
|
||||
"own-secrets": {
|
||||
"repository": "${dir:state}/repository.secret"
|
||||
},
|
||||
"data": {
|
||||
"own": [
|
||||
{
|
||||
"id": "repository",
|
||||
"path": "${dir:repository}",
|
||||
"class": "rebuildable",
|
||||
"backup": "none",
|
||||
"why": "the restore points themselves: a copy of data kept elsewhere on this machine, never the only copy of anything; lost, the history goes and nothing live does"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"path": "${dir:state}",
|
||||
"class": "cache",
|
||||
"why": "what the last nights and measurements were; the next night writes it again"
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {
|
||||
"keep-daily": {
|
||||
"type": "number",
|
||||
"description": "How many nightly restore points are kept, a count from 1 to 9999: two weeks undo a mistake noticed within a fortnight. Read by the night, which thins nothing while it does not read (novox/hq issue 416); lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.",
|
||||
"minimum": 1,
|
||||
"maximum": 9999,
|
||||
"trusted": true,
|
||||
"default": 14
|
||||
},
|
||||
"keep-weekly": {
|
||||
"type": "number",
|
||||
"description": "How many weekly restore points are kept behind the dailies, a count from 1 to 9999, refused by the night like keep-daily; lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.",
|
||||
"minimum": 1,
|
||||
"maximum": 9999,
|
||||
"trusted": true,
|
||||
"default": 8
|
||||
},
|
||||
"keep-monthly": {
|
||||
"type": "number",
|
||||
"description": "How many monthly restore points are kept behind the weeklies, a count from 1 to 9999, refused by the night like keep-daily; lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.",
|
||||
"minimum": 1,
|
||||
"maximum": 9999,
|
||||
"trusted": true,
|
||||
"default": 6
|
||||
},
|
||||
"keep-hand-taken-days": {
|
||||
"type": "number",
|
||||
"description": "How many days a restore point taken by hand (node-backup.now, or the mesh's before a module moves) is kept at least, a count from 1 to 9999; it is kept until a later good night of its module exists, and this many days in any case, so the next thinning never takes the one taken against a risky change (novox/hq issue 453). Lowering it lets go of the older ones taken by hand at the next thinning, and that cannot be undone.",
|
||||
"minimum": 1,
|
||||
"maximum": 9999,
|
||||
"trusted": true,
|
||||
"default": 7
|
||||
}
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"place": ".",
|
||||
"unknown": "program"
|
||||
},
|
||||
{
|
||||
"id": "repository",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"unknown": "program"
|
||||
},
|
||||
{
|
||||
"id": "declared",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/backups.conf",
|
||||
"mode": "0600",
|
||||
"template": "files/backups.conf"
|
||||
},
|
||||
{
|
||||
"id": "data-declared",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/data.conf",
|
||||
"mode": "0600",
|
||||
"template": "files/data.conf"
|
||||
},
|
||||
{
|
||||
"id": "retention",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/retention.conf",
|
||||
"mode": "0644",
|
||||
"trusted": true,
|
||||
"template": "files/retention.conf"
|
||||
},
|
||||
{
|
||||
"id": "tool",
|
||||
"type": "package",
|
||||
"package": "restic"
|
||||
},
|
||||
{
|
||||
"id": "sqlite",
|
||||
"type": "package",
|
||||
"package": "sqlite"
|
||||
},
|
||||
{
|
||||
"id": "night",
|
||||
"type": "process",
|
||||
"name": "restic-night",
|
||||
"artifact": "tools",
|
||||
"run": [
|
||||
"./restic-backups",
|
||||
"night"
|
||||
],
|
||||
"user": "${machine:account}",
|
||||
"env": {
|
||||
"MESH_BACKUP_DECLARED": "${dir:state}/backups.conf",
|
||||
"MESH_BACKUP_REPOSITORY": "${dir:repository}",
|
||||
"MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret",
|
||||
"MESH_BACKUP_STATE": "${dir:state}",
|
||||
"MESH_BACKUP_DATA": "${dir:state}/data.conf"
|
||||
},
|
||||
"health": {
|
||||
"kind": "tool",
|
||||
"tool": "backup_health",
|
||||
"interval": "60s",
|
||||
"timeout": "20s",
|
||||
"looks": 2,
|
||||
"grace": "60s"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "measure",
|
||||
"type": "process",
|
||||
"name": "restic-measure",
|
||||
"artifact": "tools",
|
||||
"run": [
|
||||
"./restic-backups",
|
||||
"measure"
|
||||
],
|
||||
"user": "${machine:account}",
|
||||
"env": {
|
||||
"MESH_BACKUP_DECLARED": "${dir:state}/backups.conf",
|
||||
"MESH_BACKUP_REPOSITORY": "${dir:repository}",
|
||||
"MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret",
|
||||
"MESH_BACKUP_STATE": "${dir:state}",
|
||||
"MESH_BACKUP_DATA": "${dir:state}/data.conf"
|
||||
},
|
||||
"health": {
|
||||
"kind": "unit"
|
||||
}
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/restic-backups",
|
||||
"binary": "restic-backups",
|
||||
"loads": [
|
||||
"restic-backups"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BACKUP_DECLARED": "${dir:state}/backups.conf",
|
||||
"MESH_BACKUP_REPOSITORY": "${dir:repository}",
|
||||
"MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret",
|
||||
"MESH_BACKUP_STATE": "${dir:state}",
|
||||
"MESH_BACKUP_DATA": "${dir:state}/data.conf"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "files",
|
||||
"kind": "archive",
|
||||
"from": "files"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package frombus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// aNode is a bus with n1 the hub, each of manifests placed on it and held at the indexer, in the order given.
|
||||
func aNode(t *testing.T, manifests ...string) (*bus, map[string]string) {
|
||||
t.Helper()
|
||||
p := delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}}
|
||||
b := &bus{records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t), SealingKey: "sk", Capabilities: []string{"systemd"}}},
|
||||
modules: map[string]delivery.Module{}, manifests: map[string][]byte{}, builds: map[string]*delivery.BuilderBuild{}}
|
||||
fps := map[string]string{}
|
||||
for i, raw := range manifests {
|
||||
module := strings.SplitN(strings.TrimPrefix(raw, `{"module":"`), `"`, 2)[0]
|
||||
p.Modules = append(p.Modules, delivery.PlacedModule{Module: module})
|
||||
b.modules[module] = delivery.Module{Manifest: delivery.ManifestDigest([]byte(raw))}
|
||||
b.manifests[delivery.ManifestDigest([]byte(raw))] = []byte(raw)
|
||||
fps[module] = "src2:" + strings.Repeat(string(rune('1'+i)), 64)
|
||||
}
|
||||
b.placements = map[string]delivery.Placement{"n1": p}
|
||||
return b, fps
|
||||
}
|
||||
|
||||
// Issue 774, 2026-10-30 19:30: a node refused whole after holding a module says what it held and why, since the last
|
||||
// refusal can be of the held module's stub (node-tools held, then "its build produced 0 bundle(s)", its own why unsaid).
|
||||
func aNodeRefusedWholeSaysWhatItHeld(t *testing.T) {
|
||||
refused := `{"module":"power","version":"1","items":[{"collection":"contribution:node-bar:block","attributes":{"shows":"state"},"template":"files/a"}]}`
|
||||
needs := `{"module":"lamp","version":"1","requires":["nothing-provides-this"]}`
|
||||
b, fps := aNode(t, refused, needs)
|
||||
_, _, held, err := Compose(context.Background(), b, Mesh{}, Scope{Node: "n1"}, fps)
|
||||
if err == nil || !strings.Contains(err.Error(), "nothing provides") || !strings.Contains(err.Error(), `held before: power: `) ||
|
||||
!strings.Contains(err.Error(), `"shows" is none of them`) {
|
||||
t.Errorf("the node refused whole with %v (held %v), want lamp's refusal and power held before, with its why", err, held)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 774: the culprit rule takes a module whose stub composes or fails otherwise; the runtime only where its stub
|
||||
// composes, since its stub always fails otherwise.
|
||||
func theRuntimeIsBlamedOnlyWhereItsStubComposes(t *testing.T) {
|
||||
err := errors.New("lamp refuses")
|
||||
for _, row := range []struct {
|
||||
module string
|
||||
again error
|
||||
blamed bool
|
||||
}{
|
||||
{"lamp", nil, true},
|
||||
{"lamp", errors.New("other"), true},
|
||||
{"lamp", errors.New("lamp refuses"), false},
|
||||
{catalogue.RuntimeModule, nil, true},
|
||||
{catalogue.RuntimeModule, errors.New("node-tools is assigned to n1 and its build produced 0 bundle(s)"), false},
|
||||
{catalogue.RuntimeModule, fmt.Errorf("compose: %w", &catalogue.NoRuntimeToRunError{Held: true}), true},
|
||||
{catalogue.RuntimeModule, &catalogue.NoRuntimeToRunError{}, false},
|
||||
} {
|
||||
if got := blames(row.module, row.again, err); got != row.blamed {
|
||||
t.Errorf("%s with %v: blamed %v, want %v", row.module, row.again, got, row.blamed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 774, 2026-10-30 19:30: the trunk's node-tools moved to a build not yet done, and ace, g14 and shanks composed
|
||||
// it from the indexer's manifest, which names no bundle: "its build produced 0 bundle(s)", every module refused. A
|
||||
// module that runs is now held until its build lands, the runtime composed as what runs of it; and a module wanted at
|
||||
// another build than the trunk's is composed from the build its want names.
|
||||
func aRunningModuleWaitsForItsBuild(t *testing.T) {
|
||||
dg := func(c string) string { return "sha256:" + strings.Repeat(c, 64) }
|
||||
tools := `{"module":"node-tools","version":"1","own-secrets":{"broker":"/var/lib/mesh/node-tools/broker"},` +
|
||||
`"build":{"artifacts":[{"name":"runtime","kind":"bundle","language":"go","system":"arch","from":"cmd/node-tools"}]}}`
|
||||
lamp := `{"module":"lamp","version":"1","resources":[{"id":"service","type":"service","unit":"lamp.service","state":"running","boot":"enabled"}]}`
|
||||
store := `{"module":"artifacts","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],"serves":{"artifact-store":{"port":5100}},` +
|
||||
`"receives":{"artifact-store":"/var/lib/artifacts/received.json"}}`
|
||||
old, next := "src2:"+strings.Repeat("a", 64), "src2:"+strings.Repeat("b", 64)
|
||||
running := []delivery.Composed{{Node: "n1", Module: catalogue.RuntimeModule, FP: old, Resources: []json.RawMessage{
|
||||
json.RawMessage(`{"id":"node-tools.runtime","module":"node-tools","type":"process","name":"node-tools","env":{"MESH_TOOL_MODULES":""}}`),
|
||||
json.RawMessage(`{"id":"node-tools.bundle-runtime","module":"node-tools","type":"archive","source":"artifact-store://node-tools/runtime/blobs/` +
|
||||
dg("1") + `","digest":"` + dg("1") + `","path":"` + catalogue.BundlePath("node-tools", "runtime") + `"}`)}}}
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
want string // the build node-tools' want names
|
||||
done bool // the trunk's build is done
|
||||
held string // words node-tools' hold says; empty: composed
|
||||
digest string // the runtime bundle's digest composed
|
||||
}{
|
||||
{name: "the trunk's build not done yet", want: next, held: "no done build yet"},
|
||||
{name: "wanted at the build that runs, the trunk's not done", want: old, digest: dg("1")},
|
||||
{name: "the trunk's build done", want: next, done: true, digest: dg("2")},
|
||||
} {
|
||||
b, fps := aNode(t, store, tools, lamp)
|
||||
b.held = true // every grant held, so a contribution's credential stands in for nothing
|
||||
b.own = []compose.OwnSecretRow{{Node: "n1", Module: "node-tools", Name: "broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}}
|
||||
mod := b.modules["node-tools"]
|
||||
mod.FP2 = next
|
||||
b.modules["node-tools"] = mod
|
||||
b.builds["node-tools "+old] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest,
|
||||
Artifacts: []delivery.BuiltArtifact{{Name: "runtime", Kind: "bundle", Reference: "artifact-store://node-tools/runtime/blobs/" + dg("1"), Digest: dg("1")}}}
|
||||
b.builds["node-tools "+next] = &delivery.BuilderBuild{State: "building", Manifest: mod.Manifest}
|
||||
if row.done {
|
||||
b.builds["node-tools "+next] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest,
|
||||
Artifacts: []delivery.BuiltArtifact{{Name: "runtime", Kind: "bundle", Reference: "artifact-store://node-tools/runtime/blobs/" + dg("2"), Digest: dg("2")}}}
|
||||
}
|
||||
fps["node-tools"] = row.want
|
||||
out, _, held, err := Compose(context.Background(), b, Mesh{}, Scope{Node: "n1", Running: running}, fps)
|
||||
if err != nil {
|
||||
t.Errorf("%s: the node is refused: %v", row.name, err)
|
||||
continue
|
||||
}
|
||||
if lampKey, _ := delivery.ComposedKey("n1", "lamp", fps["lamp"]); out[lampKey].Module != "lamp" {
|
||||
t.Errorf("%s: lamp is not composed (held %v)", row.name, held)
|
||||
}
|
||||
if why := held["node-tools"]; (row.held == "") != (why == "") || !strings.Contains(why, row.held) {
|
||||
t.Errorf("%s: node-tools held %q, want %q", row.name, why, row.held)
|
||||
}
|
||||
if row.digest != "" {
|
||||
key, _ := delivery.ComposedKey("n1", "node-tools", row.want)
|
||||
if c := out[key]; !strings.Contains(fmt.Sprint(rawStrings(c.Resources)), row.digest) {
|
||||
t.Errorf("%s: node-tools is composed as %v, want its bundle %s", row.name, rawStrings(c.Resources), row.digest)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func rawStrings(rs []json.RawMessage) []string {
|
||||
var out []string
|
||||
for _, r := range rs {
|
||||
out = append(out, string(r))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Issue 774: a refusal of a held module's stand-in leads with why the module was held, its first reason, as mesh-self-check's
|
||||
// on ace should have; a refusal naming no held module is as it was.
|
||||
func aHeldModulesFirstReasonLeads(t *testing.T) {
|
||||
held := map[string]string{"mesh-self-check": "mesh-self-check on ace has no bus credential: nothing was issued for ace.mesh-self-check"}
|
||||
for _, row := range []struct {
|
||||
err, want string
|
||||
}{
|
||||
{"mesh-self-check runs as its own account and declares no own secret broker",
|
||||
"mesh-self-check is held: mesh-self-check on ace has no bus credential: nothing was issued for ace.mesh-self-check; " +
|
||||
"its stand-in is then refused: mesh-self-check runs as its own account and declares no own secret broker"},
|
||||
{"ace is the hub and has no endpoint", "ace is the hub and has no endpoint"},
|
||||
} {
|
||||
err := errors.New(row.err)
|
||||
got := firstReason(err, held)
|
||||
if got.Error() != row.want || !errors.Is(got, err) {
|
||||
t.Errorf("refused %q, want %q", got, row.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 774, 2026-10-30 21:04: mesh-conditions and mesh-healers on novox and mesh-self-check on ace were placed before their
|
||||
// bus accounts were issued. Held for "needs a secret called broker and none was made", each one's bare stub was then
|
||||
// refused as "runs as its own account and declares no own secret broker", and that refusal failed every module of the
|
||||
// node (79 on novox, 74 on ace). A held module's stand-in is never refused by a rule about its own runtime or its own
|
||||
// secrets: the node composes every other module, and the held one keeps the words it was first held for.
|
||||
func aHeldModuleOfItsOwnAccountNeverRefusesTheNode(t *testing.T) {
|
||||
dg := "sha256:" + strings.Repeat("1", 64)
|
||||
tools := `{"module":"node-tools","version":"1","own-secrets":{"broker":"/var/lib/mesh/node-tools/broker"},` +
|
||||
`"build":{"artifacts":[{"name":"runtime","kind":"bundle","language":"go","system":"arch","from":"cmd/node-tools"}]}}`
|
||||
own := func(name string) string {
|
||||
return `{"module":"` + name + `","version":"1","runs-as":"` + name + `","secrets-owner":"` + name + `","tools":["status"],` +
|
||||
`"own-secrets":{"broker":"/var/lib/` + name + `/broker"},"contributes":{"artifact-store":{"name":"` + name + `"}},` +
|
||||
`"resources":[{"id":"account","type":"user","name":"` + name + `"}],` +
|
||||
`"build":{"artifacts":[{"name":"tools","kind":"bundle","language":"typescript","entrypoints":["tools/index.js"]}]}}`
|
||||
}
|
||||
lamp := `{"module":"lamp","version":"1","resources":[{"id":"service","type":"service","unit":"lamp.service","state":"running","boot":"enabled"}]}`
|
||||
store := `{"module":"artifacts","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],"serves":{"artifact-store":{"port":5100}},` +
|
||||
`"receives":{"artifact-store":"/var/lib/artifacts/received.json"}}`
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
issued []string // the own-account modules whose broker is issued
|
||||
building bool // node-tools' build is not done, and nothing of it runs
|
||||
held map[string]string // the modules held, each with words of the reason it was first held for
|
||||
// ungranted gives the provider a grants file and no consumer's grant is issued yet (a newly placed module's
|
||||
// usual state): a held module's contribution then stands in for its grant, and is left out (review round 1).
|
||||
ungranted bool
|
||||
}{
|
||||
{name: "none issued", held: map[string]string{"check": "broker", "heal": "broker"}},
|
||||
{name: "none issued, no grant issued", ungranted: true, held: map[string]string{"check": "broker", "heal": "broker"}},
|
||||
{name: "one issued", issued: []string{"heal"}, held: map[string]string{"check": "broker"}},
|
||||
{name: "both issued", issued: []string{"check", "heal"}},
|
||||
// The runtime held: its stand-in is not refused for its bundle; the modules it would run are held, not the node.
|
||||
{name: "the runtime's build not done", issued: []string{"check", "heal"}, building: true,
|
||||
held: map[string]string{"node-tools": "its build produced 0 bundle(s)", "check": "not here to run it from", "heal": "not here to run it from"}},
|
||||
} {
|
||||
provider := store
|
||||
if row.ungranted {
|
||||
provider = strings.Replace(store, `"receives":`, `"grants":{"artifact-store":"/var/lib/artifacts/grants"},"receives":`, 1)
|
||||
}
|
||||
b, fps := aNode(t, provider, tools, own("check"), lamp, own("heal"))
|
||||
// Every grant held, so the held modules' contributions are composed whole; the row ungranted issues none.
|
||||
b.held = !row.ungranted
|
||||
b.own = []compose.OwnSecretRow{{Node: "n1", Module: "node-tools", Name: "broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}}
|
||||
for _, m := range row.issued {
|
||||
b.own = append(b.own, compose.OwnSecretRow{Node: "n1", Module: m, Name: "broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`})
|
||||
}
|
||||
for _, m := range []string{"node-tools", "check", "heal"} {
|
||||
name := "tools"
|
||||
if m == "node-tools" {
|
||||
name = "runtime"
|
||||
}
|
||||
mod := b.modules[m]
|
||||
mod.FP2 = fps[m]
|
||||
b.modules[m] = mod
|
||||
b.builds[m+" "+fps[m]] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest,
|
||||
Artifacts: []delivery.BuiltArtifact{{Name: name, Kind: "bundle", Reference: "artifact-store://" + m + "/" + name + "/blobs/" + dg, Digest: dg}}}
|
||||
if m == "node-tools" && row.building {
|
||||
b.builds[m+" "+fps[m]] = &delivery.BuilderBuild{State: "building", Manifest: mod.Manifest}
|
||||
}
|
||||
}
|
||||
out, _, held, err := Compose(context.Background(), b, Mesh{}, Scope{Node: "n1"}, fps)
|
||||
if err != nil {
|
||||
t.Errorf("%s: the node is refused: %v", row.name, err)
|
||||
continue
|
||||
}
|
||||
// A held module keeps what it contributes (issue 774, 2026-10-31: nextcloud held for its admin secret dropped out
|
||||
// of postgres's received file, and postgres's helper would have locked its login): only its own secrets are left
|
||||
// out of its stand-in, never its contributions, so the provider's received file names every consumer.
|
||||
storeKey, _ := delivery.ComposedKey("n1", "artifacts", fps["artifacts"])
|
||||
// And never a stand-in for a grant not yet issued: the provider is applied, so a held module whose grant is
|
||||
// not issued is composed without that contribution, never with "foreseen: made by the next send".
|
||||
for key, c := range out {
|
||||
if got, _ := json.Marshal(c.Resources); strings.Contains(string(got), "foreseen") {
|
||||
t.Errorf("%s: %s applies a stand-in: %s", row.name, key, got)
|
||||
}
|
||||
}
|
||||
for _, m := range []string{"check", "heal"} {
|
||||
if row.ungranted {
|
||||
break
|
||||
}
|
||||
if got, _ := json.Marshal(out[storeKey].Resources); !strings.Contains(string(got), `\"from\": \"`+m+`\"`) {
|
||||
t.Errorf("%s: the provider's received file drops %s's contribution: %s", row.name, m, got)
|
||||
}
|
||||
}
|
||||
for _, m := range []string{"node-tools", "check", "lamp", "heal"} {
|
||||
key, _ := delivery.ComposedKey("n1", m, fps[m])
|
||||
_, composed := out[key]
|
||||
why, isHeld := held[m]
|
||||
want, wantHeld := row.held[m]
|
||||
if isHeld != wantHeld || composed == wantHeld {
|
||||
t.Errorf("%s: %s held %v (%q), composed %v; want held %v", row.name, m, isHeld, why, composed, wantHeld)
|
||||
} else if isHeld && !strings.Contains(why, want) {
|
||||
t.Errorf("%s: %s is held for %q, want its first reason, %q", row.name, m, why, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package frombus
|
||||
|
||||
// **The bus read through a module's declared `reads`** (the lead, 2026-10-29: one reader in place of the copies in
|
||||
// mesh-provisioner and disk-load): a Reader over the states a module reads, by a runtime's Get and Keys (a bundle's
|
||||
// stdio.State, a process's own), for a holder that composes plans or data items and reads no value: placement, each
|
||||
// node's record in its own have key, the indexer's modules and manifests, the builder's builds and mesh-config's resolved
|
||||
// values. No own secret and no credential is read (compose.Planning). Pure as the rest of this package: a bundle's
|
||||
// runtime states and an HTTP fetch are compose/fromruntime's.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// The states a module names in its manifest's `reads` to compose through StatesReader.
|
||||
const (
|
||||
PlacementRead = delivery.PlacementRead
|
||||
ModulesRead = delivery.IndexerModule + ".modules"
|
||||
ManifestsRead = delivery.IndexerModule + ".manifests"
|
||||
BuildsRead = delivery.BuilderModule + ".builds"
|
||||
HaveRead = "node-engine.have"
|
||||
ResolvedRead = "mesh-config.resolved"
|
||||
)
|
||||
|
||||
// StatesReads are those reads, in a manifest's order.
|
||||
var StatesReads = []string{PlacementRead, ModulesRead, ManifestsRead, BuildsRead, HaveRead, ResolvedRead}
|
||||
|
||||
// States is a module's read states as its runtime gives them: a key's value, false when it has none.
|
||||
type States interface {
|
||||
Get(state, key string) (value []byte, found bool, err error)
|
||||
Keys(state string) ([]string, error)
|
||||
}
|
||||
|
||||
// Fetch reads an archive at a URL (fromruntime.HTTPFetch over HTTP).
|
||||
type Fetch func(ctx context.Context, url string) ([]byte, error)
|
||||
|
||||
// StatesReader is the Reader over s; fetch reads a templates archive, nil for a holder that reads none.
|
||||
func StatesReader(s States, fetch Fetch) Reader { return statesReader{s: s, fetch: fetch} }
|
||||
|
||||
type statesReader struct {
|
||||
s States
|
||||
fetch Fetch
|
||||
}
|
||||
|
||||
func (r statesReader) each(read string, visit func(key string, value []byte)) error {
|
||||
keys, err := r.s.Keys(read)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", read, err)
|
||||
}
|
||||
for _, k := range keys {
|
||||
v, found, err := r.s.Get(read, k)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s %s: %w", read, k, err)
|
||||
}
|
||||
if found {
|
||||
visit(k, v)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r statesReader) Placements(context.Context) (map[string]delivery.Placement, error) {
|
||||
keys := delivery.NewPlacementKeys()
|
||||
err := r.each(PlacementRead, func(k string, v []byte) {
|
||||
_, _ = keys.Put(k, v, 0, false) // a key that does not read places nothing, as before
|
||||
})
|
||||
return keys.All(), err
|
||||
}
|
||||
|
||||
func (r statesReader) Records(context.Context) (map[string]delivery.NodeRecord, error) {
|
||||
out := map[string]delivery.NodeRecord{}
|
||||
err := r.each(HaveRead, func(k string, v []byte) {
|
||||
if node, _, isNode, err := delivery.ParseHaveKey(k); err == nil && isNode {
|
||||
if h, err := delivery.DecodeHaveNode(v); err == nil && h.Record != nil {
|
||||
out[node] = *h.Record
|
||||
}
|
||||
}
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (r statesReader) Modules(context.Context) (map[string]delivery.Module, error) {
|
||||
out := map[string]delivery.Module{}
|
||||
err := r.each(ModulesRead, func(k string, v []byte) {
|
||||
if module, err := delivery.ParseModulesKey(k); err == nil {
|
||||
if m, err := delivery.DecodeModule(v); err == nil {
|
||||
out[module] = m
|
||||
}
|
||||
}
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (r statesReader) Manifest(_ context.Context, digest string) ([]byte, error) {
|
||||
key, err := delivery.ManifestsKey(digest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v, found, err := r.s.Get(ManifestsRead, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !found {
|
||||
return nil, fmt.Errorf("the indexer holds no manifest %s", digest)
|
||||
}
|
||||
m, err := delivery.DecodeManifest(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []byte(m.JSON), nil
|
||||
}
|
||||
|
||||
func (r statesReader) Values(_ context.Context, node, module string) (map[string]any, error) {
|
||||
var out map[string]any
|
||||
prefix := node + "." + module + "."
|
||||
err := r.each(ResolvedRead, func(k string, v []byte) {
|
||||
name, ok := strings.CutPrefix(k, prefix)
|
||||
if !ok || strings.Contains(name, ".") {
|
||||
return
|
||||
}
|
||||
if value, laid := catalogue.LaidValue(v); laid {
|
||||
if out == nil {
|
||||
out = map[string]any{}
|
||||
}
|
||||
out[name] = value
|
||||
}
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (statesReader) OwnSecrets(context.Context, string) ([]compose.OwnSecretRow, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (statesReader) Credentials(context.Context, string, []compose.CredentialKey, []compose.CredentialKey) ([]compose.Credential, []compose.Issued, error) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
func (r statesReader) Build(_ context.Context, module, fp string) (*delivery.BuilderBuild, error) {
|
||||
key, err := delivery.BuilderBuildKey(module, fp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v, found, err := r.s.Get(BuildsRead, key)
|
||||
if err != nil || !found {
|
||||
return nil, err
|
||||
}
|
||||
b, err := delivery.DecodeBuilderBuild(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &b, nil
|
||||
}
|
||||
|
||||
// archiveWithin bounds one read of a templates archive.
|
||||
const archiveWithin = 30 * time.Second
|
||||
|
||||
func (r statesReader) Archive(ctx context.Context, url, digest string) ([]byte, error) {
|
||||
if r.fetch == nil {
|
||||
return nil, fmt.Errorf("this holder reads no archive (%s)", url)
|
||||
}
|
||||
fetching, cancel := context.WithTimeout(ctx, archiveWithin)
|
||||
defer cancel()
|
||||
body, err := r.fetch(fetching, url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sum := sha256.Sum256(body)
|
||||
if got := hex.EncodeToString(sum[:]); got != strings.TrimPrefix(digest, "sha256:") {
|
||||
return nil, fmt.Errorf("%s was recorded as %s and what arrived is %s", url, digest, got)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func (r statesReader) Served(ctx context.Context, node, module string, keys []string) (map[string]any, error) {
|
||||
all, err := r.Values(ctx, node, module)
|
||||
var out map[string]any
|
||||
for _, k := range keys {
|
||||
if v, ok := all[k]; ok {
|
||||
if out == nil {
|
||||
out = map[string]any{}
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Package fromruntime is compose/frombus's StatesReader over a bundle's runtime (the lead, 2026-10-29): the states a
|
||||
// module's manifest names in `reads` (frombus.StatesReads), read through stdio.State, and a templates archive fetched
|
||||
// over HTTP. Apart from frombus so that frombus stays pure for the mesh-compose command.
|
||||
package fromruntime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose/frombus"
|
||||
)
|
||||
|
||||
// States are a bundle's read states through its runtime.
|
||||
type States struct{}
|
||||
|
||||
// Get is a key's value, false when it has none.
|
||||
func (States) Get(state, key string) ([]byte, bool, error) {
|
||||
e, err := stdio.State(state).Get(key)
|
||||
if err != nil || e == nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return e.Value, true, nil
|
||||
}
|
||||
|
||||
// Keys are a state's keys.
|
||||
func (States) Keys(state string) ([]string, error) { return stdio.State(state).Keys() }
|
||||
|
||||
// Reader is frombus's Reader over this bundle's read states, fetching archives over HTTP.
|
||||
func Reader() frombus.Reader { return frombus.StatesReader(States{}, HTTPFetch) }
|
||||
|
||||
// HTTPFetch reads an archive over HTTP, at most 256 MiB.
|
||||
func HTTPFetch(ctx context.Context, url string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%s answered %s", url, resp.Status)
|
||||
}
|
||||
return io.ReadAll(io.LimitReader(resp.Body, 256<<20))
|
||||
}
|
||||
@@ -142,6 +142,10 @@ type Node struct {
|
||||
Place Place
|
||||
// Assigned is the modules assigned to it, in the order the controller reads them (inv.Assigned).
|
||||
Assigned []string
|
||||
// Handed is, by module, whether the vault judged it handed to the provisioner when it answered this node's own
|
||||
// secrets (catalogue.AssignmentHands of the plan in its assignment): the node's own composition hands exactly those
|
||||
// (catalogue.Rendering.Assigned, issue 774). Nil where the vault said nothing of it, as for the controller.
|
||||
Handed map[string]bool
|
||||
// Capabilities are the ones present on it (inv.ProfileOf).
|
||||
Capabilities map[string]bool
|
||||
// PublicDomain is the domain it composes its routed names under (inv.PublicDomainOf).
|
||||
@@ -210,6 +214,12 @@ type Node struct {
|
||||
// (RunsOwn), folded or leaving the fold, is carried there as it runs, whatever its manifest names, so the node's own
|
||||
// never drops it.
|
||||
Held map[string]bool
|
||||
// RunsTools is, by module, the tools bundles the node's runtime serves of it as it runs (ToolsRunning of what the
|
||||
// node runs); nil when not known. A module composed as what runs of it (its Stub or Carried: held, or folded at a
|
||||
// build of the old form) contributes these to the runtime, never its manifest's: a manifest no build resolved has no
|
||||
// bundles, and a held module's new build's bundles are not delivered (issue 1126, 2026-10-29: shanks's runtime lost
|
||||
// the folded systemd-resolved, novox's the folded nats).
|
||||
RunsTools map[string][]catalogue.Bundle
|
||||
}
|
||||
|
||||
// Place is where a node is, as its placement says (inv.Overlay).
|
||||
|
||||
@@ -333,3 +333,19 @@ func namedModules(plan catalogue.Resolution, leftOut map[string]string) []string
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DataItems is every data item the modules placed on a node declare, placed there (novox/hq ADR 0571 clause 3): what the
|
||||
// node-backup holder is handed as `${backup:data}`, composed over the Input as Compose composes the node, so disk-load
|
||||
// reads the list from the bus with no composed file. Unplaced names each item left out and why.
|
||||
func DataItems(in Input, node string) (map[string][]catalogue.PlacedDataItem, []string, error) {
|
||||
c := newComposer(in)
|
||||
resolved, err := c.planFor(node)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
rendered, err := c.render(node, Planning)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return resolved.Plan.DataItems(rendered.Rendering)
|
||||
}
|
||||
|
||||
@@ -58,6 +58,8 @@ func (c *composer) render(nodeName string, mode Mode) (Rendered, error) {
|
||||
}
|
||||
r := placed.Rendering
|
||||
out.ForeseenPorts = placed.ForeseenPorts
|
||||
// A plan carries its contributions' machine ports, refused where the placement assigned none (issue 774).
|
||||
r.Planned = mode == Planning
|
||||
r.Grants, r.Withheld, r.Unbound = grants, withheld, unbound
|
||||
out.Withheld, out.Unbound = withheld, unbound
|
||||
for _, g := range grantsForeseen {
|
||||
@@ -65,13 +67,13 @@ func (c *composer) render(nodeName string, mode Mode) (Rendered, error) {
|
||||
}
|
||||
|
||||
// And each module's own secrets: a module handed to the provisioner has its secrets from the vault through the client.
|
||||
handing := catalogue.Rendering{Hands: r.Hands, Provisioner: r.Provisioner}
|
||||
handing := catalogue.Rendering{Hands: r.Hands, Provisioner: r.Provisioner, Assigned: r.Assigned}
|
||||
needed, foreseenOwn, err := c.ownSecretsFor(node, plan, handing, mode)
|
||||
if err != nil {
|
||||
return Rendered{}, err
|
||||
}
|
||||
r.Needed = needed
|
||||
if mode == Foreseeing || len(foreseenOwn) > 0 {
|
||||
if mode == Foreseeing || mode == Planning || len(foreseenOwn) > 0 {
|
||||
r.Foreseen = foreseenOwn
|
||||
}
|
||||
var names []string
|
||||
|
||||
@@ -73,6 +73,7 @@ func (c *composer) places(nodeName string, resolved Resolved) (Placed, error) {
|
||||
// not sent the field they would refuse.
|
||||
r.Hands = takesHanded(node)
|
||||
r.Provisioner = provisionerAccount(plan)
|
||||
r.Assigned = node.Handed
|
||||
r.ReadsHealth = node.Stated && node.Contract >= ReadinessContract
|
||||
r.JudgesRoot = node.Stated && node.Contract >= RootContract
|
||||
r.ReadsBusy = node.Stated && node.Contract >= BusyContract
|
||||
|
||||
@@ -70,6 +70,12 @@ func TestAnAllocatedPortIsComposedAndAnUnallocatedOneIsNamedForeseenNotInvented(
|
||||
t.Errorf("an unpublished port: %v foreseen %v", got.Rendering.Ports, got.ForeseenPorts)
|
||||
}
|
||||
unchanged(t, mk, func(in Input) { placed(t, in, "laptop") })
|
||||
// Only a composition for plans refuses a contribution whose port the module does not listen on (issue 774).
|
||||
for _, mode := range []Mode{Reading, Foreseeing, Planning} {
|
||||
if got := rendered(t, mk(), "laptop", mode).Rendering; got.Planned != (mode == Planning) {
|
||||
t.Errorf("mode %d composes with Planned %v", mode, got.Planned)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1363/AC3: a port the operator gave is used, over the allocated one, and a consumer elsewhere is told it
|
||||
|
||||
@@ -54,6 +54,7 @@ func (c *composer) planFor(nodeName string) (Resolved, error) {
|
||||
// Issued for this holder alone and kept by the vault (issue 757).
|
||||
resolved.Needs[i].IssuedToHolder = issuedToHolder(shelf, n)
|
||||
resolved.Needs[i].ProviderMoved = providerMoved(shelf, n)
|
||||
resolved.Needs[i].AnsweredByHelper = answeredByHelper(shelf, n)
|
||||
if n.ByRecord {
|
||||
// Answered by something the mesh holds, so there is no pair-wise secret between two machines.
|
||||
licence, _ := c.licence(n.From)
|
||||
@@ -189,6 +190,16 @@ func providerMoved(shelf map[string]catalogue.Manifest, n catalogue.Needed) bool
|
||||
return known && (movedProvider(provider) || provider.AnsweredByHelperFor(n.Name))
|
||||
}
|
||||
|
||||
// answeredByHelper is whether a need's provider offers it answered by the provider helper (issue 792): keyed by the seat
|
||||
// mesh-provisioner for every consumer, handed or not (catalogue.Keyed, issue 774).
|
||||
func answeredByHelper(shelf map[string]catalogue.Manifest, n catalogue.Needed) bool {
|
||||
if n.ByRecord || n.Module == "" {
|
||||
return false
|
||||
}
|
||||
provider, known := shelf[n.Module]
|
||||
return known && provider.AnsweredByHelperFor(n.Name)
|
||||
}
|
||||
|
||||
// movedProvider is whether a provider's credentials are the vault's: the module is marked moved, or it holds the
|
||||
// mesh-vault seat (issue 793).
|
||||
func movedProvider(m catalogue.Manifest) bool {
|
||||
|
||||
@@ -358,3 +358,31 @@ func TestALicenceKeyTheInputDoesNotCarryIsAStandInAndNamedForeseen(t *testing.T)
|
||||
t.Errorf("a key the mesh holds none of: %q %v", n.Sealed, got.Foreseen)
|
||||
}
|
||||
}
|
||||
|
||||
// A consumer not handed of a provision whose provider's offer says the helper answers it has that provision in a plan of
|
||||
// its own (issue 774: grafana's route): the composition marks the need answered by the helper, and the plan keys it.
|
||||
func TestANeedAnsweredByTheHelperIsInTheConsumersPlan(t *testing.T) {
|
||||
in := theStoreMesh(t)
|
||||
store := in.Catalogue["store"]
|
||||
store.Provides = []catalogue.Offer{{Name: "postgres-database", Scope: catalogue.ScopeMesh, AnsweredBy: catalogue.AnsweredByHelper}}
|
||||
in.Catalogue["store"] = store
|
||||
got, err := Resolve(in, "laptop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := need(t, got.Plan, "board", "postgres-database"); !n.AnsweredByHelper {
|
||||
t.Fatalf("the need is not marked answered by the helper: %+v", n)
|
||||
}
|
||||
with := rendered(t, in, "laptop", Foreseeing).Rendering
|
||||
with.Hands, with.Provisioner = true, "mesh-provisioner-client"
|
||||
if with.Generators, err = newComposer(in).generators(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := got.Plan.Compose(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p := c.Plans["board"].Provisions; len(p) != 1 || p[0].Provision != "postgres-database" || p[0].ProviderModule != "store" {
|
||||
t.Fatalf("board's plan is %+v", c.Plans["board"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,11 +141,14 @@ const (
|
||||
RuleProviders = 5 // every provider the module requires runs healthy on that node, or at its canary
|
||||
RuleGrant = 6 // the module's credential for F exists (grants.<module>.<fp>)
|
||||
RuleComposed = 7 // the module's composition for that node at F exists (mesh-controller_composed, ADR 0539 §6)
|
||||
// RuleProvisions: every provision the module requires on that node is provided (novox/hq ADR 0570 §4): a want's
|
||||
// words name the key and its state that hold it, as "the database of keycloak on novox is asked of postgres".
|
||||
RuleProvisions = 8
|
||||
)
|
||||
|
||||
func checkRule(rule int, of string) error {
|
||||
if rule < RuleBuildDone || rule > RuleComposed {
|
||||
return fmt.Errorf("delivery: %s names the rule %d, which is not one of the seven (1 to 7)", of, rule)
|
||||
if rule < RuleBuildDone || rule > RuleProvisions {
|
||||
return fmt.Errorf("delivery: %s names the rule %d, which is not one of the eight (1 to 8)", of, rule)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@ func TestAssignerValuesRefused(t *testing.T) {
|
||||
{Removed: true, FP: fp2of('a')},
|
||||
{FP: fp2of('a'), Newest: fp2of('b')},
|
||||
{FP: fp2of('a'), Newest: fp2of('a'), Rule: 3},
|
||||
{FP: fp2of('a'), Composed: hashOf('1'), Newest: fp2of('b'), Rule: 8},
|
||||
{FP: fp2of('a'), Composed: hashOf('1'), Newest: fp2of('b'), Rule: 9},
|
||||
{FP: fp2of('a'), Composed: hashOf('1'), Hold: true},
|
||||
{FP: fp2of('a'), Composed: hashOf('1'), Put: true, Newest: fp2of('b'), Rule: 3},
|
||||
{Removed: true, Put: true},
|
||||
|
||||
@@ -35,10 +35,22 @@ func placing(t *testing.T, node string, modules ...string) (string, []byte) {
|
||||
for _, m := range modules {
|
||||
p.Modules = append(p.Modules, delivery.PlacedModule{Module: m})
|
||||
}
|
||||
key, err := delivery.PlacementKey(node)
|
||||
values, err := delivery.PlacementValues(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key, _ := delivery.PlacementNodeKey(node)
|
||||
return key, values[key]
|
||||
}
|
||||
|
||||
// placingLegacy is placing in the form before, `placement.<node>` (research 072 row 37).
|
||||
func placingLegacy(t *testing.T, node string, modules ...string) (string, []byte) {
|
||||
t.Helper()
|
||||
p := delivery.Placement{Node: node}
|
||||
for _, m := range modules {
|
||||
p.Modules = append(p.Modules, delivery.PlacedModule{Module: m})
|
||||
}
|
||||
key, _ := delivery.LegacyPlacementKey(node)
|
||||
value, err := delivery.EncodePlacement(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -177,8 +189,21 @@ func TestToolchainGate(t *testing.T) {
|
||||
|
||||
t.Run("the history: a module in the first revision heard of a key stands since before it", func(t *testing.T) {
|
||||
p := delivery.NewPlaced()
|
||||
k, v := placing(t, "ace", NodeBuildModule)
|
||||
// ace in the form before, then converted: its module stands since before, the legacy key read no more.
|
||||
k, v := placingLegacy(t, "ace", NodeBuildModule)
|
||||
p.Put(k, v, false, old)
|
||||
k, v = placing(t, "ace", NodeBuildModule)
|
||||
if _, changed, err := p.Put(k, v, false, placed); len(changed) != 0 || err != nil {
|
||||
t.Errorf("the conversion of ace's key changed %v, %v", changed, err)
|
||||
}
|
||||
if _, changed, _ := p.Put("ace."+NodeBuildModule, []byte("{}"), false, placed); len(changed) != 0 {
|
||||
t.Errorf("a module key says which modules stand: %v", changed)
|
||||
}
|
||||
if k, v := placingLegacy(t, "ace"); true {
|
||||
if _, changed, _ := p.Put(k, v, false, placed); len(changed) != 0 {
|
||||
t.Errorf("the legacy key is read after the node key: %v", changed)
|
||||
}
|
||||
}
|
||||
k2, v2 := placing(t, "g14")
|
||||
p.Put(k2, v2, false, old)
|
||||
k2, v2 = placing(t, "g14", NodeBuildModule)
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// **The control records' layouts, out of the controller** (novox/hq ADR 0565 clauses 2, 3 and 6; issue 1953 L5-S2): what
|
||||
// the indexer keeps of the catalogue's predefined list and of each open pull request's config declarations, which
|
||||
// mesh-config reads in place of the controller's mirrors and `pull-declares`; and each node's declared packages, which
|
||||
// its engine writes in place of the controller's mirror.
|
||||
|
||||
// The indexer's two states (clause 2).
|
||||
const (
|
||||
// IndexerPredefinedState holds one key, PredefinedListKey: the catalogue's predefined.json at the newest merge taken.
|
||||
IndexerPredefinedState = "predefined"
|
||||
PredefinedListKey = "list"
|
||||
// IndexerConfigCandidatesState holds one key per open pull request (ConfigCandidateKey).
|
||||
IndexerConfigCandidatesState = "config-candidates"
|
||||
)
|
||||
|
||||
// PredefinedKept is the indexer's key `predefined.list`: the list whole, as predefined.json holds it, with the merge it
|
||||
// was read at. A merge older than the one the key names never replaces it (ADR 0348 §7).
|
||||
type PredefinedKept struct {
|
||||
V int `json:"v"`
|
||||
Commit string `json:"commit"`
|
||||
At time.Time `json:"at"`
|
||||
List json.RawMessage `json:"list"`
|
||||
}
|
||||
|
||||
// ConfigDeclaration is one config key a module's manifest declares, in the layout of the controller's declared-config
|
||||
// mirror (mesh-controller internal/inventory DeclaredMirrored) unchanged, so mesh-config reads the indexer's as it read
|
||||
// the mirror's.
|
||||
type ConfigDeclaration struct {
|
||||
Kind string `json:"kind"`
|
||||
Default json.RawMessage `json:"default"`
|
||||
Why string `json:"why"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Enum json.RawMessage `json:"enum,omitempty"`
|
||||
Minimum *float64 `json:"minimum,omitempty"`
|
||||
Maximum *float64 `json:"maximum,omitempty"`
|
||||
Pattern string `json:"pattern,omitempty"`
|
||||
Trusted bool `json:"trusted,omitempty"`
|
||||
Predefined string `json:"predefined,omitempty"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
Runtime bool `json:"runtime,omitempty"`
|
||||
}
|
||||
|
||||
// ConfigCandidate is one open pull request's key of `config-candidates`: its head, and per module the config
|
||||
// declarations the head's manifests add or change against the trunk's, an empty set when none. Written at each new
|
||||
// head and each trunk merge that changes a module it names; deleted when the pull request merges or closes.
|
||||
type ConfigCandidate struct {
|
||||
V int `json:"v"`
|
||||
Pull string `json:"pull"`
|
||||
Head string `json:"head"`
|
||||
Declares map[string]map[string]ConfigDeclaration `json:"declares"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// ConfigCandidateKey is a pull request's key, `owner/repository#number` with `/` and `#` as dots, the form mesh-config
|
||||
// keeps a pull request's values under.
|
||||
func ConfigCandidateKey(pull string) (string, error) {
|
||||
owner, rest, ok := strings.Cut(strings.TrimSpace(pull), "/")
|
||||
repo, number, ok2 := strings.Cut(rest, "#")
|
||||
if !ok || !ok2 || owner == "" || repo == "" || number == "" || strings.ContainsAny(owner+repo+number, ".*> ") {
|
||||
return "", fmt.Errorf("delivery: %q is not a pull request, owner/repository#number", pull)
|
||||
}
|
||||
return owner + "." + repo + "." + number, nil
|
||||
}
|
||||
|
||||
// The node-engine's packages (clause 6): its state `packages`, one key, the node's name, written by that node's engine
|
||||
// alone.
|
||||
const EnginePackagesState = "packages"
|
||||
|
||||
// PackageDeclared is one package a node's modules declare, the controller's declared-packages mirror's entry unchanged
|
||||
// (mesh-controller internal/inventory PackageDeclared): its name, the modules that declare it that way, sorted, and
|
||||
// whether they declare it absent.
|
||||
type PackageDeclared struct {
|
||||
Package string `json:"package"`
|
||||
Modules []string `json:"modules"`
|
||||
Absent bool `json:"absent"`
|
||||
}
|
||||
|
||||
// DeclaredPackages is one node's key of `packages`, the mirror's value unchanged: its packages sorted by name, absent
|
||||
// after present. Fixed fields and no time, so the same declaration is the same bytes (ADR 0289 §1).
|
||||
type DeclaredPackages struct {
|
||||
Node string `json:"node"`
|
||||
Packages []PackageDeclared `json:"packages"`
|
||||
}
|
||||
|
||||
// mesh-config's state of the approved values held for a pull request (novox/hq ADR 0565 clause 5): read by the checker
|
||||
// and the assigner's composition check, which lay a pull request's own held values over its modules' values.
|
||||
const (
|
||||
MeshConfigKeptState = "kept"
|
||||
MeshConfigKeptRead = "mesh-config." + MeshConfigKeptState
|
||||
)
|
||||
|
||||
// KeptValue is one approved held value, mesh-config's layout unchanged (modules/mesh-config pulls.go): kept apart from
|
||||
// the values mesh-config resolves, laid only over the composition of its own pull request at its head.
|
||||
type KeptValue struct {
|
||||
Pull string `json:"pull"`
|
||||
Head string `json:"head"`
|
||||
Digest string `json:"declaration"`
|
||||
Node string `json:"node"`
|
||||
Module string `json:"module"`
|
||||
Key string `json:"key"`
|
||||
// Value is the value held, as the operator approved it.
|
||||
Value json.RawMessage `json:"value"`
|
||||
By string `json:"by"`
|
||||
Via string `json:"via"`
|
||||
Why string `json:"why"`
|
||||
// Approved is who approved it, as the proposal kept it.
|
||||
Approved string `json:"approved"`
|
||||
Proposal string `json:"proposal"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// HeldValueKey is a held value's key in `kept`: the pull request's key (ConfigCandidateKey's form), the node, the module and
|
||||
// the config key, joined by dots, as mesh-config writes it.
|
||||
func HeldValueKey(pull, node, module, key string) (string, error) {
|
||||
p, err := ConfigCandidateKey(pull)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return p + "." + node + "." + module + "." + key, nil
|
||||
}
|
||||
|
||||
// The assigner's state of a person's upgrade policy, one key per module (clause 7; catalogue.PersonPolicy).
|
||||
const AssignerPoliciesState = "policies"
|
||||
@@ -0,0 +1,150 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ADR 0565 clauses 2 and 6 (issue 1953 L5-S2): the control records' layouts read what their writers write. The
|
||||
// packages value is the controller's declared-packages mirror's, as its writer's own test pins it (mesh-controller
|
||||
// internal/inventory/declared_packages_test.go at 0fbb5ef3); the config declaration is its declared-config mirror's
|
||||
// (DeclaredMirrored); a pull request's key is mesh-config's form.
|
||||
func TestTheControlRecordsReadTheirWritersValues(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
raw string
|
||||
into any
|
||||
}{
|
||||
{"declared packages", `{"node":"one","packages":[{"package":"curl","modules":["backup","web"],"absent":false},` +
|
||||
`{"package":"nano","modules":["web"],"absent":true}]}`, &DeclaredPackages{}},
|
||||
{"a config candidate", `{"v":1,"pull":"novox/mesh-catalog#42","head":"abc","declares":{"mail":{"quota":{"kind":"config",` +
|
||||
`"default":10,"why":"a quota","type":"integer","minimum":1}}},"at":"2026-10-29T12:00:00Z"}`, &ConfigCandidate{}},
|
||||
{"a held value", `{"pull":"novox/mesh-catalog#42","head":"abc","declaration":"sha256:d","node":"novox","module":"mail",` +
|
||||
`"key":"quota","value":20,"by":"person.jochen","via":"telegram","why":"more room","approved":"the operator",` +
|
||||
`"proposal":"p-1","at":"2026-10-29T12:00:00Z"}`, &KeptValue{}},
|
||||
// A node's health report as mesh-host 65fbc1e6 encodes it (its test's look, struct order kept).
|
||||
{"a node's health report", `{"v":1,"node":"anchor","network":{"state":"unhealthy","since":"2026-10-29T20:00:00Z",` +
|
||||
`"parts":[{"part":"resolv-conf","state":"unhealthy","writer":"NetworkManager","owner":"dnsmasq","since":"2026-10-29T20:00:00Z"}]},` +
|
||||
`"peers":[{"key":"A=","addresses":["10.10.0.2/32"],"seen":false},{"key":"B=","seen":true}],` +
|
||||
`"units":{"state":"degraded","failed":[{"unit":"cups.service","scope":"system","since":"2026-10-29T20:00:00Z"}]},` +
|
||||
`"relogin":[{"account":"jochen","module":"brother","resource":"brother.scanner","reason":"relogin needed: the session began before scanner","since":"2026-10-29T20:00:00Z"}],` +
|
||||
`"found":[{"module":"photos","resource":"photos.library","path":"/srv/photos","reason":"used as found: owned by root","since":"2026-10-29T20:00:00Z"}],` +
|
||||
`"at":"2026-10-29T20:00:00Z"}`, &NodeHealth{}},
|
||||
// The engine's verdict on an account declared never to become root (ADR 0266), and its contract.
|
||||
{"a node's health report, the agent account judged", `{"v":1,"node":"anchor","root":[{"account":"agent",` +
|
||||
`"module":"claude-code","resource":"claude-code.agent","state":"healthy","since":"2026-10-29T20:00:00Z"}],` +
|
||||
`"contract":13,"at":"2026-10-29T20:00:00Z"}`, &NodeHealth{}},
|
||||
// A part pointing at another machine names it (issue 774): the hub's tunnel, held at the hub's machine.
|
||||
{"a node's health report, a part pointing at the hub", `{"v":1,"node":"anchor","network":{"state":"unhealthy",` +
|
||||
`"since":"2026-10-29T20:00:00Z","parts":[{"part":"tunnel","state":"unhealthy","reason":"the tunnel to the hub has not handshaken for over five minutes",` +
|
||||
`"toward":["hub"],"targets":["novox"],"since":"2026-10-29T20:00:00Z"}]},"at":"2026-10-29T20:00:00Z"}`, &NodeHealth{}},
|
||||
{"the predefined list", `{"v":1,"commit":"abc","at":"2026-10-29T12:00:00Z","list":{"domain":{"type":"string"}}}`, &PredefinedKept{}},
|
||||
} {
|
||||
if err := json.Unmarshal([]byte(c.raw), c.into); err != nil {
|
||||
t.Fatalf("%s: %v", c.name, err)
|
||||
}
|
||||
again, _ := json.Marshal(c.into)
|
||||
var want, got any
|
||||
_ = json.Unmarshal([]byte(c.raw), &want)
|
||||
_ = json.Unmarshal(again, &got)
|
||||
if string(jsonOf(want)) != string(jsonOf(got)) || ((c.name == "declared packages" || strings.HasPrefix(c.name, "a node's health report")) && string(again) != c.raw) {
|
||||
t.Errorf("%s reads back as %s", c.name, again)
|
||||
}
|
||||
}
|
||||
if a, b := (NodeHealth{Node: "anchor", At: time.Now()}), (NodeHealth{Node: "anchor"}); !SameNodeHealth(a, b) ||
|
||||
SameNodeHealth(a, NodeHealth{Node: "anchor", Peers: []PeerSeen{{Key: "A="}}}) {
|
||||
t.Error("two reports differing only in when they were written are not the same, or differing ones are")
|
||||
}
|
||||
if _, err := EncodeNodeHealth(NodeHealth{}); err == nil {
|
||||
t.Error("a report naming no node was encoded")
|
||||
}
|
||||
// ADR 0571 clause 4: who holds which seat, from placement; a node seat by its node.
|
||||
held := SeatHoldersOf(map[string]Placement{
|
||||
"ace": {Node: "ace", Holds: []Holding{{Claim: "mesh-dns-resolver", Scope: "mesh", Module: "dnsmasq"}, {Claim: "node-files", Scope: "node", Module: "mesh-files"}}},
|
||||
"novox": {Node: "novox", Holds: []Holding{{Claim: "mesh-dns-resolver", Scope: "mesh", Module: "dnsmasq"}, {Claim: "mesh-broker", Scope: "mesh", Module: "nats"},
|
||||
{Claim: "photo-gallery", Scope: "mesh", Module: "photos"}}},
|
||||
})
|
||||
if o := held["photo-gallery"]; !o.Outside || len(o.Holders) != 1 || held["mesh-broker"].Outside {
|
||||
t.Errorf("a claim outside the set is %+v", o)
|
||||
}
|
||||
keys := map[string][]byte{}
|
||||
for k, v := range held {
|
||||
keys[k], _ = json.Marshal(v)
|
||||
}
|
||||
if h, err := HoldersOf(keys, "mesh-dns-resolver", ""); err != nil || len(h) != 2 || h[0].Node != "ace" {
|
||||
t.Errorf("a replicated seat's holders are %v, %v", h, err)
|
||||
}
|
||||
if h, _ := HoldersOf(keys, "node-files", "ace"); len(h) != 1 || h[0].Module != "mesh-files" || len(held) != 4 {
|
||||
t.Errorf("a node seat's holder is %v, keys %v", h, held)
|
||||
}
|
||||
// ADR 0571 clauses 4, 5: the modules and the machines, from the indexer's and the placement seat's keys.
|
||||
if m, err := ModuleNames(map[string][]byte{"letta": nil, "gitea": nil}); err != nil || strings.Join(m, ",") != "gitea,letta" {
|
||||
t.Errorf("the modules are %v, %v", m, err)
|
||||
}
|
||||
if _, err := ModuleNames(map[string][]byte{"a.b": nil}); err == nil {
|
||||
t.Error("a key that is no module's was read as one")
|
||||
}
|
||||
if n := MachineNames(map[string][]byte{"placement.novox": nil, "placement.ace": nil, "ace.node": nil, "ace.app": nil, "g14.app": nil, "proposal.p1": nil}); strings.Join(n, ",") != "ace,novox" {
|
||||
t.Errorf("the machines are %v", n)
|
||||
}
|
||||
if h, _ := HoldersOf(keys, "node-files", "novox"); h != nil {
|
||||
t.Errorf("a node seat nobody holds there is held by %v", h)
|
||||
}
|
||||
// ADR 0571 clause 1: the builder's log, a start and an end, and the duration between them.
|
||||
at := time.Date(2026, 10, 29, 22, 0, 0, 0, time.UTC)
|
||||
start := BuildLogEntry{Event: BuildStartedEvent, Module: "letta", FP: "src2:a", Source: "abc", Repository: "novox/mesh-catalog",
|
||||
Directory: "modules/letta", Commit: "abc123", Node: "ace", At: at}
|
||||
end := start
|
||||
end.Event, end.At, end.Outcome, end.Manifest, end.Against = BuildEndedEvent, at.Add(3*time.Minute), "done", "sha256:m", []string{"artifact-store://base/image@sha256:b"}
|
||||
end.Made = []BuiltArtifact{{Name: "image", Kind: "image", Digest: "sha256:1", Reference: "artifact-store://letta/image@sha256:1"}}
|
||||
for _, e := range []BuildLogEntry{start, end} {
|
||||
raw, err := EncodeBuildLogEntry(e)
|
||||
back, derr := DecodeBuildLogEntry(raw)
|
||||
if err != nil || derr != nil || back.Event != e.Event || back.Outcome != e.Outcome || back.Directory != "modules/letta" ||
|
||||
back.Repository != "novox/mesh-catalog" || back.Commit != "abc123" {
|
||||
t.Errorf("%s read back as %+v, %v %v", e.Event, back, err, derr)
|
||||
}
|
||||
}
|
||||
if d, ok := BuildDuration(start, end); !ok || d != 3*time.Minute {
|
||||
t.Errorf("the build took %s, %v", d, ok)
|
||||
}
|
||||
older := end
|
||||
older.At, older.FP = at.Add(-time.Hour), "src2:b"
|
||||
other := end
|
||||
other.Module = "mesh-catalog"
|
||||
if n := NewestPerModule([]BuildLogEntry{older, end, other}); n["letta"].FP != "src2:a" || len(n) != 2 {
|
||||
t.Errorf("each module's newest is %v", n)
|
||||
}
|
||||
if e := EndedSince([]BuildLogEntry{end, start, older}, at.Add(-2*time.Hour)); len(e) != 2 || e[0].FP != "src2:b" {
|
||||
t.Errorf("the ended builds since are %v", e)
|
||||
}
|
||||
if k, err := BuildLogKey("letta"); k != "letta" || err != nil || BuildLogRead != "mesh-delivery-builder.build-log" {
|
||||
t.Errorf("the key is %q, %v", k, err)
|
||||
}
|
||||
if _, err := BuildLogKey("a.b"); err == nil {
|
||||
t.Error("a module with a dot was taken as a key")
|
||||
}
|
||||
for _, edit := range []func(*BuildLogEntry){func(e *BuildLogEntry) { e.Outcome = "" }, func(e *BuildLogEntry) { e.Commit = "" },
|
||||
func(e *BuildLogEntry) { e.Event, e.Outcome, e.Against = BuildStartedEvent, "", nil }} {
|
||||
bad := end
|
||||
edit(&bad)
|
||||
if _, err := EncodeBuildLogEntry(bad); err == nil {
|
||||
t.Errorf("%+v was encoded", bad)
|
||||
}
|
||||
}
|
||||
if k, err := HeldValueKey("novox/mesh-catalog#42", "novox", "mail", "quota"); k != "novox.mesh-catalog.42.novox.mail.quota" || err != nil {
|
||||
t.Errorf("a held value's key is %q, %v", k, err)
|
||||
}
|
||||
for pull, want := range map[string]string{"novox/mesh-catalog#42": "novox.mesh-catalog.42", "x#1": "", "a/b": "", "a/b.c#1": ""} {
|
||||
if got, err := ConfigCandidateKey(pull); got != want || (want == "") != (err != nil) {
|
||||
t.Errorf("%s keyed %q, %v", pull, got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func jsonOf(v any) []byte {
|
||||
b, _ := json.Marshal(v)
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// **The data items of each module placed on a node, as its engine composed them** (novox/hq ADR 0577 clauses 1 and 4):
|
||||
// the node-engine's state `data` (EngineDataBucket), one key `<node>.<module>` (EngineDataKey) per module placed on the
|
||||
// node that declares data items, written by that node's engine alone, when the module's composition changes and only
|
||||
// when the value differs, and deleted when the module leaves the node. That node's disk-load watches its node's keys
|
||||
// (EngineDataNodeKeys) and measures what they name; it composes nothing itself. Only the contract is here: the state's
|
||||
// names, its key and its value; the engine composes the items and disk-load measures them.
|
||||
|
||||
// EngineDataState, EngineDataBucket and EngineDataRead are the node-engine's state data, by its local name, as the bus
|
||||
// names it and as a reader declares it.
|
||||
const (
|
||||
EngineDataState = "data"
|
||||
EngineDataBucket = "mesh-host_" + EngineDataState
|
||||
EngineDataRead = "mesh-host." + EngineDataState
|
||||
)
|
||||
|
||||
// EngineDataVersion is the version of EngineData's layout.
|
||||
const EngineDataVersion = 1
|
||||
|
||||
// EngineData is one key's value: the module's data items on the node as the engine composed them (each item's id, its
|
||||
// path resolved on that node, its measure, class, protection and what covers it), and the digest of the module's
|
||||
// composition they came from (ComposedDigestOf).
|
||||
type EngineData struct {
|
||||
V int `json:"v"`
|
||||
Node string `json:"node"`
|
||||
Module string `json:"module"`
|
||||
Digest string `json:"digest"`
|
||||
Items []catalogue.PlacedDataItem `json:"items"`
|
||||
}
|
||||
|
||||
// EngineDataKey is a module's key on a node, `<node>.<module>`, its module part escaped as a have's is (HaveKey).
|
||||
func EngineDataKey(node, module string) (string, error) { return HaveKey(node, module) }
|
||||
|
||||
// EngineDataNodeKeys is the pattern of every key of a node, `<node>.*`: what that node's disk-load watches.
|
||||
func EngineDataNodeKeys(node string) (string, error) {
|
||||
if err := checkName("node", node); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return node + ".*", nil
|
||||
}
|
||||
|
||||
// EncodeEngineData writes a value at this layout's version.
|
||||
func EncodeEngineData(d EngineData) ([]byte, error) {
|
||||
d.V = EngineDataVersion
|
||||
if _, err := EngineDataKey(d.Node, d.Module); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if d.Items == nil {
|
||||
d.Items = []catalogue.PlacedDataItem{}
|
||||
}
|
||||
return json.Marshal(d)
|
||||
}
|
||||
|
||||
// DecodeEngineData reads a value; it refuses another version.
|
||||
func DecodeEngineData(raw []byte) (EngineData, error) {
|
||||
var d EngineData
|
||||
if err := json.Unmarshal(raw, &d); err != nil {
|
||||
return EngineData{}, fmt.Errorf("delivery: a module's data items are not readable: %w", err)
|
||||
}
|
||||
if d.V != EngineDataVersion {
|
||||
return EngineData{}, fmt.Errorf("delivery: a module's data items of version %d, and this reads %d", d.V, EngineDataVersion)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
@@ -11,6 +11,10 @@ import (
|
||||
// SourcePrefix2 opens every source fingerprint of the new form.
|
||||
const SourcePrefix2 = "src2:"
|
||||
|
||||
// NoPackageVersion opens the reason a module has no fingerprint while a mesh package it uses has no published version
|
||||
// the indexer holds (novox/hq ADR 0573 §2); the packages follow, by module path.
|
||||
const NoPackageVersion = "no published version of the package "
|
||||
|
||||
// Toolchain2 is what an artifact's toolchain enters the new fingerprint as (novox/hq ADR 0405 clause 7): the
|
||||
// artifact's language, the installed toolchain's version and the build module's recipe version, in place of the
|
||||
// toolchain image's digest. A toolchain update so changes the fingerprints of that language's modules only.
|
||||
@@ -37,15 +41,19 @@ type SourceInputs2 struct {
|
||||
Contexts map[string]string
|
||||
Locks map[string]string
|
||||
Toolchains map[string]Toolchain2
|
||||
Unpinned string
|
||||
// Packages are the version of each mesh package the module uses, by package (novox/hq ADR 0573 §2): a package with
|
||||
// no version gives no fingerprint. None leaves the fingerprint as it was before packages entered it.
|
||||
Packages map[string]string
|
||||
Unpinned string
|
||||
}
|
||||
|
||||
// SourceFingerprint2 is the module's source fingerprint of the new form, `src2:` and the hex sha256 of its inputs'
|
||||
// lines, or none and the reason when the source does not pin the build: Unpinned when set, "no tree" when Tree is
|
||||
// empty, "no lock" when an artifact resolves packages and has no lock. It is a pure function of its inputs: the order
|
||||
// of maps and a base given twice change nothing. The lines are "module <m>", "tree <t>" and "builder <fp>", then
|
||||
// "base <fp>" (once each), "context <artifact> <tree>", "lock <artifact> <hash>" and "toolchain <artifact> <language>
|
||||
// <version> <recipe>", sorted, all joined by a newline.
|
||||
// "base <fp>" (once each), "context <artifact> <tree>", "lock <artifact> <hash>", "toolchain <artifact> <language>
|
||||
// <version> <recipe>" and "package <package> <version>", sorted, all joined by a newline. A module that uses no mesh
|
||||
// package has no package line, so its fingerprint is the one it had before packages entered (novox/hq ADR 0573 §2).
|
||||
func SourceFingerprint2(in SourceInputs2) (fp, none string) {
|
||||
if in.Unpinned != "" {
|
||||
return "", in.Unpinned
|
||||
@@ -58,6 +66,16 @@ func SourceFingerprint2(in SourceInputs2) (fp, none string) {
|
||||
return "", "no lock"
|
||||
}
|
||||
}
|
||||
var unpublished []string
|
||||
for p, v := range in.Packages {
|
||||
if v == "" {
|
||||
unpublished = append(unpublished, p)
|
||||
}
|
||||
}
|
||||
if len(unpublished) > 0 {
|
||||
sort.Strings(unpublished)
|
||||
return "", NoPackageVersion + strings.Join(unpublished, ", ")
|
||||
}
|
||||
lines := []string{"module " + in.Module, "tree " + in.Tree, "builder " + in.Builder}
|
||||
bases := map[string]bool{}
|
||||
for _, b := range in.Bases {
|
||||
@@ -75,6 +93,9 @@ func SourceFingerprint2(in SourceInputs2) (fp, none string) {
|
||||
for a, t := range in.Toolchains {
|
||||
lines = append(lines, "toolchain "+a+" "+t.Language+" "+t.Version+" "+t.Recipe)
|
||||
}
|
||||
for p, v := range in.Packages {
|
||||
lines = append(lines, "package "+p+" "+v)
|
||||
}
|
||||
sort.Strings(lines[3:])
|
||||
sum := sha256.Sum256([]byte(strings.Join(lines, "\n")))
|
||||
return SourcePrefix2 + hex.EncodeToString(sum[:]), ""
|
||||
|
||||
@@ -62,6 +62,9 @@ func TestFingerprint2Changes(t *testing.T) {
|
||||
"a context": func(in *SourceInputs2) { in.Contexts["mail"] = "t-changed" },
|
||||
"a context added": func(in *SourceInputs2) { in.Contexts["extra"] = "t-root" },
|
||||
"a toolchain added": func(in *SourceInputs2) { in.Toolchains["extra"] = Toolchain2{"go", "1.26.0", "3"} },
|
||||
// Issue 1585 (ADR 0573 §2): a package's new published version, and a package used at all.
|
||||
"a package added": func(in *SourceInputs2) { in.Packages = map[string]string{"git.novox.be/novox/mesh-sdk/go": "c1"} },
|
||||
"a package version": func(in *SourceInputs2) { in.Packages = map[string]string{"git.novox.be/novox/mesh-sdk/go": "c2"} },
|
||||
} {
|
||||
in := inputs2()
|
||||
f(&in)
|
||||
@@ -111,6 +114,17 @@ func TestFingerprint2None(t *testing.T) {
|
||||
if fp, none := SourceFingerprint2(in); fp != "" || none != "no tree" {
|
||||
t.Errorf("no tree: fp = %q, none = %q", fp, none)
|
||||
}
|
||||
// Issue 1585: a package with no published version gives none, naming it; an empty set of packages changes nothing.
|
||||
in = inputs2()
|
||||
in.Packages = map[string]string{"git.novox.be/novox/b/go": "", "git.novox.be/novox/a/go": "", "git.novox.be/novox/c/go": "c1"}
|
||||
if fp, none := SourceFingerprint2(in); fp != "" || none != NoPackageVersion+"git.novox.be/novox/a/go, git.novox.be/novox/b/go" {
|
||||
t.Errorf("no package version: fp = %q, none = %q", fp, none)
|
||||
}
|
||||
in = inputs2()
|
||||
in.Packages = map[string]string{}
|
||||
if fp, _ := SourceFingerprint2(in); fp != mustFP2(t, inputs2()) {
|
||||
t.Errorf("no package changes the fingerprint: %s", fp)
|
||||
}
|
||||
in = inputs2()
|
||||
in.Tree, in.Unpinned = "", "a context is a Go program's build source"
|
||||
if _, none := SourceFingerprint2(in); none != in.Unpinned {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -33,6 +34,22 @@ func TestForgeMergedCarriesManifestsAndLocks(t *testing.T) {
|
||||
if got.Manifests["a"] != oddManifest || !SameForge(got, withV(f)) {
|
||||
t.Errorf("the message changed on the way: %q", got.Manifests["a"])
|
||||
}
|
||||
// ADR 0565 §2 (issue 1953 D1): a merged message carries the predefined list exact, or says the merge holds none; a
|
||||
// pull message carries neither.
|
||||
f.Predefined = []byte(`{"domain":{"type":"string"}}`)
|
||||
if data, err := EncodeForge(f); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if got, err := DecodeForge(data); err != nil || string(got.Predefined) != `{"domain":{"type":"string"}}` {
|
||||
t.Errorf("the predefined list read back as %s, %v", got.Predefined, err)
|
||||
}
|
||||
f.Predefined, f.PredefinedRead = nil, true
|
||||
if _, err := EncodeForge(f); err != nil {
|
||||
t.Errorf("a merge holding no predefined list: %v", err)
|
||||
}
|
||||
pull := Forge{Kind: ForgePull, Repository: "novox/mesh-sdk", Number: 1, Head: "h", Action: PullOpened, PredefinedRead: true}
|
||||
if _, err := EncodeForge(pull); err == nil || !strings.Contains(err.Error(), "only a merged message") {
|
||||
t.Errorf("a pull message carrying the predefined list: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func withV(f Forge) Forge { f.V = Version; return f }
|
||||
@@ -50,6 +67,9 @@ func TestForgeMergedRefusesBadInputs(t *testing.T) {
|
||||
// No repository holds placement (ADR 0567 §11): a message carrying placement files is refused, on a merge or a pull.
|
||||
"placement files": {func(f *Forge) { f.RetiredPlacements = []byte(`{"anchor":"{}"}`) }, "mesh-placement"},
|
||||
"placements read": {func(f *Forge) { f.RetiredRead = true }, "mesh-placement"},
|
||||
// ADR 0565 §2 (issue 1953 D1): the predefined list is one JSON object, said present or absent, never both.
|
||||
"predefined not an object": {func(f *Forge) { f.Predefined = []byte(`["domain"]`) }, "one JSON object"},
|
||||
"predefined both": {func(f *Forge) { f.Predefined = []byte(`{}`); f.PredefinedRead = true }, "says the merge holds none"},
|
||||
} {
|
||||
f := mergedWithInputs()
|
||||
tc.mut(&f)
|
||||
@@ -144,4 +164,35 @@ func TestModuleKeepsInputs2(t *testing.T) {
|
||||
if _, err := EncodeModule(m); err == nil {
|
||||
t.Error("Inputs2 without a manifest digest was encoded")
|
||||
}
|
||||
|
||||
// Issue 1585 (ADR 0573 §2): the mesh packages a go.sum names, those of other repositories, and the latest published
|
||||
// version of each: the trunk commit of the deepest module of its repository whose directory holds it.
|
||||
sum := "git.novox.be/novox/mesh-sdk/go v0.1.7 h1:a=\ngit.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:b=\n" +
|
||||
"git.novox.be/novox/mesh-controller/modules/route-proxy/contract v0.1.0 h1:c=\n" +
|
||||
"github.com/nats-io/nats.go v1.0.0 h1:d=\ngit.novox.be/novox/mesh-host v0.0.0-1 h1:e=\n"
|
||||
pkgs := MeshPackagesOf([]byte(sum))
|
||||
if want := []string{"git.novox.be/novox/mesh-controller/modules/route-proxy/contract", "git.novox.be/novox/mesh-host", "git.novox.be/novox/mesh-sdk/go"}; !reflect.DeepEqual(pkgs, want) {
|
||||
t.Errorf("the packages of a go.sum: %v, want %v", pkgs, want)
|
||||
}
|
||||
if got := OtherPackages(pkgs, "novox/mesh-controller"); len(got) != 2 || got[0] != "git.novox.be/novox/mesh-host" {
|
||||
t.Errorf("the packages of other repositories: %v", got)
|
||||
}
|
||||
held := map[string]Module{
|
||||
"mesh-sdk": {Repository: "novox/mesh-sdk", Path: RootPath, Commit: "c-sdk"},
|
||||
"route-proxy": {Repository: "novox/mesh-controller", Path: "modules/route-proxy", Commit: "c-rp"},
|
||||
"controller": {Repository: "novox/mesh-controller", Path: RootPath, Commit: "c-ctl"},
|
||||
"route-proxy2": {Repository: "novox/mesh-controller", Path: "modules/route", Commit: "c-wrong"},
|
||||
"no-path": {Repository: "novox/mesh-host", Commit: "c-host"},
|
||||
}
|
||||
versions, missing := PackageVersionsOf(pkgs, held)
|
||||
if want := (PackageVersions{"git.novox.be/novox/mesh-sdk/go": "c-sdk", "git.novox.be/novox/mesh-controller/modules/route-proxy/contract": "c-rp"}); !reflect.DeepEqual(versions, want) {
|
||||
t.Errorf("versions %v, want %v", versions, want)
|
||||
}
|
||||
if len(missing) != 1 || missing[0] != "git.novox.be/novox/mesh-host" {
|
||||
t.Errorf("missing %v: a module that names no directory holds no package", missing)
|
||||
}
|
||||
kept.Packages = []string{"git.novox.be/novox/mesh-sdk/go", "git.novox.be/novox/mesh-host"}
|
||||
if fp, none := SourceFingerprint2(kept.WithPackages("m", tc, versions)); fp != "" || !strings.HasPrefix(none, NoPackageVersion) {
|
||||
t.Errorf("a package with no version: %q %q", fp, none)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// **Who holds which seat, and the builder's log** (novox/hq ADR 0571 clauses 1 and 4, issue 1955).
|
||||
|
||||
// SeatHoldersState is the state the seat mesh-placement's holder writes of who holds which seat on which node (clause 4):
|
||||
// one key per seat held once for the mesh, `<seat>`, and one per node of a seat held on every node, `<seat>.<node>`.
|
||||
const (
|
||||
SeatHoldersState = "seat-holders"
|
||||
SeatHoldersBucket = AssignerModule + "_" + SeatHoldersState
|
||||
SeatHoldersRead = AssignerModule + "." + SeatHoldersState
|
||||
)
|
||||
|
||||
// SeatHolder is one module holding a seat on a node.
|
||||
type SeatHolder struct {
|
||||
Module string `json:"module"`
|
||||
Node string `json:"node"`
|
||||
}
|
||||
|
||||
// SeatHolders is one key of seat-holders: the seat, its scope, the node for a node seat's key, and its holders
|
||||
// (several only for a replicated mesh seat), sorted. Outside says the claim names no seat of the set (catalogue.Seats):
|
||||
// a module registered before the set closed, or built outside the indexed repositories, keeps its claim listed, as the
|
||||
// controller's `seats` listed it under outside.
|
||||
type SeatHolders struct {
|
||||
V int `json:"v"`
|
||||
Seat string `json:"seat"`
|
||||
Scope string `json:"scope"`
|
||||
Node string `json:"node,omitempty"`
|
||||
Outside bool `json:"outside,omitempty"`
|
||||
Holders []SeatHolder `json:"holders"`
|
||||
}
|
||||
|
||||
// SeatHoldersKey is a seat's key: `<seat>` for a seat held once for the mesh (node empty), `<seat>.<node>` for a node
|
||||
// seat on one node.
|
||||
func SeatHoldersKey(seat, node string) (string, error) {
|
||||
if err := checkName("seat", seat); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if node == "" {
|
||||
return seat, nil
|
||||
}
|
||||
if err := checkName("node", node); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return seat + "." + node, nil
|
||||
}
|
||||
|
||||
// SeatHoldersOf is the state composed from every node's placement (ADR 0567 §1): each holding by the seat it claims,
|
||||
// keyed as SeatHoldersKey says, with the module holding it and its node. A claim naming a seat's former name is keyed by
|
||||
// the seat's name (ADR 0122); one naming no seat is kept, marked Outside. Deterministic, so a writer writes only a key
|
||||
// that differs.
|
||||
func SeatHoldersOf(placements map[string]Placement) map[string]SeatHolders {
|
||||
out := map[string]SeatHolders{}
|
||||
nodes := make([]string, 0, len(placements))
|
||||
for n := range placements {
|
||||
nodes = append(nodes, n)
|
||||
}
|
||||
sort.Strings(nodes)
|
||||
for _, n := range nodes {
|
||||
for _, h := range placements[n].Holds {
|
||||
node := ""
|
||||
if h.Scope == "node" {
|
||||
node = n
|
||||
}
|
||||
seat, known := catalogue.SeatNamed(h.Claim)
|
||||
name := h.Claim
|
||||
if known {
|
||||
name = seat.Name
|
||||
}
|
||||
key, err := SeatHoldersKey(name, node)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
sh := out[key]
|
||||
sh.V, sh.Seat, sh.Scope, sh.Node, sh.Outside = Version, name, h.Scope, node, !known
|
||||
sh.Holders = append(sh.Holders, SeatHolder{Module: h.Module, Node: n})
|
||||
out[key] = sh
|
||||
}
|
||||
}
|
||||
for k, sh := range out {
|
||||
sort.Slice(sh.Holders, func(i, j int) bool {
|
||||
return sh.Holders[i].Node+"/"+sh.Holders[i].Module < sh.Holders[j].Node+"/"+sh.Holders[j].Module
|
||||
})
|
||||
out[k] = sh
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// HoldersOf reads who holds seat (on node, for a node seat) from the state's keys as a reader watched them: the node's
|
||||
// key first, then the seat's. None when nobody holds it.
|
||||
func HoldersOf(keys map[string][]byte, seat, node string) ([]SeatHolder, error) {
|
||||
for _, n := range []string{node, ""} {
|
||||
key, err := SeatHoldersKey(seat, n)
|
||||
if err != nil {
|
||||
if n == "" {
|
||||
return nil, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
raw, ok := keys[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var sh SeatHolders
|
||||
if err := json.Unmarshal(raw, &sh); err != nil {
|
||||
return nil, fmt.Errorf("delivery: the holders of %s are not readable: %w", key, err)
|
||||
}
|
||||
if sh.V != Version {
|
||||
return nil, fmt.Errorf("delivery: the holders of %s are of version %d, and this reads %d", key, sh.V, Version)
|
||||
}
|
||||
return sh.Holders, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// The builder's log (clause 1; the lead and the operator, 2026-10-29): the builder's own module log (ADR 0297) `build-log`,
|
||||
// kept 30 days (max-age-days), one entry under the key `<module>` when a build of a fingerprint starts and one when it
|
||||
// ends, so the newest entry of a key is the module's newest start or end. The builder's tools read it through the
|
||||
// runtime's log verbs; another module reads it by naming BuildLogRead in its manifest's `reads`: a bundle through the
|
||||
// runtime (stdio.Log(BuildLogRead)), a process on its own credential (process.Conn.ReadLog), each by newest per key,
|
||||
// from a time, or following new entries. The catalogue module asks the builder's tool BuildsSinceTool instead.
|
||||
const (
|
||||
BuilderLog = "build-log"
|
||||
BuildLogRead = BuilderModule + "." + BuilderLog
|
||||
BuildStartedEvent = "build.started"
|
||||
BuildEndedEvent = "build.ended"
|
||||
// BuilderLogDays is how long the log keeps its entries.
|
||||
BuilderLogDays = 30
|
||||
// BuildsSinceTool is the builder's tool answering every ended build since a time, in order (clause 9): its
|
||||
// argument `since` (RFC 3339), its answer BuildsSince.
|
||||
BuildsSinceTool = "builder_builds_since"
|
||||
)
|
||||
|
||||
// BuildLogKey is the key of a module's entries in the builder's log: the module's name, refused when a log key could not
|
||||
// hold it.
|
||||
func BuildLogKey(module string) (string, error) {
|
||||
if err := checkName("module", module); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return module, nil
|
||||
}
|
||||
|
||||
// BuildLogEntry is one entry of the builder's log: the module, the fingerprint, the source revision, where the module is
|
||||
// built from (its repository, its directory there, the commit and the ref), the build node and the time; at the end,
|
||||
// the outcome, the manifest built (its digest, read from the indexer's state `manifests` under ManifestsKey), what it was
|
||||
// built against (the bases' references, as the controller's built-before said them) and each artifact made
|
||||
// (BuiltArtifact, the done key's: its name, kind, digest and reference). The repository, directory and commit are what
|
||||
// the indexer's PathOf, the outside-built registrations and the assigner's running commits read; the manifest, against
|
||||
// and made what the catalogue module registers a build from (the lead, 2026-10-29).
|
||||
type BuildLogEntry struct {
|
||||
V int `json:"v"`
|
||||
Event string `json:"event"`
|
||||
Module string `json:"module"`
|
||||
FP string `json:"fp"`
|
||||
Source string `json:"source"`
|
||||
Repository string `json:"repository"`
|
||||
Directory string `json:"directory"`
|
||||
Commit string `json:"commit"`
|
||||
Ref string `json:"ref,omitempty"`
|
||||
Node string `json:"node"`
|
||||
At time.Time `json:"at"`
|
||||
Outcome string `json:"outcome,omitempty"`
|
||||
Manifest string `json:"manifest,omitempty"`
|
||||
Against []string `json:"against,omitempty"`
|
||||
Made []BuiltArtifact `json:"made,omitempty"`
|
||||
}
|
||||
|
||||
// BuildsSince is the answer of BuildsSinceTool: every ended build since a time, oldest first, and whether the log held
|
||||
// more than one answer carries (then ask again from the last one's time).
|
||||
type BuildsSince struct {
|
||||
Since time.Time `json:"since"`
|
||||
Builds []BuildLogEntry `json:"builds"`
|
||||
More bool `json:"more,omitempty"`
|
||||
}
|
||||
|
||||
// NewestPerModule is each module's newest entry of entries (one kind, read from the last per subject or whole), by
|
||||
// module: the reader's helper for the five newest and the running commits.
|
||||
func NewestPerModule(entries []BuildLogEntry) map[string]BuildLogEntry {
|
||||
out := map[string]BuildLogEntry{}
|
||||
for _, e := range entries {
|
||||
if have, ok := out[e.Module]; !ok || e.At.After(have.At) {
|
||||
out[e.Module] = e
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// EndedSince is, of entries, the ended builds at or after since, oldest first: the reader's helper for history and for
|
||||
// BuildsSinceTool's answer.
|
||||
func EndedSince(entries []BuildLogEntry, since time.Time) []BuildLogEntry {
|
||||
var out []BuildLogEntry
|
||||
for _, e := range entries {
|
||||
if e.Event == BuildEndedEvent && !e.At.Before(since) {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool { return out[i].At.Before(out[j].At) })
|
||||
return out
|
||||
}
|
||||
|
||||
// EncodeBuildLogEntry is e as JSON at Version; it refuses an entry a reader could not place.
|
||||
func EncodeBuildLogEntry(e BuildLogEntry) ([]byte, error) {
|
||||
e.V = Version
|
||||
switch {
|
||||
case e.Event != BuildStartedEvent && e.Event != BuildEndedEvent:
|
||||
return nil, fmt.Errorf("delivery: a build log entry is %s or %s, not %q", BuildStartedEvent, BuildEndedEvent, e.Event)
|
||||
case e.Module == "" || e.FP == "" || e.Node == "" || e.At.IsZero() || e.Repository == "" || e.Commit == "":
|
||||
return nil, fmt.Errorf("delivery: a build log entry names its module, fingerprint, repository, commit, node and time")
|
||||
case e.Event == BuildEndedEvent && e.Outcome == "":
|
||||
return nil, fmt.Errorf("delivery: a build's end names its outcome")
|
||||
case e.Event == BuildStartedEvent && (e.Outcome != "" || len(e.Made) > 0 || e.Manifest != "" || len(e.Against) > 0):
|
||||
return nil, fmt.Errorf("delivery: a build's start names no outcome, manifest, base or artifact")
|
||||
}
|
||||
return json.Marshal(e)
|
||||
}
|
||||
|
||||
// DecodeBuildLogEntry reads one entry.
|
||||
func DecodeBuildLogEntry(data []byte) (BuildLogEntry, error) {
|
||||
var e BuildLogEntry
|
||||
if err := json.Unmarshal(data, &e); err != nil {
|
||||
return BuildLogEntry{}, fmt.Errorf("delivery: a build log entry is not readable: %w", err)
|
||||
}
|
||||
if e.V != Version {
|
||||
return BuildLogEntry{}, fmt.Errorf("delivery: a build log entry of version %d, and this reads %d", e.V, Version)
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// BuildDuration is a build's duration, its end less its start (clause 1): false unless both are the same module's
|
||||
// build of the same fingerprint, the start first.
|
||||
func BuildDuration(start, end BuildLogEntry) (time.Duration, bool) {
|
||||
if start.Event != BuildStartedEvent || end.Event != BuildEndedEvent || start.Module != end.Module || start.FP != end.FP ||
|
||||
end.At.Before(start.At) {
|
||||
return 0, false
|
||||
}
|
||||
return end.At.Sub(start.At), true
|
||||
}
|
||||
|
||||
// **Which modules exist and which machines the mesh has, from the bus** (novox/hq ADR 0571 clauses 4, 5; the lead for a6,
|
||||
// 2026-10-29): the tool runner reads the modules from the indexer's state `modules` and the machines from the placement
|
||||
// seat's keys, and no longer asks the controller's `modules` or `nodes`.
|
||||
|
||||
// ModuleNames is every module the indexer holds, sorted, from its state `modules` as a reader holds its keys (key to
|
||||
// value; a deleted key absent). A key that is not a module's is refused, naming it.
|
||||
func ModuleNames(keys map[string][]byte) ([]string, error) {
|
||||
out := make([]string, 0, len(keys))
|
||||
for k := range keys {
|
||||
module, err := ParseModulesKey(k)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, module)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// MachineNames is every machine the placement seat places modules on, sorted, from its state `placement` as a reader
|
||||
// holds its keys: each with a node key `<node>.node` or, in the form before, `placement.<node>` (placement_keys.go).
|
||||
// Module keys and keys of the state's other kinds (a proposal's) are passed over.
|
||||
func MachineNames(keys map[string][]byte) []string {
|
||||
seen := map[string]bool{}
|
||||
out := []string{}
|
||||
for k := range keys {
|
||||
if node, _, kind, err := ParsePlacementKey(k); err == nil && kind != PlacementModuleKind && !seen[node] {
|
||||
seen[node] = true
|
||||
out = append(out, node)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
+140
-4
@@ -3,6 +3,7 @@ package delivery
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -84,9 +85,6 @@ func judged(from, to Composed) (Judgement, string) {
|
||||
if ids := changedOf(before, after, func(kind string) bool { return kind == string(declaration.TypeContainer) }); len(ids) > 0 {
|
||||
return JudgedRecreates, "it recreates " + strings.Join(ids, ", ")
|
||||
}
|
||||
if from.Data != to.Data {
|
||||
return JudgedMovesData, "its data declaration changes"
|
||||
}
|
||||
places := dataPlaces(before, after)
|
||||
held := func(kind string) bool {
|
||||
return kind == string(declaration.TypeDirectory) || kind == string(declaration.TypeFile)
|
||||
@@ -104,13 +102,151 @@ func judged(from, to Composed) (Judgement, string) {
|
||||
if to.Data || from.Data {
|
||||
moved = append(moved, changedOf(before, after, func(kind string) bool { return kind == string(declaration.TypeProcess) })...)
|
||||
}
|
||||
// A data flag that differs is no move of data by itself: data moves only when a data item does (issue 1126, 2026-10-29:
|
||||
// a running composition read back from the kept declaration carries no flag, and mongodb's toolchain move asked a yes for
|
||||
// moving its data). It is said loudly in the words, and never hides an item that moved.
|
||||
if len(moved) > 0 {
|
||||
sort.Strings(moved)
|
||||
return JudgedMovesData, "it changes where its data is kept: " + strings.Join(moved, ", ")
|
||||
// A file the mesh makes for the module's provisions (what its consumers contribute, what it is bound to, the
|
||||
// secret it is given) is said as the consumers that join or leave, not as its data moving (issue 774,
|
||||
// 2026-10-31: postgres's yes said "where its data is kept" when nextcloud left its grants). Still a move: the
|
||||
// yes it asks is what keeps a consumer's login while that consumer is held.
|
||||
var kept, made []string
|
||||
for _, id := range moved {
|
||||
if who := consumersOf(id, before[id], after[id]); who != nil {
|
||||
made = append(made, who...)
|
||||
} else {
|
||||
kept = append(kept, id)
|
||||
}
|
||||
}
|
||||
var parts []string
|
||||
if len(kept) > 0 {
|
||||
parts = append(parts, "it changes where its data is kept: "+strings.Join(kept, ", "))
|
||||
}
|
||||
if len(made) > 0 {
|
||||
parts = append(parts, "consumers join or leave: "+strings.Join(made, ", "))
|
||||
}
|
||||
words := strings.Join(parts, "; ")
|
||||
if from.Data != to.Data {
|
||||
words += fmt.Sprintf("; its data flag differs too (running %v, composed %v)", from.Data, to.Data)
|
||||
}
|
||||
return JudgedMovesData, words
|
||||
}
|
||||
if from.Data != to.Data {
|
||||
return JudgedNeither, fmt.Sprintf("it recreates no container and moves no data; WARNING: its data flag differs "+
|
||||
"(running %v, composed %v) while no data item does, so one side's flag is wrong", from.Data, to.Data)
|
||||
}
|
||||
return JudgedNeither, "it recreates no container and moves no data"
|
||||
}
|
||||
|
||||
// ResourceChange is one resource that differs between two compositions of a module: its id, its kind, whether it is
|
||||
// added, removed or changed, and for a file the mesh makes for a provision (received-, bound-, secret-) the consumers
|
||||
// that join or leave, in words.
|
||||
type ResourceChange struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Change string `json:"change"`
|
||||
Consumers []string `json:"consumers,omitempty"`
|
||||
}
|
||||
|
||||
// ChangedResources is every resource added, removed or changed from running to composed, by id, each compared
|
||||
// canonical (issue 774: the engine's verb composition shows what a move waiting for a yes would change). Running nil
|
||||
// is every resource added.
|
||||
func ChangedResources(running *Composed, composed Composed) []ResourceChange {
|
||||
var from Composed
|
||||
if running != nil {
|
||||
from = *running
|
||||
}
|
||||
before, after := judgedResourcesByID(from), judgedResourcesByID(composed)
|
||||
var out []ResourceChange
|
||||
for _, id := range changedOf(before, after, func(string) bool { return true }) {
|
||||
was, now := before[id], after[id]
|
||||
c := ResourceChange{ID: id, Change: "changed"}
|
||||
switch {
|
||||
case was == nil:
|
||||
c.Change, c.Kind = "added", now.kind
|
||||
case now == nil:
|
||||
c.Change, c.Kind = "removed", was.kind
|
||||
default:
|
||||
c.Kind = now.kind
|
||||
}
|
||||
c.Consumers = consumersOf(id, was, now)
|
||||
out = append(out, c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// consumersOf is, for a changed file the mesh makes for a provision (received-, bound- or secret-, catalogue.ReceivedID,
|
||||
// BoundID, SecretID), what changes of it in words: each consumer that joins or leaves a received file, by the given
|
||||
// list's from and node; else that what it is given for the provision changes. Nil for any other resource.
|
||||
func consumersOf(id string, before, after *judgedResource) []string {
|
||||
_, local, _ := strings.Cut(id, ".")
|
||||
r := after
|
||||
if r == nil {
|
||||
r = before
|
||||
}
|
||||
if r == nil || r.kind != string(declaration.TypeFile) {
|
||||
return nil
|
||||
}
|
||||
if provision, ok := strings.CutPrefix(local, "received-"); ok {
|
||||
was, now := givenIn(before), givenIn(after)
|
||||
var out []string
|
||||
for _, c := range sortedGiven(was) {
|
||||
if !now[c] {
|
||||
out = append(out, c+" leaves "+provision)
|
||||
}
|
||||
}
|
||||
for _, c := range sortedGiven(now) {
|
||||
if !was[c] {
|
||||
out = append(out, c+" joins "+provision)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
out = append(out, "what "+provision+"'s consumers are given changes")
|
||||
}
|
||||
return out
|
||||
}
|
||||
for _, prefix := range []string{"bound-", "secret-"} {
|
||||
if provision, ok := strings.CutPrefix(local, prefix); ok {
|
||||
return []string{"what it is given for " + provision + " changes"}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// givenIn is the consumers a received file's content names, each as "<from> on <node>".
|
||||
func givenIn(r *judgedResource) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
if r == nil {
|
||||
return out
|
||||
}
|
||||
var file struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
var received struct {
|
||||
Given []struct {
|
||||
From string `json:"from"`
|
||||
Node string `json:"node"`
|
||||
} `json:"given"`
|
||||
}
|
||||
if json.Unmarshal(r.raw, &file) != nil || json.Unmarshal([]byte(file.Content), &received) != nil {
|
||||
return out
|
||||
}
|
||||
for _, g := range received.Given {
|
||||
out[g.From+" on "+g.Node] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sortedGiven(m map[string]bool) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// judgedResource is a resource as the judgement reads it: its kind, the path it acts on, what its containers mount,
|
||||
// and its canonical bytes.
|
||||
type judgedResource struct {
|
||||
|
||||
+38
-12
@@ -2,6 +2,7 @@ package delivery
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -28,6 +29,8 @@ func TestJudgeMove(t *testing.T) {
|
||||
config2 = `{"id":"mesh-mail.conf","type":"file","module":"mesh-mail","path":"/var/lib/mesh-mail/spool/conf","content":"b"}`
|
||||
process = `{"id":"mesh-mail.daemon","type":"process","module":"mesh-mail","exec":"/usr/bin/a"}`
|
||||
process2 = `{"id":"mesh-mail.daemon","type":"process","module":"mesh-mail","exec":"/usr/bin/b"}`
|
||||
received = `{"id":"mesh-mail.received-smtp","type":"file","module":"mesh-mail","path":"/var/lib/mesh-mail/spool/received.json","content":"{\"given\":[{\"from\":\"cloud\",\"node\":\"novox\"},{\"from\":\"photos\",\"node\":\"novox\"}]}"}`
|
||||
received2 = `{"id":"mesh-mail.received-smtp","type":"file","module":"mesh-mail","path":"/var/lib/mesh-mail/spool/received.json","content":"{\"given\":[{\"from\":\"photos\",\"node\":\"novox\"},{\"from\":\"wiki\",\"node\":\"ace\"}]}"}`
|
||||
users = `{"id":"nats.bus-users","type":"file","module":"nats","path":"/etc/nats/users","content":"a"}`
|
||||
users2 = `{"id":"nats.bus-users","type":"file","module":"nats","path":"/etc/nats/users","content":"b"}`
|
||||
natsConf = `{"id":"nats.conf","type":"file","module":"nats","path":"/etc/nats/conf","content":"a"}`
|
||||
@@ -43,24 +46,47 @@ func TestJudgeMove(t *testing.T) {
|
||||
from, to *Composed
|
||||
want Judgement
|
||||
userList bool
|
||||
words string // what the words must say, when given
|
||||
}{
|
||||
{"a new image", mail('a', true, container, spool, tools), mail('b', true, container2, spool, tools), JudgedRecreates, false},
|
||||
{"a container gone", mail('a', true, container, spool), mail('b', true, spool), JudgedRecreates, false},
|
||||
{"its spool moved", mail('a', true, container, spool), mail('b', true, container, spoolMoved), JudgedMovesData, false},
|
||||
{"a file in its spool", mail('a', true, container, spool, config), mail('b', true, container, spool, config2), JudgedMovesData, false},
|
||||
{"its data declaration", mail('a', false, container, spool), mail('b', true, container, spool), JudgedMovesData, false},
|
||||
{"a process of a module with data", mail('a', true, spool, process), mail('b', true, spool, process2), JudgedMovesData, false},
|
||||
{"a process of a module without", mail('a', false, process), mail('b', false, process2), JudgedNeither, false},
|
||||
{"its tools only", mail('a', true, container, spool, tools), mail('b', true, container, spool, tools2), JudgedNeither, false},
|
||||
{"never ran", nil, mail('b', true, container), JudgedNeither, false},
|
||||
{"equal, written otherwise", mail('a', true, `{"type":"directory","id":"mesh-mail.spool","module":"mesh-mail","path":"/var/lib/mesh-mail/spool"}`), mail('b', true, spool), JudgedUnchanged, false},
|
||||
{"only the user list", bus(natsConf, users), bus(natsConf, users2), JudgedNeither, true},
|
||||
{"the user list and more", bus(natsConf, users), bus(natsConf2, users2), JudgedNeither, false},
|
||||
{"a new image", mail('a', true, container, spool, tools), mail('b', true, container2, spool, tools), JudgedRecreates, false, ""},
|
||||
{"a container gone", mail('a', true, container, spool), mail('b', true, spool), JudgedRecreates, false, ""},
|
||||
{"its spool moved", mail('a', true, container, spool), mail('b', true, container, spoolMoved), JudgedMovesData, false, ""},
|
||||
{"a file in its spool", mail('a', true, container, spool, config), mail('b', true, container, spool, config2), JudgedMovesData, false, "it changes where its data is kept: mesh-mail.conf"},
|
||||
// A received file the mesh makes, in its data's place, names its consumers joining or leaving, not its data moving
|
||||
// (issue 774, 2026-10-31: postgres asked a yes for "where its data is kept" when nextcloud left its grants).
|
||||
{"a consumer leaves", mail('a', true, container, spool, received), mail('b', true, container, spool, received2), JudgedMovesData, false,
|
||||
"consumers join or leave: cloud on novox leaves smtp, wiki on ace joins smtp"},
|
||||
{"a consumer leaves and a file moves", mail('a', true, container, spool, received, config), mail('b', true, container, spool, received2, config2), JudgedMovesData, false,
|
||||
"it changes where its data is kept: mesh-mail.conf; consumers join or leave: cloud on novox leaves smtp, wiki on ace joins smtp"},
|
||||
{"its data flag alone", mail('a', false, container, spool), mail('b', true, container, spool), JudgedNeither, false, ""},
|
||||
{"its data flag and its spool", mail('a', false, container, spool), mail('b', true, container, spoolMoved), JudgedMovesData, false, ""},
|
||||
{"a process of a module with data", mail('a', true, spool, process), mail('b', true, spool, process2), JudgedMovesData, false, ""},
|
||||
{"a process of a module without", mail('a', false, process), mail('b', false, process2), JudgedNeither, false, ""},
|
||||
{"its tools only", mail('a', true, container, spool, tools), mail('b', true, container, spool, tools2), JudgedNeither, false, ""},
|
||||
{"never ran", nil, mail('b', true, container), JudgedNeither, false, ""},
|
||||
{"equal, written otherwise", mail('a', true, `{"type":"directory","id":"mesh-mail.spool","module":"mesh-mail","path":"/var/lib/mesh-mail/spool"}`), mail('b', true, spool), JudgedUnchanged, false, ""},
|
||||
{"only the user list", bus(natsConf, users), bus(natsConf, users2), JudgedNeither, true, ""},
|
||||
{"the user list and more", bus(natsConf, users), bus(natsConf2, users2), JudgedNeither, false, ""},
|
||||
} {
|
||||
got := JudgeMove(c.from, *c.to)
|
||||
if got.Judgement != c.want || got.UserListOnly != c.userList || got.Words == "" {
|
||||
t.Errorf("%s: %+v, want %s (user list only %v)", c.name, got, c.want, c.userList)
|
||||
}
|
||||
if c.words != "" && got.Words != c.words {
|
||||
t.Errorf("%s: says %q, want %q", c.name, got.Words, c.words)
|
||||
}
|
||||
if strings.Contains(c.name, "data flag alone") && !strings.Contains(got.Words, "WARNING: its data flag differs") {
|
||||
t.Errorf("%s: the flag's difference is not said: %q", c.name, got.Words)
|
||||
}
|
||||
}
|
||||
// The resources that differ, as the engine's verb composition lists them (issue 774): a provider's received file
|
||||
// names its consumers, a file changed and one added are said as such, and nothing equal is listed.
|
||||
changes := ChangedResources(mail('a', true, container, spool, received, config), *mail('b', true, container, spool, received2, config2, tools))
|
||||
if want := `[{mesh-mail.conf file changed []} {mesh-mail.received-smtp file changed [cloud on novox leaves smtp wiki on ace joins smtp]} {mesh-mail.tools file added []}]`; fmt.Sprint(changes) != want {
|
||||
t.Errorf("changed resources: %v, want %s", changes, want)
|
||||
}
|
||||
if got := ChangedResources(nil, *mail('b', true, spool)); len(got) != 1 || got[0].Change != "added" {
|
||||
t.Errorf("never ran: %v, want its one resource added", got)
|
||||
}
|
||||
if got := JudgeMove(nil, Composed{Node: "ace"}); got.Judgement != JudgedNeither {
|
||||
t.Errorf("the node's own composition: %+v", got)
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package delivery
|
||||
|
||||
import "time"
|
||||
|
||||
// **Whether a module is being delivered on a node, read from its want and its have** (novox/hq ADR 0564 clause 13 and
|
||||
// Limits 12, ADR 0540 §7; issue 1952 S7): one derivation, used by the delivery view, which says a module `moving`,
|
||||
// `settled` or `stuck` (the messenger's hold hears those), and by the condition keeper, which holds a module's own
|
||||
// findings while it is delivered there and raises what still stands when the delivery ends. Before, it lived in the view
|
||||
// alone (mesh-delivery-view internal/view motionOf), and a second reader would have judged a delivery its own way.
|
||||
|
||||
// The motion of a module on a node, as the view says it.
|
||||
const (
|
||||
MotionMoving = "moving"
|
||||
MotionSettled = "settled"
|
||||
MotionStuck = "stuck"
|
||||
)
|
||||
|
||||
// The outcomes of a motion that settled or stuck.
|
||||
const (
|
||||
// OutcomeRunning is a settled module: the have reached the want, running and healthy.
|
||||
OutcomeRunning = "running"
|
||||
// OutcomeFailed is a have at the wanted fingerprint whose apply or health check failed.
|
||||
OutcomeFailed = "failed"
|
||||
// OutcomeStopped is a module held, or a want the watchman says stopped moving past its bound.
|
||||
OutcomeStopped = "stopped"
|
||||
)
|
||||
|
||||
// DefaultDeliveryBound is how long a module's delivery holds its own findings when its manifest names no bound (ADR 0564
|
||||
// clause 13 (d)): past it, what is held is raised, saying the delivery did not end.
|
||||
const DefaultDeliveryBound = 15 * time.Minute
|
||||
|
||||
// Motion is where one module's want stands on one node.
|
||||
type Motion struct {
|
||||
// State is MotionMoving, MotionSettled or MotionStuck; empty when there is no want to move to (none, removed, or
|
||||
// naming no fingerprint).
|
||||
State string
|
||||
// Outcome and Words are a settled or stuck motion's: running; failed with the have's words; stopped with the have's
|
||||
// words (held) or the watchman's (stopped moving).
|
||||
Outcome string
|
||||
Words string
|
||||
// Delivering says the module is being delivered there (clause 13 (a)): its want names a fingerprint other than its
|
||||
// have's, or its have's phase is applying.
|
||||
Delivering bool
|
||||
// Failed says the delivery ended failed (Limits 12): the node refused or failed its apply (the have's phase failed,
|
||||
// at whichever fingerprint it kept), or the have at the wanted fingerprint is unhealthy. What a hold kept is raised
|
||||
// at once. Apart from State, which says failed only at the wanted fingerprint, as the view always has.
|
||||
Failed bool
|
||||
}
|
||||
|
||||
// Ended says the delivery ended, failed or settled healthy at the want: what a hold kept is judged now.
|
||||
func (m Motion) Ended() bool { return m.Failed || (m.State == MotionSettled && !m.Delivering) }
|
||||
|
||||
// MotionOf is the motion of a module on a node from its want (wanted: one was read), its have (has: one was read) and
|
||||
// the watchman's words when it says the want stopped moving (stuck: it says so and the want has not moved since).
|
||||
func MotionOf(w Want, wanted bool, h Have, has bool, stuckWords string, stuck bool) Motion {
|
||||
if !wanted || w.Removed || w.FP == "" {
|
||||
return Motion{}
|
||||
}
|
||||
m := Motion{Delivering: !has || h.FP != w.FP || h.Phase == PhaseApplying,
|
||||
Failed: has && (h.Phase == PhaseFailed || (h.FP == w.FP && h.Health == HealthFailed))}
|
||||
applied := has && h.FP == w.FP && (h.Composed == "" || h.Composed == w.Composed)
|
||||
switch {
|
||||
case applied && h.Phase == PhaseRunning && h.Health == HealthOK:
|
||||
m.State, m.Outcome = MotionSettled, OutcomeRunning
|
||||
case has && h.Phase == PhaseHeld:
|
||||
m.State, m.Outcome, m.Words = MotionStuck, OutcomeStopped, h.Detail
|
||||
case has && h.FP == w.FP && (h.Health == HealthFailed || h.Phase == PhaseFailed):
|
||||
m.State, m.Outcome, m.Words = MotionStuck, OutcomeFailed, h.Detail
|
||||
case stuck:
|
||||
m.State, m.Outcome, m.Words = MotionStuck, OutcomeStopped, stuckWords
|
||||
default:
|
||||
m.State = MotionMoving
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package delivery
|
||||
|
||||
import "testing"
|
||||
|
||||
// 1952 S7 (ADR 0564 clause 13 (a), (c) and Limits 12; ADR 0540 §7): one derivation of whether a module is being
|
||||
// delivered on a node, read by the view (moving, settled, stuck, as its motionOf said them) and by the condition keeper
|
||||
// (delivering, failed, ended). Wants and haves as the assigner and the node-engine write them (assigner.go Want,
|
||||
// delivery.go Have; agent.go writes a refusal as phase failed at the prior fingerprint).
|
||||
func TestMotionOf(t *testing.T) {
|
||||
want := Want{V: Version, FP: "fp-new", Composed: "sha256:new"}
|
||||
at := func(fp, composed string, phase Phase, health Health, detail string) Have {
|
||||
return Have{V: Version, FP: fp, Composed: composed, Phase: phase, Health: health, Detail: detail}
|
||||
}
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
w Want
|
||||
wanted bool
|
||||
h Have
|
||||
has bool
|
||||
stuck string
|
||||
state string
|
||||
outcome string
|
||||
delivering bool
|
||||
failed bool
|
||||
ended bool
|
||||
}{
|
||||
{name: "no want", h: at("fp-old", "", PhaseRunning, HealthOK, ""), has: true},
|
||||
{name: "a removed want", w: Want{V: Version, Removed: true}, wanted: true},
|
||||
{name: "a want no node has yet", w: want, wanted: true, state: MotionMoving, delivering: true},
|
||||
{name: "the old fingerprint still runs", w: want, wanted: true, h: at("fp-old", "sha256:old", PhaseRunning, HealthOK, ""), has: true,
|
||||
state: MotionMoving, delivering: true},
|
||||
{name: "applying the wanted fingerprint", w: want, wanted: true, h: at("fp-new", "sha256:new", PhaseApplying, HealthUnknown, ""), has: true,
|
||||
state: MotionMoving, delivering: true},
|
||||
{name: "checking at the want: no longer delivering, not yet settled", w: want, wanted: true,
|
||||
h: at("fp-new", "sha256:new", PhaseChecking, HealthUnknown, ""), has: true, state: MotionMoving},
|
||||
{name: "running healthy at the want", w: want, wanted: true, h: at("fp-new", "sha256:new", PhaseRunning, HealthOK, ""), has: true,
|
||||
state: MotionSettled, outcome: OutcomeRunning, ended: true},
|
||||
{name: "running healthy at another composition", w: want, wanted: true, h: at("fp-new", "sha256:other", PhaseRunning, HealthOK, ""), has: true,
|
||||
state: MotionMoving},
|
||||
{name: "unhealthy at the want", w: want, wanted: true, h: at("fp-new", "sha256:new", PhaseRunning, HealthFailed, "exit 1"), has: true,
|
||||
state: MotionStuck, outcome: OutcomeFailed, failed: true, ended: true},
|
||||
{name: "its apply failed at the want", w: want, wanted: true, h: at("fp-new", "sha256:new", PhaseFailed, HealthUnknown, "no image"), has: true,
|
||||
state: MotionStuck, outcome: OutcomeFailed, failed: true, ended: true},
|
||||
{name: "refused, the prior fingerprint kept", w: want, wanted: true,
|
||||
h: at("fp-old", "sha256:old", PhaseFailed, HealthOK, "not composed: no grant"), has: true,
|
||||
state: MotionMoving, delivering: true, failed: true, ended: true},
|
||||
{name: "held on the node", w: want, wanted: true, h: at("fp-old", "sha256:old", PhaseHeld, HealthOK, "held by a person"), has: true,
|
||||
state: MotionStuck, outcome: OutcomeStopped, delivering: true},
|
||||
{name: "the watchman says it stopped moving", w: want, wanted: true, h: at("fp-old", "sha256:old", PhaseRunning, HealthOK, ""), has: true,
|
||||
stuck: "ace to apply fp-new", state: MotionStuck, outcome: OutcomeStopped, delivering: true},
|
||||
} {
|
||||
m := MotionOf(c.w, c.wanted, c.h, c.has, c.stuck, c.stuck != "")
|
||||
if m.State != c.state || m.Outcome != c.outcome || m.Delivering != c.delivering || m.Failed != c.failed || m.Ended() != c.ended {
|
||||
t.Errorf("%s: %+v (ended %v), want state %q outcome %q delivering %v failed %v ended %v", c.name, m, m.Ended(),
|
||||
c.state, c.outcome, c.delivering, c.failed, c.ended)
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user