From 21dd8881ed0adf7a934494fa2a4816709857195e Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 16:17:41 +0100 Subject: [PATCH 01/22] buildstep: a check ask runs every level and every package after a failure, so one check names every failure The operator, 2026-11-02: a failed check showed only its first fault; a group's three faults took three runs. The change's own packages still run first (issue 1289), and nothing after them is skipped. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/buildstep/askrun.go | 22 ++++++----------- go/buildstep/askrun_test.go | 49 +++++++++++++++++-------------------- go/delivery/checker.go | 11 +++++---- go/delivery/stepasks.go | 5 ++-- 4 files changed, 39 insertions(+), 48 deletions(-) diff --git a/go/buildstep/askrun.go b/go/buildstep/askrun.go index 06156e1..2a0836b 100644 --- a/go/buildstep/askrun.go +++ b/go/buildstep/askrun.go @@ -213,16 +213,15 @@ func (s AskSteps) check(ctx context.Context, job delivery.CheckJob) (any, error) if job.Runner != "" && (job.RunnerWhen == "" || changesUnder(f.Changed, job.RunnerWhen)) { runner = job.Runner } + // Every level runs, also after one that did not pass: one check names every failure, not only the first (the operator, + // 2026-11-02: "A failed check shows only its first fault"). for _, level := range job.Levels { la := delivery.LevelAnswer{Level: level} - passed, err := s.level(ctx, job, level, f, runner, &la) + _, err := s.level(ctx, job, level, f, runner, &la) answer.Levels = append(answer.Levels, la) if err != nil { return nil, err } - if !passed { - return answer, nil // the first level that did not pass ends the check (ADR 0484 clause 13) - } } return answer, nil } @@ -261,10 +260,9 @@ func (s AskSteps) level(ctx context.Context, job delivery.CheckJob, level string _ = json.Unmarshal(r, &t) return t.Passed, nil } - // The packages (or Node directories) whose tests the change touches run first, and a failure among them ends the level - // there, so a change's own failing test is answered without the rest of the suite (issue 1289; ADR 0444). Past them every - // test step of the level runs, also after one that did not pass, so one check names every failing test of the rest of - // the level and not only the first (issue 1921); the level passes only when each of its steps did. + // The packages (or Node directories) whose tests the change touches run first (issue 1289; ADR 0444), and then every + // other test step of the level, also after one that did not pass, so one check names every failing test of the level + // and not only the first (issue 1921; the operator, 2026-11-02); the level passes only when each of its steps did. all := true if s.Digest == nil { // A module with no digest (Node) tests each module directory of the tree, those whose tests changed first. @@ -279,9 +277,6 @@ func (s AskSteps) level(ctx context.Context, job delivery.CheckJob, level string } all = all && passed } - if !all { - return false, nil // the change's own tests failed: the others are not run (issue 1289) - } } return all, nil } @@ -317,10 +312,7 @@ func (s AskSteps) level(ctx context.Context, job delivery.CheckJob, level string if err != nil { return false, fmt.Errorf("the %s level: %w", level, err) } - all = all && passed // past the change's own packages the others still run (issue 1921) - if priority && !all { - return false, nil // the change's own tests failed: the others are not run (issue 1289) - } + all = all && passed // the others still run after a failure (issue 1921) } return all, nil } diff --git a/go/buildstep/askrun_test.go b/go/buildstep/askrun_test.go index 30d4072..5616b18 100644 --- a/go/buildstep/askrun_test.go +++ b/go/buildstep/askrun_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "slices" "strings" "testing" "time" @@ -125,8 +126,8 @@ func TestRunAskRefusesAnotherToolchain(t *testing.T) { } // 1887/A3, A5a: a check ask runs the levels of one check after one fetch: it digests the unit level, takes the kept -// verdicts, tests the packages whose tests changed first and, when one of them fails, no other (issue 1289), and runs no -// level after one that did not pass (ADR 0484 clause 13). +// verdicts, tests the packages whose tests changed first and then the others, also after one of them failed, and runs +// every level, also after one that did not pass, so one check names every failure (the operator, 2026-11-02). func TestRunAskChecksTheLevelsOfOneCheck(t *testing.T) { r := &recorder{ fetch: map[string]any{"workspace": "/w", "changed": []string{"pkg/b/b_test.go"}}, @@ -141,16 +142,20 @@ func TestRunAskChecksTheLevelsOfOneCheck(t *testing.T) { got := strings.Join(r.calls, "\n") want := `fetch {"base":"` + runCommit + `","commit":"` + runCommit + `","repository":"novox/x"} digest {"level":"unit","workspace":"/w"} -test {"level":"unit","packages":["x/pkg/b"],"workspace":"/w"}` +test {"level":"unit","packages":["x/pkg/b"],"workspace":"/w"} +test {"level":"unit","packages":["x/pkg/a"],"workspace":"/w"} +digest {"level":"integration","workspace":"/w"} +test {"level":"integration","packages":["x/pkg/b"],"workspace":"/w"} +test {"level":"integration","packages":["x/pkg/a"],"workspace":"/w"}` if got != want { t.Fatalf("calls:\n%s\nwant:\n%s", got, want) } a := out.(delivery.CheckAnswer) - if len(a.Levels) != 1 { - t.Fatalf("levels %+v, want the unit level alone", a.Levels) + if len(a.Levels) != 2 { + t.Fatalf("levels %+v, want both levels", a.Levels) } u := a.Levels[0] - if u.Level != delivery.LevelUnit || len(u.Kept) != 1 || u.Kept[0].Package != "x/pkg/k" || u.Kept[0].Tests != 7 || len(u.Tests) != 1 || u.Digest == nil { + if u.Level != delivery.LevelUnit || len(u.Kept) != 1 || u.Kept[0].Package != "x/pkg/k" || u.Kept[0].Tests != 7 || len(u.Tests) != 2 || u.Digest == nil { t.Fatalf("the unit level %+v", u) } @@ -265,17 +270,17 @@ func TestRunAskChecksNodeModulesAndConflicts(t *testing.T) { } } -// Issue 1289: a failure in the packages whose tests changed ends the level there, the others are not run, and no later level -// runs. Issue 1921: past the change's own packages every package still runs, so two failing packages none of which the -// change touched are two test results of the one level. A Node check does the same with its module directories. -func TestRunAskStopsAtTheChangesOwnFailureAndRunsTheRestAfterOthers(t *testing.T) { +// Issue 1289: the packages whose tests changed run first. Since 2026-11-02 (the operator: "A failed check shows only its +// first fault") a failure among them ends nothing: every package of the level and every level still runs, so one check +// names every failing test (issue 1921). A Node check does the same with its module directories. +func TestRunAskRunsEveryPackageAndLevelAfterAFailure(t *testing.T) { for _, row := range []struct { name string changed []string tests map[string]bool - want []string // the packages tested, in order + want []string // the packages tested at each level, in order }{ - {"the change's own test fails: nothing else runs", []string{"pkg/b/b_test.go"}, map[string]bool{"x/pkg/a": false, "x/pkg/b": false}, []string{"x/pkg/b"}}, + {"the change's own test fails: the rest still runs", []string{"pkg/b/b_test.go"}, map[string]bool{"x/pkg/a": false, "x/pkg/b": false}, []string{"x/pkg/b", "x/pkg/a"}}, {"the change's own test passes: the rest runs, also after a failure", []string{"pkg/b/b_test.go"}, map[string]bool{"x/pkg/a": false, "x/pkg/b": true, "x/pkg/c": false}, []string{"x/pkg/b", "x/pkg/a x/pkg/c"}}, {"no test changed: every failure is named", nil, map[string]bool{"x/pkg/a": false, "x/pkg/b": false}, []string{"x/pkg/a x/pkg/b"}}, } { @@ -297,19 +302,11 @@ func TestRunAskStopsAtTheChangesOwnFailureAndRunsTheRestAfterOthers(t *testing.T got = append(got, strings.Join(a.Packages, " ")) } } - // the passing row goes on to the integration level, which tests the same packages again; the first level is what is judged - if n := len(row.want); len(got) < n || fmt.Sprint(got[:n]) != fmt.Sprint(row.want) { - t.Errorf("%s: tested %v, want %v first", row.name, got, row.want) + if want := append(slices.Clone(row.want), row.want...); fmt.Sprint(got) != fmt.Sprint(want) { + t.Errorf("%s: tested %v, want %v", row.name, got, want) } - a := out.(delivery.CheckAnswer) - failed := false - for _, raw := range a.Levels[0].Tests { - var res struct{ Passed bool } - _ = json.Unmarshal(raw, &res) - failed = failed || !res.Passed - } - if failed && (len(a.Levels) != 1 || len(got) != len(row.want)) { - t.Errorf("%s: a failed level went on: levels %d, tests %v", row.name, len(a.Levels), got) + if a := out.(delivery.CheckAnswer); len(a.Levels) != 2 || len(a.Levels[0].Tests) != len(row.want) { + t.Errorf("%s: levels %+v, want both, each with every test result", row.name, a.Levels) } } @@ -322,7 +319,7 @@ func TestRunAskStopsAtTheChangesOwnFailureAndRunsTheRestAfterOthers(t *testing.T if err != nil { t.Fatal(err) } - if a := out.(delivery.CheckAnswer); len(r.calls) != 2 || len(a.Levels) != 1 || len(a.Levels[0].Tests) != 1 { - t.Fatalf("calls %v, levels %+v: want the changed directory b alone and no integration level", r.calls, a.Levels) + if a := out.(delivery.CheckAnswer); len(r.calls) != 5 || len(a.Levels) != 2 || len(a.Levels[0].Tests) != 2 { + t.Fatalf("calls %v, levels %+v: want b then a at each of the two levels", r.calls, a.Levels) } } diff --git a/go/delivery/checker.go b/go/delivery/checker.go index 6b07ede..b5f5bad 100644 --- a/go/delivery/checker.go +++ b/go/delivery/checker.go @@ -143,7 +143,7 @@ type FailedFirst struct { Words string `json:"words,omitempty"` } -// MaxFailures is the most entries a verdict's Failures holds (issue 1921): fifty tests named one by one, and an entry +// MaxFailures is the most entries a verdict's Failures holds (issue 1921): fifty failures named one by one, and an entry // after them saying how many more failed. const MaxFailures = 50 @@ -157,10 +157,11 @@ type Verdict struct { Trunk map[string]string `json:"trunk,omitempty"` State string `json:"state"` Failed FailedFirst `json:"failed,omitzero"` - // Failures names every test that failed, in the order judged, each with the first line of its words (issue 1921): - // Failed keeps the first one's words whole, and a check that ran every test says all it found here. At most - // MaxFailures entries, the last of a longer list saying how many more failed. Absent in a value written before it, - // and from a verdict whose failure is no test. + // Failures names everything that failed, in the order judged, each with the first line of its words (issue 1921): every + // failed test, and every member's refused step, conflict, manifest refusal and the composition's failure, since a check + // runs every member, level and the composition to its end (the operator, 2026-11-02). Failed keeps the first one's + // words whole. At most MaxFailures entries, the last of a longer list saying how many more failed. Absent in a value + // written before it, and from a verdict with one failure that is no test. Failures []FailedFirst `json:"failures,omitempty"` Levels map[string]LevelResult `json:"levels,omitempty"` Took Took `json:"took,omitzero"` diff --git a/go/delivery/stepasks.go b/go/delivery/stepasks.go index 155a685..f1d932d 100644 --- a/go/delivery/stepasks.go +++ b/go/delivery/stepasks.go @@ -344,7 +344,8 @@ type AskArtifact struct { // CheckJob is a check ask's job, the levels of one check (ADR 0525 §3): fetch the head merged onto its trunk once, then // for each level in order digest its packages, take the kept verdicts for the digests that have one, and test the rest, -// the packages the change's own tests are in first, stopping at the first level that does not pass (CheckAnswer). +// the packages the change's own tests are in first, every level and package running to its end also after a failure, +// so one check names every failure (CheckAnswer). type CheckJob struct { // Fetch is the fetch tool's arguments (repository, commit, ref, base, and the build module's own: beside, package). Fetch map[string]any `json:"fetch"` @@ -564,7 +565,7 @@ type AskBuilt struct { } // CheckAnswer is a check ask's step result, each part the build module's own tool's answer whole: the fetch's, then one -// LevelAnswer per level run, in order; a level that did not pass is the last. The checker judges and keeps from it as it +// LevelAnswer per level run, in order, every asked level also after one that did not pass. The checker judges and keeps from it as it // did from the tools' answers. type CheckAnswer struct { Fetch json.RawMessage `json:"fetch"` -- 2.54.0 From 3d87ddcc9a54460f6299cab841f75601c5cee7ca Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 18:15:35 +0100 Subject: [PATCH 02/22] =?UTF-8?q?delivery:=20the=20indexer's=20packages=20?= =?UTF-8?q?state=20and=20the=20imports=20a=20build=20reports,=20so=20only?= =?UTF-8?q?=20the=20indexer=20computes=20a=20fingerprint=20over=20what=20a?= =?UTF-8?q?=20module=20imports=20(ADR=200593=20=C2=A72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/delivery/history_records.go | 11 ++- go/delivery/published_packages.go | 126 +++++++++++++++++++++++++ go/delivery/published_packages_test.go | 87 +++++++++++++++++ go/delivery/source.go | 8 ++ go/delivery/stepasks.go | 4 + 5 files changed, 234 insertions(+), 2 deletions(-) create mode 100644 go/delivery/published_packages.go create mode 100644 go/delivery/published_packages_test.go diff --git a/go/delivery/history_records.go b/go/delivery/history_records.go index 2649a74..1ce6146 100644 --- a/go/delivery/history_records.go +++ b/go/delivery/history_records.go @@ -171,6 +171,10 @@ type BuildLogEntry struct { Manifest string `json:"manifest,omitempty"` Against []string `json:"against,omitempty"` Made []BuiltArtifact `json:"made,omitempty"` + // Imports are, at a build's end, the mesh packages (import paths) the built programs import, their tests left out, + // sorted and each once, as the build module answered them (BuildAnswer.Imports; novox/hq ADR 0593 §2): the indexer + // keeps them on the module (KeptInputs2.Imports) when the build's fingerprint is the module's. + Imports []string `json:"imports,omitempty"` } // BuildsSince is the answer of BuildsSinceTool: every ended build since a time, oldest first, and whether the log held @@ -216,8 +220,11 @@ func EncodeBuildLogEntry(e BuildLogEntry) ([]byte, error) { 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") + case e.Event == BuildStartedEvent && (e.Outcome != "" || len(e.Made) > 0 || e.Manifest != "" || len(e.Against) > 0 || len(e.Imports) > 0): + return nil, fmt.Errorf("delivery: a build's start names no outcome, manifest, base, artifact or import") + } + if err := CheckImports(e.Imports); err != nil { + return nil, err } return json.Marshal(e) } diff --git a/go/delivery/published_packages.go b/go/delivery/published_packages.go new file mode 100644 index 0000000..a631821 --- /dev/null +++ b/go/delivery/published_packages.go @@ -0,0 +1,126 @@ +package delivery + +import ( + "encoding/json" + "fmt" + "slices" + "strings" + "time" +) + +// The published mesh packages (novox/hq ADR 0593 §2): the indexer alone reads each mesh Go module's package digests +// from the forge's listing at its merge and writes, in its own state, the latest published version of the module and +// the content digest of each package in it. The builder, the checker and the build modules read this state; no other +// part computes a package's version or digest. This is the contract only: the key, the value and its checks. How a +// digest is computed is the indexer's own. + +const ( + // PackagesState is the name of the indexer's state of published packages, as its manifest declares it, and + // PackagesBucket its bucket: one key per mesh Go module (PackagesKey). + PackagesState = "packages" + PackagesBucket = IndexerModule + "_" + PackagesState + // PackagesHistory is the revisions the packages state keeps per key: the module's newest publications. + PackagesHistory = 5 +) + +// PackagesKey is the key, in PackagesBucket, of a mesh Go module (its module path, such as +// git.novox.be/novox/mesh-sdk/go): the path as a key part (EscapeName). It refuses a path that is no mesh Go module's +// (PackageRepository). +func PackagesKey(module string) (string, error) { + if _, _, ok := PackageRepository(module); !ok { + return "", fmt.Errorf("delivery: %q is no mesh Go module's path (it starts with %s)", module, ForgeModulePrefix) + } + return EscapeName(module), nil +} + +// PublishedPackages is the value of a packages key: a mesh Go module as its latest trunk merge published it. Module is +// its module path, Repository (`/`) and Dir its repository and its directory there (RootPath for the +// root), Commit the merge commit (the version a build pins, StepAsk.Packages), Requires the digest of its go.mod and +// go.sum together, and Packages the content digest of each package of the module (its files, tests and sub-packages +// excluded), by import path, sorted and each once. Packages is a list, never a map keyed by import path: a state value +// refuses a field name ending in a credential word, and a package such as .../internal/token is one (issue 1930). +type PublishedPackages struct { + V int `json:"v"` + Module string `json:"module"` + Repository string `json:"repository"` + Dir string `json:"dir"` + Commit string `json:"commit"` + Requires string `json:"requires"` + Packages []PackageDigest `json:"packages"` + At time.Time `json:"at"` +} + +// DigestOf is the content digest of the package at import path pkg, and whether p holds it. +func (p PublishedPackages) DigestOf(pkg string) (string, bool) { + i, found := slices.BinarySearchFunc(p.Packages, pkg, func(d PackageDigest, s string) int { return strings.Compare(d.Package, s) }) + if !found { + return "", false + } + return p.Packages[i].Digest, true +} + +func (p PublishedPackages) check() error { + repository, _, ok := PackageRepository(p.Module) + switch { + case !ok: + return fmt.Errorf("delivery: %q is no mesh Go module's path (it starts with %s)", p.Module, ForgeModulePrefix) + case !SameRepository(p.Repository, repository): + return fmt.Errorf("delivery: the module %s is published from %s, and the value names the repository %q", p.Module, repository, p.Repository) + case p.Dir == "": + return fmt.Errorf("delivery: the published module %s names no directory (%q for the repository's root)", p.Module, RootPath) + case !askCommitOK.MatchString(p.Commit): + return fmt.Errorf("delivery: the published module %s names the commit %q, which is no full commit", p.Module, p.Commit) + case p.Requires == "": + return fmt.Errorf("delivery: the published module %s names no digest of its requirements", p.Module) + case p.At.IsZero(): + return fmt.Errorf("delivery: the published module %s names no time", p.Module) + } + for i, d := range p.Packages { + if d.Package != p.Module && !strings.HasPrefix(d.Package, p.Module+"/") { + return fmt.Errorf("delivery: the package %q is not in the module %s", d.Package, p.Module) + } + if d.Digest == "" { + return fmt.Errorf("delivery: the package %s names no digest", d.Package) + } + if i > 0 && p.Packages[i-1].Package >= d.Package { + return fmt.Errorf("delivery: the packages of %s are not sorted and each once (%s after %s)", p.Module, d.Package, p.Packages[i-1].Package) + } + } + return nil +} + +// EncodePublishedPackages is p as JSON at Version; it refuses what a reader could not take (check). +func EncodePublishedPackages(p PublishedPackages) ([]byte, error) { + if err := p.check(); err != nil { + return nil, err + } + p.V = Version + return json.Marshal(p) +} + +// DecodePublishedPackages reads a packages value; it refuses an unknown version (ErrUnknownVersion) and what +// EncodePublishedPackages refuses. +func DecodePublishedPackages(data []byte) (PublishedPackages, error) { + var p PublishedPackages + if err := decode(data, &p); err != nil { + return PublishedPackages{}, err + } + if err := p.check(); err != nil { + return PublishedPackages{}, err + } + return p, nil +} + +// CheckImports refuses an import set (KeptInputs2.Imports, BuildAnswer.Imports, BuildLogEntry.Imports) that holds a +// path that is no mesh package's, or is not sorted and each once. +func CheckImports(imports []string) error { + for i, pkg := range imports { + if _, _, ok := PackageRepository(pkg); !ok || strings.ContainsAny(pkg, " \t\n@") { + return fmt.Errorf("delivery: the import %q is no mesh package's path (it starts with %s)", pkg, ForgeModulePrefix) + } + if i > 0 && imports[i-1] >= pkg { + return fmt.Errorf("delivery: the imports are not sorted and each once (%s after %s)", pkg, imports[i-1]) + } + } + return nil +} diff --git a/go/delivery/published_packages_test.go b/go/delivery/published_packages_test.go new file mode 100644 index 0000000..a8b2240 --- /dev/null +++ b/go/delivery/published_packages_test.go @@ -0,0 +1,87 @@ +package delivery + +import ( + "encoding/json" + "reflect" + "strings" + "testing" + "time" +) + +// The packages state, the build end's imports and the kept imports (ADR 0593 §2) round-trip, and a reader refuses what it +// could not take, naming it. +func TestPublishedPackagesAndImportsRoundTripAndRefuse(t *testing.T) { + commit := strings.Repeat("a", 40) + sdk := "git.novox.be/novox/mesh-sdk/go" + good := PublishedPackages{Module: sdk, Repository: "novox/mesh-sdk", Dir: "go", Commit: commit, Requires: "sha256:01", + Packages: []PackageDigest{{Package: sdk, Digest: "d0"}, {Package: sdk + "/announce", Digest: "d1"}, {Package: sdk + "/internal/token", Digest: "d2"}}, + At: time.Date(2026, 11, 2, 18, 0, 0, 0, time.UTC)} + raw, err := EncodePublishedPackages(good) + if err != nil { + t.Fatal(err) + } + back, err := DecodePublishedPackages(raw) + good.V = Version + if err != nil || !reflect.DeepEqual(back, good) { + t.Fatalf("round trip: %v, %+v", err, back) + } + if d, ok := back.DigestOf(sdk + "/announce"); !ok || d != "d1" { + t.Fatalf("DigestOf announce = %q, %v", d, ok) + } + if _, ok := back.DigestOf(sdk + "/buildstep"); ok { + t.Fatal("DigestOf finds a package the value does not hold") + } + if key, err := PackagesKey(sdk); err != nil || key != "git=2enovox=2ebe/novox/mesh-sdk/go" { + t.Fatalf("PackagesKey = %q, %v", key, err) + } + if _, err := PackagesKey("github.com/x/y"); err == nil { + t.Fatal("PackagesKey takes a path that is no mesh Go module's") + } + for name, mutate := range map[string]func(p *PublishedPackages){ + "no mesh module": func(p *PublishedPackages) { p.Module = "github.com/x/y" }, + "other repository": func(p *PublishedPackages) { p.Repository = "novox/mesh-host" }, + "short commit": func(p *PublishedPackages) { p.Commit = "abc" }, + "no requires": func(p *PublishedPackages) { p.Requires = "" }, + "package elsewhere": func(p *PublishedPackages) { + p.Packages = []PackageDigest{{Package: "git.novox.be/novox/mesh-host/x", Digest: "d"}} + }, + "unsorted": func(p *PublishedPackages) { p.Packages = []PackageDigest{p.Packages[1], p.Packages[0]} }, + "no digest": func(p *PublishedPackages) { p.Packages = []PackageDigest{{Package: sdk}} }, + } { + p := good + p.Packages = slicesClone(good.Packages) + mutate(&p) + if _, err := EncodePublishedPackages(p); err == nil { + t.Errorf("%s: encoded", name) + } + } + + imports := []string{sdk, sdk + "/announce"} + end := BuildLogEntry{Event: BuildEndedEvent, Module: "netcheck", FP: "src2:x", Repository: "novox/mesh-catalog", Commit: commit, + Node: "ace", At: good.At, Outcome: "done", Imports: imports} + raw, err = EncodeBuildLogEntry(end) + if err != nil { + t.Fatal(err) + } + if e, err := DecodeBuildLogEntry(raw); err != nil || !reflect.DeepEqual(e.Imports, imports) { + t.Fatalf("build end round trip: %v, %v", err, e.Imports) + } + start := end + start.Event, start.Outcome = BuildStartedEvent, "" + if _, err := EncodeBuildLogEntry(start); err == nil { + t.Error("a build's start with imports encoded") + } + end.Imports = []string{sdk + "/announce", sdk} + if _, err := EncodeBuildLogEntry(end); err == nil { + t.Error("unsorted imports encoded") + } + var answer BuildAnswer + if err := json.Unmarshal([]byte(`{"artifacts":[],"imports":["`+sdk+`"]}`), &answer); err != nil || len(answer.Imports) != 1 { + t.Fatalf("build answer imports: %v, %v", err, answer.Imports) + } + if err := CheckImports([]string{"fmt"}); err == nil { + t.Error("CheckImports takes a standard package") + } +} + +func slicesClone(d []PackageDigest) []PackageDigest { return append([]PackageDigest(nil), d...) } diff --git a/go/delivery/source.go b/go/delivery/source.go index a627671..d424962 100644 --- a/go/delivery/source.go +++ b/go/delivery/source.go @@ -467,6 +467,11 @@ type KeptInputs2 struct { // fingerprint takes the latest published version of each (Module.Packages). Absent for none, and in a value written // before. Packages []string `json:"packages,omitempty"` + // Imports are the mesh packages (import paths) the module's programs import, their tests left out, as the module's + // last trunk build reported them at its end (BuildLogEntry.Imports; novox/hq ADR 0593 §2), sorted and each once: + // the fingerprint takes each one's content digest (PublishedPackages) instead of Packages' versions. Absent for a + // module with no reported import set, which keeps the version line, and in a value written before. + Imports []string `json:"imports,omitempty"` } // checkPathObjects refuses a named path that is not clean and relative to the module's directory, or names no object. @@ -549,6 +554,9 @@ func (m Module) check() error { } } if m.Inputs2 != nil { + if err := CheckImports(m.Inputs2.Imports); err != nil { + return err + } if err := checkNamed(m.Inputs2.Tree, m.Manifest, m.Inputs2.Paths, "the module's kept inputs"); err != nil { return err } diff --git a/go/delivery/stepasks.go b/go/delivery/stepasks.go index f1d932d..bf8aa49 100644 --- a/go/delivery/stepasks.go +++ b/go/delivery/stepasks.go @@ -555,6 +555,10 @@ func DecodeStepAsk(data []byte) (StepAsk, error) { type BuildAnswer struct { Artifacts []AskBuilt `json:"artifacts"` Toolchain *ToolchainUsed `json:"toolchain,omitempty"` + // Imports are the mesh packages (import paths) the built programs import, their tests left out, sorted and each + // once (novox/hq ADR 0593 §2): a Go build module reports them, the builder carries them onto the build's end + // (BuildLogEntry.Imports). Absent from a build module that reports none. + Imports []string `json:"imports,omitempty"` } // AskBuilt is one artifact a build ask built. -- 2.54.0 From 04d951d9dae7d0f6f9706bb38097e69ef2409653 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 18:17:14 +0100 Subject: [PATCH 03/22] =?UTF-8?q?catalogue:=20a=20module's=20own=20object?= =?UTF-8?q?=20bucket,=20its=20reader=20grant=20and=20a=20stream=20that=20k?= =?UTF-8?q?eeps=20its=20history,=20so=20the=20knowledge=20base=20can=20kee?= =?UTF-8?q?p=20its=20records=20on=20the=20bus=20(ADR=200592=20=C2=A75,=20?= =?UTF-8?q?=C2=A76,=20=C2=A79)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/catalogue/manifest.go | 5 ++ go/catalogue/objects.go | 104 +++++++++++++++++++++++++++++++++++ go/catalogue/objects_test.go | 55 ++++++++++++++++++ go/catalogue/streams.go | 18 +++++- 4 files changed, 180 insertions(+), 2 deletions(-) create mode 100644 go/catalogue/objects.go create mode 100644 go/catalogue/objects_test.go diff --git a/go/catalogue/manifest.go b/go/catalogue/manifest.go index 568be11..ef4b2ea 100644 --- a/go/catalogue/manifest.go +++ b/go/catalogue/manifest.go @@ -612,6 +612,11 @@ type Manifest struct { // hears a part of it only where that setting's value names it. Streams []StreamDeclaration `json:"streams,omitempty"` + // Objects are the object buckets this module owns on the bus (novox/hq ADR 0592 §5): each named locally and + // bounded in size; the mesh creates each once, with no age limit, and refuses a new object when it is full. Only its + // module writes it; another reads it only through a `reads` entry and the bucket's grant (ADR 0592 §9). + Objects []ObjectsDeclaration `json:"objects,omitempty"` + // Settings are the defaults this module gives its settings (novox/hq ADR 0262): each key a file, // a contribution or a served fact asks for as `${setting:}`, its default, and why that // default. Only a preference has one — a font size, a width, a number of workers — and a value diff --git a/go/catalogue/objects.go b/go/catalogue/objects.go new file mode 100644 index 0000000..888c232 --- /dev/null +++ b/go/catalogue/objects.go @@ -0,0 +1,104 @@ +package catalogue + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// A module's own object bucket (novox/hq ADR 0592 §5, §9). +// +// A module declares an object bucket as it declares a state (ADR 0201) or a stream (ADR 0363): named locally, +// with its maximum size. The mesh creates it once, keeps every object with no age limit, and refuses a new object +// when it is full rather than dropping one; only its module writes it. Another module reads it only as a restricted +// stream is heard: a `reads` entry `.` naming it, and a module the owner's trusted config value of the +// bucket's grant names (ADR 0363 §4, §5), read-only. This is the shape only: its checks are the indexer's judge's. + +// The mesh's bounds on a module's object bucket: its size in MiB (ADR 0592 §5, §25; the bound ADR 0363 §1 sets for a +// stream). A bucket says its size: there is no default. +const ( + ObjectsLeastMiB = 1 + ObjectsMostMiB = 8192 +) + +// FullWarnPercent is how full, in percent of its cap, a history stream or an object bucket is when the mesh raises its +// condition, in one place for every module (novox/hq ADR 0592 §6, §25). +const FullWarnPercent = 75 + +// ObjectsDeclaration is one object bucket a module owns. +type ObjectsDeclaration struct { + // Name is its local name, one plain name as a state's (lower-case letters, digits and hyphens). + Name string `json:"name"` + // MaxMiB bounds it, ObjectsLeastMiB to ObjectsMostMiB. + MaxMiB int `json:"max-mib"` + // Grant names the trusted config value of the module whose entries name the modules that may read it; absent, + // no other module reads it. + Grant *ObjectsGrant `json:"grant,omitempty"` +} + +// ObjectsGrant says which trusted config value of the owning module grants another module the read of a bucket +// (novox/hq ADR 0592 §9): the value has a stream grant's shape (GrantedKeys), and a module any of its keys names, wherever +// it runs or as `.`, reads the whole bucket, read-only. One value may grant a module's streams and its +// object buckets alike. +type ObjectsGrant struct { + // Config is the trusted config value of the module that grants it (ADR 0348 §8): set only at the controller's + // terminal or on the operator's Approve. + Config string `json:"config"` +} + +// UnmarshalJSON reads an object bucket strictly: an unknown key is refused here, not passed over. +func (o *ObjectsDeclaration) UnmarshalJSON(raw []byte) error { + type plain ObjectsDeclaration + var p plain + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&p); err != nil { + return fmt.Errorf("an object bucket is {name, max-mib, grant}: %w", typedUnknown(err)) + } + *o = ObjectsDeclaration(p) + return nil +} + +// UnmarshalJSON reads an object bucket's grant strictly. +func (g *ObjectsGrant) UnmarshalJSON(raw []byte) error { + type plain ObjectsGrant + var p plain + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&p); err != nil { + return fmt.Errorf("an object bucket's grant is {config}: %w", typedUnknown(err)) + } + *g = ObjectsGrant(p) + return nil +} + +// ObjectsBucket is the bucket's name on the bus, as a state's is: the module and the local name joined by an +// underscore, which neither may hold, so two modules never derive one bucket. +func ObjectsBucket(module, local string) string { return module + "_" + local } + +// Bucket is this bucket's name on the bus for its module. +func (o ObjectsDeclaration) Bucket(module string) string { return ObjectsBucket(module, o.Name) } + +// ObjectsNamed is the module's object bucket of the local name, and whether it declares one. +func (m Manifest) ObjectsNamed(local string) (ObjectsDeclaration, bool) { + for _, o := range m.Objects { + if o.Name == local { + return o, true + } + } + return ObjectsDeclaration{}, false +} + +// ObjectsReader says whether the restricting config values of a bucket's grant let module, on machine, read it +// (novox/hq ADR 0592 §9): a bucket with no grant is read by its owner alone, and a value of another shape grants nothing. +func (o ObjectsDeclaration) ObjectsReader(values []any, module, machine string) bool { + if o.Grant == nil { + return false + } + for _, v := range values { + if len(GrantedKeys(v, module, machine)) > 0 { + return true + } + } + return false +} diff --git a/go/catalogue/objects_test.go b/go/catalogue/objects_test.go new file mode 100644 index 0000000..f296a66 --- /dev/null +++ b/go/catalogue/objects_test.go @@ -0,0 +1,55 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// A module declares its own object bucket and a stream that keeps its history (ADR 0592 §5, §6, §9): both are read with +// their bounds, the bucket's grant names its readers through a trusted config value as a restricted stream's does, and +// an unknown key in either is refused, not passed over. +func TestAModuleDeclaresAnObjectBucketAndAHistoryStream(t *testing.T) { + m, err := decodeForTest([]byte(`{"module":"mesh-knowledge","version":"1",` + + `"streams":[{"name":"versions","events":["version.**"],"max-mib":2048,"retention":"history","grant":{"config":"readers","events":"{key}.**"}}],` + + `"objects":[{"name":"sections","max-mib":4096,"grant":{"config":"readers"}},{"name":"own","max-mib":1}]}`)) + if err != nil { + t.Fatal(err) + } + s := m.Streams[0] + if !s.KeepsHistory() || s.Days() != 0 || s.Cap() != 2048 { + t.Fatalf("history stream read as %+v (days %d)", s, s.Days()) + } + o, ok := m.ObjectsNamed("sections") + if !ok || o.MaxMiB != 4096 || o.Grant == nil || o.Grant.Config != "readers" || o.Bucket(m.Module) != "mesh-knowledge_sections" { + t.Fatalf("object bucket read as %+v", o) + } + readers := []any{map[string]any{"records": []any{"knowledge-web", "ace.mesh-view"}}} + if !o.ObjectsReader(readers, "knowledge-web", "g14") || !o.ObjectsReader(readers, "mesh-view", "ace") || + o.ObjectsReader(readers, "mesh-view", "g14") || o.ObjectsReader(readers, "other", "ace") { + t.Fatal("the bucket's grant does not name its readers as a restricted stream's does") + } + own, _ := m.ObjectsNamed("own") + if own.ObjectsReader(readers, "knowledge-web", "g14") { + t.Fatal("a bucket with no grant is read by another module") + } + for name, raw := range map[string]string{ + "bucket": `{"module":"x","version":"1","objects":[{"name":"a","max-mib":1,"max-age-days":3}]}`, + "bucket grant": `{"module":"x","version":"1","objects":[{"name":"a","max-mib":1,"grant":{"config":"r","events":"{key}"}}]}`, + } { + if _, err := decodeStrictObjects(raw); err == nil || !strings.Contains(err.Error(), "object bucket") { + t.Errorf("%s: an unknown key was not refused in the object bucket's words: %v", name, err) + } + } +} + +func decodeStrictObjects(raw string) (Manifest, error) { + m, err := decodeForTest([]byte(raw)) + if err == nil && m.SetAside() != "" { + return m, errString(m.SetAside()) + } + return m, err +} + +type errString string + +func (e errString) Error() string { return string(e) } diff --git a/go/catalogue/streams.go b/go/catalogue/streams.go index 86cb60f..884a29b 100644 --- a/go/catalogue/streams.go +++ b/go/catalogue/streams.go @@ -35,6 +35,12 @@ const GrantKey = "{key}" // is the mesh's own limits retention — kept until it ages or the stream fills, dropping its oldest. const StreamRetentionWorkQueue = "work-queue" +// StreamRetentionHistory is a stream's declared retention that keeps its history (novox/hq ADR 0592 §6): every message +// is kept with no maximum age, and when the stream reaches its maximum size the bus refuses a new message rather than +// dropping its oldest. It takes no max-age-days, max-deliveries or duplicates; the mesh raises a condition when it +// passes FullWarnPercent of its cap. +const StreamRetentionHistory = "history" + // StreamLeastDeliveries and StreamMostDeliveries bound a work-queue stream's max-deliveries (novox/hq ADR // 0436 §6): how many times a message is handed over before it is given up as a dead letter. const ( @@ -57,7 +63,8 @@ type StreamDeclaration struct { // Grant restricts it, when said. Grant *StreamGrant `json:"grant,omitempty"` // Retention is how the stream decides what to keep when it fills (novox/hq ADR 0436 §6): empty for the - // mesh's own limits retention (dropping its oldest), or StreamRetentionWorkQueue. + // mesh's own limits retention (dropping its oldest), StreamRetentionWorkQueue, or StreamRetentionHistory + // (novox/hq ADR 0592 §6). Retention string `json:"retention,omitempty"` // MaxDeliveries bounds how many times a work-queue message is handed over, 1 to 10; refused without // retention work-queue (novox/hq ADR 0436 §6). @@ -104,14 +111,21 @@ func (g *StreamGrant) UnmarshalJSON(raw []byte) error { return nil } -// Days is the stream's age in days, the default where none is said. +// Days is the stream's age in days, the default where none is said, and 0 (no maximum age) for a stream that keeps its +// history. func (s StreamDeclaration) Days() int { + if s.KeepsHistory() { + return 0 + } if s.MaxAgeDays == 0 { return StreamDefaultDays } return s.MaxAgeDays } +// KeepsHistory says the stream keeps every message and refuses a new one when full (StreamRetentionHistory). +func (s StreamDeclaration) KeepsHistory() bool { return s.Retention == StreamRetentionHistory } + // Cap is the stream's size in MiB, the default where none is said. func (s StreamDeclaration) Cap() int { if s.MaxMiB == 0 { -- 2.54.0 From 346dd432288641be8fa2b1f5fe7bceed8ceefaec Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 18:19:24 +0100 Subject: [PATCH 04/22] =?UTF-8?q?delivery,=20catalogue:=20the=20node-engin?= =?UTF-8?q?e's=20compose-candidate=20verb,=20compositions=20carrying=20the?= =?UTF-8?q?ir=20plan=20or=20refusal,=20the=20kept=20import=20form=20and=20?= =?UTF-8?q?a=20build's=20imports=20on=20its=20key,=20so=20the=20engine=20a?= =?UTF-8?q?lone=20composes=20and=20the=20indexer=20switches=20form=20witho?= =?UTF-8?q?ut=20a=20rebuild=20(ADR=200593=20=C2=A74,=20=C2=A76,=20=C2=A77)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/catalogue/engine_seat.go | 8 ++ go/delivery/builder.go | 6 + go/delivery/compose_candidate.go | 189 ++++++++++++++++++++++++++ go/delivery/compose_candidate_test.go | 120 ++++++++++++++++ go/delivery/composed.go | 19 ++- go/delivery/composed_value.go | 27 +++- go/delivery/source.go | 8 ++ 7 files changed, 373 insertions(+), 4 deletions(-) create mode 100644 go/delivery/compose_candidate.go create mode 100644 go/delivery/compose_candidate_test.go diff --git a/go/catalogue/engine_seat.go b/go/catalogue/engine_seat.go index 829143f..518e8e7 100644 --- a/go/catalogue/engine_seat.go +++ b/go/catalogue/engine_seat.go @@ -69,6 +69,14 @@ func EngineVerbs() []Verb { "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)}, + // A composition that does not exist yet (novox/hq ADR 0593 §7). Optional until every engine serves it: a node + // whose engine does not is a node that does not answer (§9). + {Name: "compose-candidate", Optional: true, Description: "Compose a check's candidate on this machine in shadow, " + + "with its own sealed values and the indexer's manifest verdicts, and say per module placed here whether it " + + "composes (with its digest and whether the candidate changes it), is refused by the candidate or is held " + + "without it, each with why, and the verdict of the machine's own composition (novox/hq ADR 0593 §7). Read-only: " + + "applies nothing and writes nothing. Answered by this machine's engine alone, within the asker's bound.", + Input: schema(map[string]string{"candidate": "the key of the indexer's candidate, ."}, []string{"candidate"})}, } } diff --git a/go/delivery/builder.go b/go/delivery/builder.go index c940019..95468e3 100644 --- a/go/delivery/builder.go +++ b/go/delivery/builder.go @@ -188,9 +188,15 @@ type BuilderBuild struct { // its id (novox/hq ADR 0525 §1; issue 1887 A4b): a builder that takes the key over at its start waits for those asks' // steps and never puts them again. A reader that does not know the field reads the key as before. Asks map[string]string `json:"asks,omitempty"` + // Imports are, on a done key, the mesh packages the built programs import, as the build module answered them + // (BuildAnswer.Imports; novox/hq ADR 0593 §2): the builder carries them onto the build's end (BuildLogEntry.Imports). + Imports []string `json:"imports,omitempty"` } func (b BuilderBuild) check() error { + if err := CheckImports(b.Imports); err != nil { + return err + } switch b.State { case BuildQueued, BuildBuilding, BuildDone, BuildFailed: default: diff --git a/go/delivery/compose_candidate.go b/go/delivery/compose_candidate.go new file mode 100644 index 0000000..ad382a8 --- /dev/null +++ b/go/delivery/compose_candidate.go @@ -0,0 +1,189 @@ +package delivery + +import ( + "bytes" + "encoding/json" + "fmt" + "time" +) + +// A composition that does not exist yet is asked of the node-engine (novox/hq ADR 0593 §7): the seat node-engine's verb +// compose-candidate, served by each node's engine alone, composes a check's candidate in shadow with the node's own +// sealed values and the indexer's manifest verdicts, applies and writes nothing, and answers within the asker's bound. +// The checker asks every placed node in parallel and makes the check's composition verdict from the answers; a node +// that does not answer within the bound is named "not composed on that node: no answer" and the check passes with that +// warning (§9). This is the contract only: the composing is the node-engine's own. + +const ( + // EngineComposeCandidateVerb is the verb's name on the seat node-engine (catalogue.EngineSeat). + EngineComposeCandidateVerb = "compose-candidate" + // ComposeAnswerBound is the default of the checker's setting compose-answer-bound: how long it waits for a node's + // answer (ADR 0593 §7, §9). + ComposeAnswerBound = 60 * time.Second + // ComposeNoAnswer is the words a check's verdict names a node with that did not answer within the bound (ADR 0593 §9). + ComposeNoAnswer = "not composed on that node: no answer" + + // ComposeCandidateComposed and ComposeCandidateRefused are an answer's verdict: no module and not the node's own composition is + // refused by the candidate, or at least one is (or the node could not compose with it at all, Refused). + ComposeCandidateComposed = "composed" + ComposeCandidateRefused = "refused" + + // The states of one module's composition with the candidate: composed (Digest set), refused by the candidate + // (Words set), or held, refused without the candidate too (Words set), which counts toward no verdict. + ModuleComposedState = "composed" + ModuleRefusedState = "refused" + ModuleHeldState = "held" +) + +// ComposeCandidateAsk is the verb's input: Candidate is the key of the indexer's candidate (CompositionKey, +// `.`); the engine reads that candidate itself and lays its modules over its placement. +// Input asks the engine to answer too the composition input it composed the candidate from (its own node's scope, the +// catalogue left out as composer.Request.Input says): the checker puts it into the request a build module's composer +// runs over, for a group that changes the node-engine's composition code (ADR 0593 §7). +type ComposeCandidateAsk struct { + Candidate string `json:"candidate"` + Input bool `json:"input,omitempty"` +} + +// ComposeCandidateAnswer is the verb's answer: the node, the candidate, the verdict, every module placed on the node +// sorted by name, the node's own composition (Own, Module "node"), why the node could not compose with the candidate at +// all (Refused: Modules is then empty and Verdict refused), how long the engine took in milliseconds, and, when asked +// (ComposeCandidateAsk.Input), the composition input it composed from. +type ComposeCandidateAnswer struct { + V int `json:"v"` + Node string `json:"node"` + Candidate string `json:"candidate"` + Verdict string `json:"verdict"` + Modules []ModuleComposed `json:"modules"` + Own ModuleComposed `json:"own"` + Refused string `json:"refused,omitempty"` + Took int64 `json:"took"` + Input json.RawMessage `json:"input,omitempty"` +} + +// ModuleComposed is one module's composition with the candidate: the fingerprint it was composed at (the candidate's +// FP2 for a module the candidate touches, else its newest done build as composed- holds it), its state, the +// composition's digest when composed (ComposedDigestOf), whether the candidate changes it from the one composed without +// it (the checker's "what the change reaches on this node"), and why, for a refused or held one. +type ModuleComposed struct { + Module string `json:"module"` + FP string `json:"fp,omitempty"` + State string `json:"state"` + Digest string `json:"digest,omitempty"` + Changed bool `json:"changed,omitempty"` + Words string `json:"words,omitempty"` +} + +func (a ComposeCandidateAsk) check() error { + if _, _, err := ParseCompositionKey(a.Candidate); err != nil { + return fmt.Errorf("delivery: a compose-candidate ask names no candidate: %w", err) + } + return nil +} + +func (m ModuleComposed) check(node string) error { + if m.Module == "" { + return fmt.Errorf("delivery: a module's composition on %s names no module", node) + } + switch m.State { + case ModuleComposedState: + if m.Digest == "" { + return fmt.Errorf("delivery: %s is composed on %s and names no digest", m.Module, node) + } + case ModuleRefusedState, ModuleHeldState: + if m.Words == "" { + return fmt.Errorf("delivery: %s is %s on %s and says no why", m.Module, m.State, node) + } + default: + return fmt.Errorf("delivery: %s on %s is %q, neither %s, %s nor %s", m.Module, node, m.State, + ModuleComposedState, ModuleRefusedState, ModuleHeldState) + } + return nil +} + +func (a ComposeCandidateAnswer) check() error { + if err := checkName("node", a.Node); err != nil { + return err + } + if _, _, err := ParseCompositionKey(a.Candidate); err != nil { + return err + } + if a.Took < 0 { + return fmt.Errorf("delivery: the answer of %s took %d ms", a.Node, a.Took) + } + if a.Refused != "" { + if len(a.Modules) > 0 || a.Verdict != ComposeCandidateRefused { + return fmt.Errorf("delivery: %s could not compose the candidate (%s), and its answer names modules or no refusal", a.Node, a.Refused) + } + return nil + } + refused := false + for i, m := range append([]ModuleComposed{a.Own}, a.Modules...) { + if err := m.check(a.Node); err != nil { + return err + } + if i > 1 && a.Modules[i-2].Module >= m.Module { + return fmt.Errorf("delivery: the modules of %s's answer are not sorted and each once (%s after %s)", a.Node, m.Module, a.Modules[i-2].Module) + } + refused = refused || m.State == ModuleRefusedState + } + switch { + case a.Own.Module != nodeWord: + return fmt.Errorf("delivery: the node's own composition in %s's answer is named %q, not %q", a.Node, a.Own.Module, nodeWord) + case refused && a.Verdict != ComposeCandidateRefused: + return fmt.Errorf("delivery: %s's answer refuses a module and its verdict is %q", a.Node, a.Verdict) + case !refused && a.Verdict != ComposeCandidateComposed: + return fmt.Errorf("delivery: %s's answer refuses no module and its verdict is %q", a.Node, a.Verdict) + } + return nil +} + +// strictDecode reads data into v, refusing an unknown key. +func strictDecode(data []byte, v any, what string) error { + d := json.NewDecoder(bytes.NewReader(data)) + d.DisallowUnknownFields() + if err := d.Decode(v); err != nil { + return fmt.Errorf("delivery: %s does not fit its shape: %w", what, err) + } + return nil +} + +// EncodeComposeCandidateAsk is a as JSON; it refuses an ask naming no candidate key. +func EncodeComposeCandidateAsk(a ComposeCandidateAsk) ([]byte, error) { + if err := a.check(); err != nil { + return nil, err + } + return json.Marshal(a) +} + +// DecodeComposeCandidateAsk reads an ask strictly; it refuses an unknown key and what EncodeComposeCandidateAsk refuses. +func DecodeComposeCandidateAsk(data []byte) (ComposeCandidateAsk, error) { + var a ComposeCandidateAsk + if err := strictDecode(data, &a, "a compose-candidate ask"); err != nil { + return ComposeCandidateAsk{}, err + } + return a, a.check() +} + +// EncodeComposeCandidateAnswer is a as JSON at Version; it refuses an unknown state, a composed module without a digest, +// a refused or held one without words, a refusal of the whole naming modules, and a verdict the modules do not give. +func EncodeComposeCandidateAnswer(a ComposeCandidateAnswer) ([]byte, error) { + if err := a.check(); err != nil { + return nil, err + } + a.V = Version + return json.Marshal(a) +} + +// DecodeComposeCandidateAnswer reads an answer strictly; it refuses an unknown version (ErrUnknownVersion), an unknown +// key and what EncodeComposeCandidateAnswer refuses. +func DecodeComposeCandidateAnswer(data []byte) (ComposeCandidateAnswer, error) { + if err := checkVersion(data); err != nil { + return ComposeCandidateAnswer{}, err + } + var a ComposeCandidateAnswer + if err := strictDecode(data, &a, "a compose-candidate answer"); err != nil { + return ComposeCandidateAnswer{}, err + } + return a, a.check() +} diff --git a/go/delivery/compose_candidate_test.go b/go/delivery/compose_candidate_test.go new file mode 100644 index 0000000..4c9558c --- /dev/null +++ b/go/delivery/compose_candidate_test.go @@ -0,0 +1,120 @@ +package delivery + +import ( + "encoding/json" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// The verb compose-candidate's ask and answer, and a composition that carries its plan or its refusal (ADR 0593 §6, §7): +// each round-trips, and a reader refuses what it could not take, naming it. +func TestComposeCandidateAndComposedPlanRoundTripAndRefuse(t *testing.T) { + key, err := CompositionKey("mesh-sdk#7", "0123456789ab") + if err != nil { + t.Fatal(err) + } + raw, err := EncodeComposeCandidateAsk(ComposeCandidateAsk{Candidate: key, Input: true}) + if err != nil { + t.Fatal(err) + } + if a, err := DecodeComposeCandidateAsk(raw); err != nil || a.Candidate != key || !a.Input { + t.Fatalf("ask round trip: %v, %+v", err, a) + } + if _, err := DecodeComposeCandidateAsk([]byte(`{"candidate":"` + key + `","other":1}`)); err == nil { + t.Error("an ask with an unknown key was read") + } + if _, err := EncodeComposeCandidateAsk(ComposeCandidateAsk{Candidate: "nope"}); err == nil { + t.Error("an ask naming no candidate key was encoded") + } + + good := ComposeCandidateAnswer{Node: "ace", Candidate: key, Verdict: ComposeCandidateRefused, Took: 812, + Own: ModuleComposed{Module: "node", State: ModuleComposedState, Digest: "sha256:00"}, + Modules: []ModuleComposed{ + {Module: "netcheck", FP: "src2:x", State: ModuleComposedState, Digest: "sha256:01", Changed: true}, + {Module: "route-proxy", State: ModuleRefusedState, Words: "the setting domain has no value"}, + {Module: "zz", State: ModuleHeldState, Words: "held before"}, + }, Input: json.RawMessage(`{"machines":["ace"]}`)} + raw, err = EncodeComposeCandidateAnswer(good) + if err != nil { + t.Fatal(err) + } + back, err := DecodeComposeCandidateAnswer(raw) + if err != nil || back.Verdict != ComposeCandidateRefused || len(back.Modules) != 3 || string(back.Input) != string(good.Input) { + t.Fatalf("answer round trip: %v, %+v", err, back) + } + for name, mutate := range map[string]func(a *ComposeCandidateAnswer){ + "verdict not the modules'": func(a *ComposeCandidateAnswer) { a.Verdict = ComposeCandidateComposed }, + "unknown state": func(a *ComposeCandidateAnswer) { a.Modules[0].State = "fine" }, + "composed without digest": func(a *ComposeCandidateAnswer) { a.Modules[0].Digest = "" }, + "refused without words": func(a *ComposeCandidateAnswer) { a.Modules[1].Words = "" }, + "unsorted": func(a *ComposeCandidateAnswer) { a.Modules[0], a.Modules[1] = a.Modules[1], a.Modules[0] }, + "own misnamed": func(a *ComposeCandidateAnswer) { a.Own.Module = "ace" }, + "whole refusal w/ modules": func(a *ComposeCandidateAnswer) { a.Refused = "the bound passed" }, + } { + a := good + a.Modules = append([]ModuleComposed(nil), good.Modules...) + mutate(&a) + if _, err := EncodeComposeCandidateAnswer(a); err == nil { + t.Errorf("%s: encoded", name) + } + } + whole := ComposeCandidateAnswer{Node: "ace", Candidate: key, Verdict: ComposeCandidateRefused, Refused: "the bound passed"} + if _, err := EncodeComposeCandidateAnswer(whole); err != nil { + t.Errorf("a whole refusal was not encoded: %v", err) + } + if _, err := DecodeComposeCandidateAnswer([]byte(strings.Replace(string(raw), `"took"`, `"other":1,"took"`, 1))); err == nil { + t.Error("an answer with an unknown key was read") + } + + fp := "src2:" + strings.Repeat("a", 64) + planned := Composed{Node: "ace", Module: "netcheck", FP: fp, Resources: []json.RawMessage{}, + Plan: &catalogue.Plan{Files: []catalogue.PlanFile{}}} + raw, err = EncodeComposed(planned) + if err != nil { + t.Fatal(err) + } + c, err := DecodeComposed(raw) + if err != nil || c.Plan == nil { + t.Fatalf("a composition's plan did not round-trip: %v, %+v", err, c) + } + d1, _ := ComposedDigestOf(planned) + d0, _ := ComposedDigestOf(Composed{Node: "ace", Module: "netcheck", FP: fp, Resources: []json.RawMessage{}}) + if d1 == "" || d1 == d0 { + t.Error("the digest does not move with the plan") + } + refused := Composed{Node: "ace", Module: "netcheck", FP: fp, Refused: "the setting domain has no value"} + if raw, err = EncodeComposed(refused); err != nil { + t.Fatal(err) + } + if c, err := DecodeComposed(raw); err != nil || c.Refused != refused.Refused { + t.Fatalf("a refused composition did not round-trip: %v", err) + } + if _, err := ComposedDigestOf(refused); err == nil { + t.Error("a refused composition has a digest") + } + refused.Data = true + if _, err := EncodeComposed(refused); err == nil { + t.Error("a refused composition holding data was encoded") + } + if _, err := EncodeComposed(Composed{Node: "ace", Resources: []json.RawMessage{}, Refused: "x"}); err == nil { + t.Error("the node's own composition carried a refusal") + } +} + +// A kept import form is set only with imports, and in the fingerprint's form (ADR 0593 §4). +func TestAKeptImportFormNeedsImports(t *testing.T) { + form := SourcePrefix2 + strings.Repeat("b", 64) + in := KeptInputs2{Tree: "t", Imports: []string{"git.novox.be/novox/mesh-sdk/go"}, ImportForm: form} + m := Module{FP2: form, Manifest: strings.Repeat("c", 64), Inputs2: &in, Repository: "novox/x", Commit: "c"} + if err := m.check(); err != nil && strings.Contains(err.Error(), "import form") { + t.Fatalf("a good import form refused: %v", err) + } + for _, bad := range []KeptInputs2{{Tree: "t", ImportForm: form}, {Tree: "t", Imports: in.Imports, ImportForm: "src2:short"}} { + m.Inputs2 = &bad + if err := m.check(); err == nil || !strings.Contains(err.Error(), "import form") { + t.Errorf("%+v: %v", bad, err) + } + } +} diff --git a/go/delivery/composed.go b/go/delivery/composed.go index 659a19b..5fdb5f5 100644 --- a/go/delivery/composed.go +++ b/go/delivery/composed.go @@ -8,8 +8,8 @@ import ( ) // The compositions (novox/hq ADR 0539 clauses 1 to 4). A **composition** is one module's resources composed for one node -// at one fingerprint: a node composes its own (ADR 0547 §2), and the ended controller wrote them into a bucket of each -// node, `composed-`, which nothing writes since it ended (ADR 0590), under `..`, and +// at one fingerprint: a node composes its own (ADR 0547 §2) and writes each it makes into its node's bucket, +// `composed-` (ADR 0593 §6), under `..`, and // the node's own resources (adoption, firewall openings, accounts, the resolver, identities and groups) under // `.node`. A composition's digest and its judgement of the move from the composition the node runs now is // ComposedDigest. @@ -23,6 +23,21 @@ const ( ComposedKeep = 5 ) +// Who writes and who reads a node's compositions (novox/hq ADR 0593 §6): the node's engine alone writes its node's +// bucket, unsigned, a record for readers that no engine applies; the grants part creates each bucket, create-only, and +// grants the readers read-only on every node's bucket. A reader declares ComposedRead in its manifest's `reads`, and the +// engine's manifest announces the state ComposedState so that name resolves. +const ( + // ComposedState is the local name the node-engine's manifest announces for the compositions of every node. + ComposedState = "composed" + // ComposedRead is the `reads` entry naming it: the node-engine's module and ComposedState. + ComposedRead = "mesh-host." + ComposedState +) + +// ComposedReaders are the modules that may read every node's compositions (ADR 0593 §6, §8): the provisioner makes its +// plans from them, the assigner compares them for mesh-wants.upgrade, and the view shows them. +var ComposedReaders = []string{"mesh-delivery-assigner", "mesh-delivery-view", "mesh-provisioner"} + // nodeWord is the last part of a node's own key, in the have bucket and in a node's composition bucket alike. const nodeWord = "node" diff --git a/go/delivery/composed_value.go b/go/delivery/composed_value.go index a1afb5a..b98f85e 100644 --- a/go/delivery/composed_value.go +++ b/go/delivery/composed_value.go @@ -8,6 +8,7 @@ import ( "fmt" "io" + "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/declaration" ) @@ -46,6 +47,15 @@ type Composed struct { Resources []json.RawMessage Filled []string Own NodeOwn + // Plan is the module's whole provisioning plan as the node-engine hands it to the provisioner (templates, files, + // own secrets by name, items, collections, provisions; never a value), composed node-wide (novox/hq ADR 0593 §6, + // §8): the provisioner makes every plan from it. Nil when the module hands the provisioner nothing; only on a + // module's key. It is part of the encoding, so the digest moves when the plan does. + Plan *catalogue.Plan + // Refused is why the module cannot be composed at this fingerprint on this node, the rest of the node composed + // (ADR 0593 §6): a refused composition holds no resource, filled file, data or plan, and has no digest + // (ComposedDigestOf refuses it); only on a module's key. The node keeps running what it ran of the module. + Refused string // Signature is the controller's or, made by hand, the operator's (ADR 0539 clause 3, SignComposed); it signs the // digest and is never part of it. Signature *Signature @@ -61,6 +71,8 @@ type composedWire struct { Resources []json.RawMessage `json:"resources"` Filled []string `json:"filled_files,omitempty"` Own *NodeOwn `json:"own,omitempty"` + Plan *catalogue.Plan `json:"plan,omitempty"` + Refused string `json:"refused,omitempty"` Signature *Signature `json:"signature,omitempty"` } @@ -99,6 +111,12 @@ func (c Composed) check() error { return fmt.Errorf("delivery: the composition of %s carries the node's own parts, which only .node carries", c.Module) } } + if c.Module == "" && (c.Plan != nil || c.Refused != "") { + return fmt.Errorf("delivery: the node's own composition carries no plan and no refusal: only a module's does") + } + if c.Refused != "" && (len(c.Resources) > 0 || len(c.Filled) > 0 || c.Data || c.Plan != nil) { + return fmt.Errorf("delivery: the composition of %s on %s is refused and holds resources, filled files, data or a plan", c.Module, c.Node) + } seen := map[string]bool{} files := map[string][]*declaration.File{} for _, raw := range c.Resources { @@ -166,7 +184,7 @@ func (c Composed) wire() (composedWire, error) { return composedWire{}, err } w := composedWire{V: Version, Node: c.Node, Module: c.Module, FP: c.FP, Data: c.Data, Filled: c.Filled, - Signature: c.Signature, Resources: make([]json.RawMessage, len(c.Resources))} + Plan: c.Plan, Refused: c.Refused, Signature: c.Signature, Resources: make([]json.RawMessage, len(c.Resources))} for i, r := range c.Resources { canon, err := canonicalJSON(r) if err != nil { @@ -205,7 +223,7 @@ func DecodeComposed(data []byte) (Composed, error) { return Composed{}, fmt.Errorf("delivery: a composition does not fit its shape: %w", err) } c := Composed{V: w.V, Node: w.Node, Module: w.Module, FP: w.FP, Data: w.Data, Resources: w.Resources, - Filled: w.Filled, Signature: w.Signature} + Filled: w.Filled, Plan: w.Plan, Refused: w.Refused, Signature: w.Signature} if w.Own != nil { c.Own = *w.Own } @@ -217,7 +235,12 @@ func DecodeComposed(data []byte) (Composed, error) { // ComposedDigestOf is a composition's digest, "sha256:", of its encoding without its signature. A want names it // (ADR 0539 clause 1), the controller writes it beside the composition, and the signature signs it. +// +// A refused composition has no digest: it is refused, naming the module (ADR 0593 §6). func ComposedDigestOf(c Composed) (string, error) { + if c.Refused != "" { + return "", fmt.Errorf("delivery: the composition of %s on %s is refused (%s) and has no digest", c.Module, c.Node, c.Refused) + } c.Signature = nil raw, err := EncodeComposed(c) if err != nil { diff --git a/go/delivery/source.go b/go/delivery/source.go index d424962..8063384 100644 --- a/go/delivery/source.go +++ b/go/delivery/source.go @@ -472,6 +472,11 @@ type KeptInputs2 struct { // the fingerprint takes each one's content digest (PublishedPackages) instead of Packages' versions. Absent for a // module with no reported import set, which keeps the version line, and in a value written before. Imports []string `json:"imports,omitempty"` + // ImportForm is the import-form fingerprint (src2: and 64 hex digits) of the inputs the build that reported Imports + // was built from (novox/hq ADR 0593 §4): set by the indexer when it keeps a build's Imports, while FP2 is still the + // form that build was asked at. A recompute whose import-form fingerprint equals it keeps FP2 as it is (the build + // stands); one that differs writes the import form. Absent with no Imports, and in a value written before. + ImportForm string `json:"import-form,omitempty"` } // checkPathObjects refuses a named path that is not clean and relative to the module's directory, or names no object. @@ -557,6 +562,9 @@ func (m Module) check() error { if err := CheckImports(m.Inputs2.Imports); err != nil { return err } + if f := m.Inputs2.ImportForm; f != "" && (len(m.Inputs2.Imports) == 0 || !strings.HasPrefix(f, SourcePrefix2) || !isHex64(f[len(SourcePrefix2):])) { + return fmt.Errorf("delivery: the kept import form %q is set only with imports, and is %s and 64 hex digits", f, SourcePrefix2) + } if err := checkNamed(m.Inputs2.Tree, m.Manifest, m.Inputs2.Paths, "the module's kept inputs"); err != nil { return err } -- 2.54.0 From d158ac52dabd599e5ab5640c1e2736c397d53ac5 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 18:20:05 +0100 Subject: [PATCH 05/22] =?UTF-8?q?composer:=20a=20request=20carries=20its?= =?UTF-8?q?=20composition=20input=20as=20raw=20JSON,=20so=20the=20shape=20?= =?UTF-8?q?stays=20in=20mesh-sdk=20while=20the=20composer=20moves=20to=20t?= =?UTF-8?q?he=20node-engine=20(ADR=200593=20=C2=A77)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/cmd/mesh-compose/main_test.go | 13 +++++++++++-- go/composer/composer.go | 13 ++++++++++--- go/composer/composer_test.go | 18 ++++++++++++++---- 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/go/cmd/mesh-compose/main_test.go b/go/cmd/mesh-compose/main_test.go index f2d909b..48d4b77 100644 --- a/go/cmd/mesh-compose/main_test.go +++ b/go/cmd/mesh-compose/main_test.go @@ -46,7 +46,7 @@ func meshOf() compose.Input { func request(t *testing.T, depth string) composer.Request { t.Helper() - return composer.Request{V: composer.Version, Input: meshOf(), Depth: depth, HoldingsFromClaims: true, + return composer.Request{V: composer.Version, Input: rawInput(meshOf()), Depth: depth, HoldingsFromClaims: true, Machines: []string{"anchor", "laptop", "ace"}, Cases: []composer.Case{{Name: "trunk", Manifests: captured(t)}}} } @@ -180,7 +180,7 @@ func TestACatalogueCheckoutIsComposedForANode(t *testing.T) { if err := os.WriteFile(filepath.Join(dir, "modules", "demo", "module.json"), []byte(manifest), 0o644); err != nil { t.Fatal(err) } - in := encode(t, composer.Request{V: composer.Version, Input: compose.Input{Nodes: []compose.Node{{Name: "laptop", Assigned: []string{"demo"}}}}}) + in := encode(t, composer.Request{V: composer.Version, Input: rawInput(compose.Input{Nodes: []compose.Node{{Name: "laptop", Assigned: []string{"demo"}}}})}) code, v, stderr := runJSON(t, []string{"-catalogue", dir, "-node", "laptop", "-declarations"}, in) if code != 0 { t.Fatalf("exit %d: %s", code, stderr) @@ -318,3 +318,12 @@ func TestTheCommandRunsWithNoNetwork(t *testing.T) { sort.Strings(names) t.Logf("composed with no network: %v", names) } + +// rawInput is a composition input as a request carries it. +func rawInput(in compose.Input) json.RawMessage { + raw, err := json.Marshal(in) + if err != nil { + panic(err) + } + return raw +} diff --git a/go/composer/composer.go b/go/composer/composer.go index 164a096..566f6ac 100644 --- a/go/composer/composer.go +++ b/go/composer/composer.go @@ -44,8 +44,9 @@ const ( type Request struct { V int `json:"v"` // Input is the composition's input without its catalogue (and without each node's kept manifests): the cases give - // those. A Catalogue or Kept it holds is ignored. - Input compose.Input `json:"input"` + // those. A Catalogue or Kept it holds is ignored. It is the node-engine's composition input as JSON, which only the + // composer decodes (novox/hq ADR 0593 §7), so this shape imports no composition. + Input json.RawMessage `json:"input"` // Cases are composed one after the other over Input, each with its own manifests; their names are unique. Cases []Case `json:"cases"` // Machines are the nodes composed in every case; none means the manifests are only read. @@ -267,7 +268,13 @@ func runCase(r Request, c Case, depth string) (CaseVerdict, error) { if err != nil { return CaseVerdict{}, err } - in, err := frombus.Placed(r.Input, c.Placements) + var input compose.Input + if len(r.Input) > 0 { + if err := json.Unmarshal(r.Input, &input); err != nil { + return CaseVerdict{}, fmt.Errorf("composer: the request's input is not a composition input: %w", err) + } + } + in, err := frombus.Placed(input, c.Placements) if err != nil { return CaseVerdict{}, fmt.Errorf("composer: the case %q: %w", c.Name, err) } diff --git a/go/composer/composer_test.go b/go/composer/composer_test.go index e1ec9ea..82a3de0 100644 --- a/go/composer/composer_test.go +++ b/go/composer/composer_test.go @@ -64,7 +64,7 @@ func TestTheInputTravelsAsJSON(t *testing.T) { Holdings: []catalogue.Held{{Claim: "mesh-broker", Scope: "mesh", Node: "anchor", Module: "nats"}}, Issued: []compose.Issued{{Provision: "bus", Consumer: "laptop", ConsumerModule: "x", Provider: "anchor"}}, } - raw, err := json.Marshal(Request{V: Version, Input: in}) + raw, err := json.Marshal(Request{V: Version, Input: rawInput(in)}) if err != nil { t.Fatal(err) } @@ -72,8 +72,9 @@ func TestTheInputTravelsAsJSON(t *testing.T) { if err != nil { t.Fatal(err) } - if !reflect.DeepEqual(back.Input, in) { - t.Fatalf("the input came back as\n%+v\nwant\n%+v", back.Input, in) + var got compose.Input + if err := json.Unmarshal(back.Input, &got); err != nil || !reflect.DeepEqual(got, in) { + t.Fatalf("the input came back as\n%+v (%v)\nwant\n%+v", got, err, in) } } @@ -102,7 +103,7 @@ func TestARequestItCannotRunIsRefused(t *testing.T) { t.Error("an unknown depth runs") } // A case's own placement (issue 1126) composes over a node the input holds, and is refused for one it does not. - placed := Request{V: Version, Input: compose.Input{Nodes: []compose.Node{{Name: "a"}}}, Machines: []string{"a"}, Depth: DepthResolve, + placed := Request{V: Version, Input: rawInput(compose.Input{Nodes: []compose.Node{{Name: "a"}}}), Machines: []string{"a"}, Depth: DepthResolve, Cases: []Case{{Name: "trunk", Manifests: map[string]Manifest{"demo": {Raw: json.RawMessage(demo)}}}, {Name: "change", Manifests: map[string]Manifest{"demo": {Raw: json.RawMessage(demo)}}, Placements: map[string]*delivery.Placement{"a": {Node: "a", Modules: []delivery.PlacedModule{{Module: "ghost"}}}}}}} @@ -152,3 +153,12 @@ func TestTheComposerTakesTheVerdictsOfItsInput(t *testing.T) { t.Fatalf("xterm refused by its verdict: %+v, want refused in the verdict's words", got) } } + +// rawInput is a composition input as a request carries it. +func rawInput(in compose.Input) json.RawMessage { + raw, err := json.Marshal(in) + if err != nil { + panic(err) + } + return raw +} -- 2.54.0 From b56841596e805328aec273a54c1c17048bb2ed73 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 18:21:30 +0100 Subject: [PATCH 06/22] =?UTF-8?q?Delete=20the=20Python,=20Rust=20and=20C?= =?UTF-8?q?=20bindings,=20the=20Go=20example=20and=20the=20TypeScript=20se?= =?UTF-8?q?al=20and=20contracts=20exports,=20which=20nothing=20imports,=20?= =?UTF-8?q?in=20the=20release's=20one=20mesh-sdk=20change=20(ADR=200593=20?= =?UTF-8?q?=C2=A712)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- c/.gitignore | 1 - c/Makefile | 8 -- c/README.md | 9 -- c/example.c | 27 ------ c/mesh_sdk.c | 83 ---------------- c/mesh_sdk.h | 29 ------ go/README.md | 2 +- go/example/main.go | 29 ------ package-lock.json | 4 +- package-version.sum | 2 +- package.json | 6 +- python/README.md | 8 -- python/example.py | 13 --- python/mesh_sdk/__init__.py | 77 --------------- .../__pycache__/__init__.cpython-314.pyc | Bin 5871 -> 0 bytes python/pyproject.toml | 9 -- rust/.gitignore | 2 - rust/Cargo.toml | 8 -- rust/README.md | 8 -- rust/examples/greet.rs | 19 ---- rust/src/lib.rs | 80 ---------------- src/index.ts | 1 - src/seal/box.ts | 90 ------------------ src/seal/index.ts | 29 ------ test/seal.test.ts | 71 -------------- 25 files changed, 6 insertions(+), 609 deletions(-) delete mode 100644 c/.gitignore delete mode 100644 c/Makefile delete mode 100644 c/README.md delete mode 100644 c/example.c delete mode 100644 c/mesh_sdk.c delete mode 100644 c/mesh_sdk.h delete mode 100644 go/example/main.go delete mode 100644 python/README.md delete mode 100755 python/example.py delete mode 100644 python/mesh_sdk/__init__.py delete mode 100644 python/mesh_sdk/__pycache__/__init__.cpython-314.pyc delete mode 100644 python/pyproject.toml delete mode 100644 rust/.gitignore delete mode 100644 rust/Cargo.toml delete mode 100644 rust/README.md delete mode 100644 rust/examples/greet.rs delete mode 100644 rust/src/lib.rs delete mode 100644 src/seal/box.ts delete mode 100644 src/seal/index.ts delete mode 100644 test/seal.test.ts diff --git a/c/.gitignore b/c/.gitignore deleted file mode 100644 index 33a9488..0000000 --- a/c/.gitignore +++ /dev/null @@ -1 +0,0 @@ -example diff --git a/c/Makefile b/c/Makefile deleted file mode 100644 index acd63f1..0000000 --- a/c/Makefile +++ /dev/null @@ -1,8 +0,0 @@ -CFLAGS ?= -O2 -Wall -Wextra -JANSSON := $(shell pkg-config --cflags --libs jansson) - -example: example.c mesh_sdk.c mesh_sdk.h - $(CC) $(CFLAGS) -o $@ example.c mesh_sdk.c $(JANSSON) - -clean: - rm -f example diff --git a/c/README.md b/c/README.md deleted file mode 100644 index bad0829..0000000 --- a/c/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# mesh-sdk for C - -The skeleton (novox/hq ADR 0188): `mesh_serve(name, tools, count)` speaks MCP over stdio to the -node's tool runtime. A `mesh_tool` is a name, a description, an argument schema and a function -returning JSON. A tool named `.` is the seat's implementation. JSON is jansson's, the -one dependency. - -What it holds, under ADR 0039's test: the stdio loop and the tool type. Nothing else — no bus, no -client, nothing of any module's. `make` and pipe a `tools/list` request into `./example`. diff --git a/c/example.c b/c/example.c deleted file mode 100644 index 49a5773..0000000 --- a/c/example.c +++ /dev/null @@ -1,27 +0,0 @@ -/* A tools bundle in C: one tool of its own and one seat verb, served over stdio. */ -#include "mesh_sdk.h" -#include - -static json_t *greet(json_t *args, char **error) { - (void)error; - const char *who = json_string_value(json_object_get(args, "who")); - char greeting[128]; - snprintf(greeting, sizeof greeting, "hello %s", who ? who : "world"); - return json_pack("{s:s,s:s}", "greeting", greeting, "language", "c"); -} - -static json_t *on(json_t *args, char **error) { - (void)args; (void)error; - return json_pack("{s:b,s:s}", "on", 1, "language", "c"); -} - -int main(void) { - json_t *who = json_pack("{s:{s:s,s:s}}", "who", "type", "string", "description", "whom"); - const mesh_tool tools[] = { - {"greet", "say hello", who, greet}, - {"node-lamp.on", "the seat's verb", NULL, on}, - }; - int rc = mesh_serve("c-example", tools, 2); - json_decref(who); - return rc; -} diff --git a/c/mesh_sdk.c b/c/mesh_sdk.c deleted file mode 100644 index 7f3e000..0000000 --- a/c/mesh_sdk.c +++ /dev/null @@ -1,83 +0,0 @@ -#include "mesh_sdk.h" -#include -#include -#include - -static void say(json_t *m) { - json_object_set_new(m, "jsonrpc", json_string("2.0")); - char *text = json_dumps(m, JSON_COMPACT); - fprintf(stdout, "%s\n", text); - fflush(stdout); - free(text); - json_decref(m); -} - -static json_t *as_schema(json_t *given) { - if (!given || !json_is_object(given) || json_object_size(given) == 0) - return json_pack("{s:s,s:{}}", "type", "object", "properties"); - if (json_object_get(given, "type") || json_object_get(given, "properties")) - return json_incref(given); - return json_pack("{s:s,s:O}", "type", "object", "properties", given); -} - -int mesh_serve(const char *name, const mesh_tool *tools, size_t count) { - char *line = NULL; - size_t cap = 0; - ssize_t got; - while ((got = getline(&line, &cap, stdin)) >= 0) { - json_error_t err; - json_t *req = json_loads(line, 0, &err); - if (!req) continue; - json_t *id = json_object_get(req, "id"); - const char *method = json_string_value(json_object_get(req, "method")); - json_t *params = json_object_get(req, "params"); - if (!method) { json_decref(req); continue; } - if (strcmp(method, "initialize") == 0) { - say(json_pack("{s:O,s:{s:s,s:{s:{}},s:{s:s,s:s}}}", "id", id ? id : json_null(), "result", - "protocolVersion", MESH_SDK_PROTOCOL, "capabilities", "tools", - "serverInfo", "name", name, "version", "1")); - } else if (strcmp(method, "notifications/initialized") == 0) { - } else if (strcmp(method, "ping") == 0) { - if (id) say(json_pack("{s:O,s:{}}", "id", id, "result")); - } else if (strcmp(method, "tools/list") == 0) { - json_t *listed = json_array(); - for (size_t i = 0; i < count; i++) - json_array_append_new(listed, json_pack("{s:s,s:s,s:o}", "name", tools[i].name, - "description", tools[i].description, "inputSchema", as_schema(tools[i].input))); - say(json_pack("{s:O,s:{s:o}}", "id", id ? id : json_null(), "result", "tools", listed)); - } else if (strcmp(method, "tools/call") == 0) { - const char *wanted = params ? json_string_value(json_object_get(params, "name")) : NULL; - const mesh_tool *tool = NULL; - for (size_t i = 0; wanted && i < count; i++) - if (strcmp(tools[i].name, wanted) == 0) tool = &tools[i]; - if (!tool) { - char message[256]; - snprintf(message, sizeof message, "%s has no tool %s", name, wanted ? wanted : ""); - say(json_pack("{s:O,s:{s:i,s:s}}", "id", id ? id : json_null(), "error", "code", -32602, "message", message)); - } else { - json_t *args = params ? json_object_get(params, "arguments") : NULL; - json_t *empty = args ? NULL : json_object(); - char *error = NULL; - json_t *result = tool->run(args ? args : empty, &error); - if (empty) json_decref(empty); - if (result) { - char *text = json_dumps(result, JSON_COMPACT); - say(json_pack("{s:O,s:{s:[{s:s,s:s}]}}", "id", id ? id : json_null(), "result", "content", "type", "text", "text", text)); - free(text); - json_decref(result); - } else { - say(json_pack("{s:O,s:{s:[{s:s,s:s}],s:b}}", "id", id ? id : json_null(), "result", "content", - "type", "text", "text", error ? error : "the tool failed", "isError", 1)); - free(error); - } - } - } else if (id) { - char message[256]; - snprintf(message, sizeof message, "no %s", method); - say(json_pack("{s:O,s:{s:i,s:s}}", "id", id, "error", "code", -32601, "message", message)); - } - json_decref(req); - } - free(line); - return ferror(stdin) ? -1 : 0; -} diff --git a/c/mesh_sdk.h b/c/mesh_sdk.h deleted file mode 100644 index 47f92ef..0000000 --- a/c/mesh_sdk.h +++ /dev/null @@ -1,29 +0,0 @@ -/* The Novox Mesh SDK for C — the skeleton (novox/hq ADR 0188). - * - * A tools bundle is a process the node's runtime launches and speaks MCP over stdio to: - * `initialize`, `tools/list` once, `tools/call` per call, newline-framed JSON-RPC on stdin and - * stdout. This is the loop, so a bundle writes its tools and nothing else. A tool named - * `.` is the module's implementation of that seat's verb; any other name is the - * module's own. JSON is jansson's. - * - * No transport here: the bus, the subjects and the memberships are the runtime's (ADR 0039, 0175). - * stdout is the protocol; log to stderr. */ -#ifndef MESH_SDK_H -#define MESH_SDK_H -#include - -#define MESH_SDK_PROTOCOL "2025-03-26" - -/* One tool. `run` returns a new JSON value (the answer) or NULL with *error set to a message the - * caller frees. `input` is the schema of the arguments, or NULL for a tool that takes nothing. */ -typedef struct { - const char *name; - const char *description; - json_t *input; - json_t *(*run)(json_t *args, char **error); -} mesh_tool; - -/* Serve these tools on stdin/stdout until stdin closes. Returns 0, or -1 on an I/O fault. */ -int mesh_serve(const char *name, const mesh_tool *tools, size_t count); - -#endif diff --git a/go/README.md b/go/README.md index 58dcfa4..f554fca 100644 --- a/go/README.md +++ b/go/README.md @@ -16,6 +16,6 @@ at the same version as the TypeScript package. What it holds, under ADR 0039's test: the stdio loop and the tool type, and `cli`, the local protocol `mesh-cli` speaks to the node-engine on its machine (novox/hq ADR 0272): the socket's path and one request and one -answer; and `seal`, the mesh's sealed box (novox/hq ADR 0206: X25519, HKDF-SHA256, AES-256-GCM, `{v, eph, iv, tag, ct}`), byte for byte the box the licence manager made before it lived here and the TypeScript `@novox/mesh-sdk/seal` makes. Nothing else — no bus, no client, nothing of any module's. `go build ./example` and pipe a `tools/list` request into it. +answer; and `seal`, the mesh's sealed box (novox/hq ADR 0206: X25519, HKDF-SHA256, AES-256-GCM, `{v, eph, iv, tag, ct}`), byte for byte the box the licence manager made before it lived here. Nothing else — no bus, no client, nothing of any module's. From release 4a of design 47 (novox/hq ADR 0453 clause 3, ADR 0446 clause 4) it also holds the composition of a machine's declaration, over a plain input and with nothing behind it: `declaration` and `validate`, the node-engine's parser and its validator, copied from mesh-host; `catalogue`, the manifests, their resolution and rendering, and `overlay`, the private network's generator, copied from mesh-controller; `compose`, the controller's planFor, renderingFor and declarationWith as functions of an `Input` (the composition never allocates, makes, keeps or writes: what a send would do it names as foreseen or returns); `sdkversion`, which mesh-sdk version a program was built with; and, in `delivery`, the layouts of the keys the builder, the checker and the indexer write (the source fingerprint's new form, the builds, the verdicts and kept verdicts, the manifests by digest). None of them holds a bus, a store or a vault client, which `go list -deps` of `compose` shows. diff --git a/go/example/main.go b/go/example/main.go deleted file mode 100644 index 283854e..0000000 --- a/go/example/main.go +++ /dev/null @@ -1,29 +0,0 @@ -// A tools bundle in Go: one tool of its own and one seat verb, served over stdio. -package main - -import ( - "fmt" - "os" - - stdio "git.novox.be/novox/mesh-sdk/go" -) - -func main() { - err := stdio.Serve("go-example", []stdio.Tool{ - {Name: "greet", Description: "say hello", Input: map[string]any{"who": map[string]any{"type": "string", "description": "whom"}}, - Run: func(a map[string]any) (any, error) { - who, _ := a["who"].(string) - if who == "" { - who = "world" - } - return map[string]any{"greeting": "hello " + who, "language": "go"}, nil - }}, - {Name: "node-lamp.on", Description: "the seat's verb", Run: func(map[string]any) (any, error) { - return map[string]any{"on": true, "language": "go"}, nil - }}, - }) - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} diff --git a/package-lock.json b/package-lock.json index acf4da6..4110505 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@novox/mesh-sdk", - "version": "0.1.17", + "version": "0.1.18", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@novox/mesh-sdk", - "version": "0.1.17", + "version": "0.1.18", "devDependencies": { "@types/node": "^22.0.0", "typescript": "^5.6.0" diff --git a/package-version.sum b/package-version.sum index 6925adf..c41176c 100644 --- a/package-version.sum +++ b/package-version.sum @@ -1 +1 @@ -0.1.17 90cca0bf1acfb386d66ccddb9f612703cf8f0205791b846982a524d0044aa0fb +0.1.18 5cbc2d043e45ab47eadff55a61f8be7d50144cec3cf295333b5ea1b4c181cfdf diff --git a/package.json b/package.json index cd0af54..8822ad8 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,10 @@ { "name": "@novox/mesh-sdk", - "version": "0.1.17", + "version": "0.1.18", "description": "The stable spine a Novox Mesh module's own code builds against.", "type": "module", "exports": { ".": "./dist/index.js", - "./contracts": "./dist/contracts/index.js", "./provider-loop": "./dist/provider-loop/index.js", "./provisioner": "./dist/provider-loop/index.js", "./tools": "./dist/tools/index.js", @@ -13,8 +12,7 @@ "./events": "./dist/events/index.js", "./primitives": "./dist/primitives/index.js", "./stdio": "./dist/stdio/index.js", - "./state": "./dist/state/index.js", - "./seal": "./dist/seal/index.js" + "./state": "./dist/state/index.js" }, "files": [ "dist" diff --git a/python/README.md b/python/README.md deleted file mode 100644 index 8bb2fac..0000000 --- a/python/README.md +++ /dev/null @@ -1,8 +0,0 @@ -# mesh-sdk for Python - -The skeleton (novox/hq ADR 0188): `mesh_sdk.serve(name, tools)` speaks MCP over stdio to the node's -tool runtime. A `Tool` is a name, a description, an argument schema and a function. A tool named -`.` is the seat's implementation. No dependencies beyond the standard library. - -What it holds, under ADR 0039's test: the stdio loop and the tool type. Nothing else — no bus, no -client, nothing of any module's. Run `./example.py` and type a `tools/list` request to see it answer. diff --git a/python/example.py b/python/example.py deleted file mode 100755 index ab483c3..0000000 --- a/python/example.py +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env python3 -# A tools bundle in Python: one tool of its own and one seat verb, served over stdio. -import os -import sys - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from mesh_sdk import Tool, serve # noqa: E402 - -serve("python-example", [ - Tool("greet", "say hello", lambda a: {"greeting": f"hello {a.get('who', 'world')}", "language": "python"}, - {"who": {"type": "string", "description": "whom"}}), - Tool("node-lamp.on", "the seat's verb", lambda a: {"on": True, "language": "python"}), -]) diff --git a/python/mesh_sdk/__init__.py b/python/mesh_sdk/__init__.py deleted file mode 100644 index ec44341..0000000 --- a/python/mesh_sdk/__init__.py +++ /dev/null @@ -1,77 +0,0 @@ -"""The Novox Mesh SDK for Python — the skeleton (novox/hq ADR 0188). - -A tools bundle is a process the node's runtime launches and speaks MCP over stdio to: `initialize`, -`tools/list` once, `tools/call` per call, newline-framed JSON-RPC on stdin and stdout. This module is -the loop, so a bundle writes its tools and nothing else. A tool named `.` is the -module's implementation of that seat's verb; any other name is the module's own. - -No transport here: the bus, the subjects and the memberships are the runtime's (ADR 0039, 0175). -stdout is the protocol; log to stderr. -""" -import json -import sys -from dataclasses import dataclass, field -from typing import Any, Callable, Dict, List - -PROTOCOL = "2025-03-26" - - -@dataclass -class Tool: - name: str - description: str - run: Callable[[Dict[str, Any]], Any] - # The JSON schema of the arguments, or the bare property map the catalogue's modules write. - input: Dict[str, Any] = field(default_factory=dict) - - -def as_schema(given: Dict[str, Any]) -> Dict[str, Any]: - if not given: - return {"type": "object", "properties": {}} - if given.get("type") == "object" or "properties" in given: - return given - return {"type": "object", "properties": given} - - -def serve(name: str, tools: List[Tool]) -> None: - """Serve these tools to the runtime over stdio until stdin closes.""" - by_name = {t.name: t for t in tools} - out = sys.stdout - - def say(message: Dict[str, Any]) -> None: - out.write(json.dumps(message) + "\n") - out.flush() - - for line in sys.stdin: - line = line.strip() - if not line: - continue - try: - request = json.loads(line) - except ValueError: - continue - rid = request.get("id") - method = request.get("method") - params = request.get("params") or {} - if method == "initialize": - say({"jsonrpc": "2.0", "id": rid, "result": {"protocolVersion": PROTOCOL, "capabilities": {"tools": {}}, "serverInfo": {"name": name, "version": "1"}}}) - elif method == "notifications/initialized": - pass - elif method == "ping": - if rid is not None: - say({"jsonrpc": "2.0", "id": rid, "result": {}}) - elif method == "tools/list": - say({"jsonrpc": "2.0", "id": rid, "result": {"tools": [ - {"name": t.name, "description": t.description, "inputSchema": as_schema(t.input)} for t in tools]}}) - elif method == "tools/call": - tool = by_name.get(str(params.get("name", ""))) - if tool is None: - say({"jsonrpc": "2.0", "id": rid, "error": {"code": -32602, "message": f"{name} has no tool {params.get('name')}"}}) - continue - try: - result = tool.run(params.get("arguments") or {}) - say({"jsonrpc": "2.0", "id": rid, "result": {"content": [{"type": "text", "text": json.dumps(result)}]}}) - except Exception as err: # the tool failed: a tool error, not a protocol one - say({"jsonrpc": "2.0", "id": rid, "result": {"content": [{"type": "text", "text": str(err)}], "isError": True}}) - elif rid is not None: - say({"jsonrpc": "2.0", "id": rid, "error": {"code": -32601, "message": f"no {method}"}}) diff --git a/python/mesh_sdk/__pycache__/__init__.cpython-314.pyc b/python/mesh_sdk/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 0956f1f7e9fea1c818dac7f6dc731f5957d51843..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5871 zcmb6-TTC3;mA4-K?uKq&#t()vwlR2tcCaTlhS=lSIAAgiQ*>A+!7i%13n)|F)vl_> z22CJ!2;9XB1 zQgY6%ZfF=IC0DBZJonu5y!V{?i;75)K&k)D53c-gn2`U*L8*9(o%PqDGfzZfk;_D6 z`WcI%Eo-sRvi)2iZ}EK|%hQPKT)(%^XZia4mcK7x1^R+ku#t$o6}rUNlKn*V>>{G~ zIP2m*UOd7Ii$0k5?;={%iiiQ|2Wh`TWDbzlQ2qjJNK7fhpgyBt7hYD(DPj2hfH0vO z!qBWWrDuizntxlc0K%MBG{u66mMp@?r>+WT&x^v*PnYOs(796KX#5L8l#sxi_Qrd*#R7%#gaUlnLvDYSKm1~-sRoW*EIisY7 zi^H!Bwu?jOU=u~2b&s{udfw^~5>r5JMo;G{-9cnl)Ad}NVCuj`k)dmbY5}>b1?*5p zk?^c;O{v*QLD5X5LvTePWa*LPJ*FaCr#pIpq2%dt6bK*=y2pSZ)J#rOGD_BxEfuKJ zCtz8&1l$CxxTzb6oE6{%;0kdTQ4}c!ApKev1V0FpGUTk8(+x`iSmhLzcQS9b(TjcuWC8spmG#$@`s%Ys}-r>$>XC41JP>Y<@6o)^rrYwj5Ea-49 z*m<<`#rC7e+B-j)DjFYu0=?@RMNG?c;a3yTgnAcTU??P4~~)Rfm5XXnDl9 ztOXDvOv2II-0k57|JP4H;!k`Sfys41OTI@2TZ4{Yl2CR)LQs-i4}?AtcQr3-#g$4) znoteX!fPX|OOk=s;P5CdBZ}Qv8Q~Lfc>#u6kR$qUN|G(hP?LGMA&}29Nv9M|lcZM0 zpsb>`y=2rtZ3IdcU8si<$Y7oTD-BH#4leIIG2i^s zjKPXq#!%g~jGV&#qO_c+)3VzA_zAY)WP z-$dSn^PRGPOn!d2)+YOVyTJ=cMUhGSfKPK*iRX6ri?oQ*_2H1 zw2W*Z*OdL2pfgWC2t`+-b&LLcZ4X$x@|f*Ew&Dwab@t8Kg}Q>Tez9-acW^2E(6ij~ zsg=r_8{w63)k5E*w$y8fI&HplQz#|&Hmu_!MgQ^JtlT<4_s_A9@;glAL_W^pT|{+m zL;$Pfp%0;P0Q!Sq3{>MFa@V$j`yJJO5P&w+-pVM<8o~N)Zk4w0p8symw=TNq8vXR6|E|Bnx zUUZf8exy7B&#~we{UVp}i2*Sfvy3x||Y>tgH<@EM419g# zlk|Upq}N~gCr&z%`oj2Rl^n^4ol1!8tg14@}#S;mFAnO01YQkrfmX2+J0120Gn zSJwZOM`iPx+&SbB!LZ;_;oU&O2Qj57Pb%()sBDFO zW)oBxRIr|!HLS{m3K%Z&bawOI1aMj~e5NeULkIJKVdi-u!zOdjdo|#e-V7;78%69^ zC2ck$UVJOJmD1|~nLPu7VI2nYD&FzX?9<_|m=N@P()mmd6D%!nPPMW|9B?=sq-Zpz zZ=QEU4bGHjovLkOf;lRkX4vx4JX!IFZUt@z-adHe$TyEH{$-)+&=UXCsFjNfauJa%wtM!I=+;i zwV-F9q#)8$u3L^zHG2_5Vvd6?>fg-&;ScJ2P_@Qf$~kk^bi8i1?0E1W2IvY>GBszQ zS{;w3%W06?h^*xmipfA_IQ}8=mBcIOUimELfCh#%<8T8i2xN%yBEkiFucs86_qA5g z6I}^1Kv{B@COF1PgyS)VaeRhyHLsW!o@ohDyyG_YgSnC(-JK*Y0`lSxdtY zS{{Ddt~v9c(U;aZ5^ekifW5vPIk1JOhr6Ck=hivq9AhxhvnM7O>K0?)uDe@zui*jn zkCDHRJpAI1ReSWc$D`7tQK>LEUWm$%rgLlVmQ})ZnGDo1i+M(vNS-O3vQoY0PWSEZ zcg=gBeDBo#Q%e&Ml7$0Zg~n5b>h2roLGW+AUQX)ahZlZ$;r)L5^Ox<{BwNz#jQKco z?NR2Mef>*?%p1UZhB=E87$qlwQ_})~~XBw95|1q;ExSK&_s!Tn z*X)|>kE63-0Tr>YOXYH#eCYk5_x*tVxltr{ble`6?Gs5mntB{gub*W|Y|n2#tR$7K zA3fbeqI;eaCQ$n`ur3bT-+#D=O8cXyHKe8imVoLV$x`eIwUE4G_fOk>nw`C7&wkPF z`I24p#xr31EW1wY-vM4g@>>&q>aT12j`RO~sIISz{g?Wa{o%TQ?QZIivH$kMQK;YV z3iSs(Kk_qJ$3hqVo*(TKE^?lK=RDAFt#o{nl-A)#0=*xMLBI#UcS?WI;D*F>$NdlC zgl_SMarl2xNX(x_F=?Zx{1F!e BHr@aL diff --git a/python/pyproject.toml b/python/pyproject.toml deleted file mode 100644 index a815345..0000000 --- a/python/pyproject.toml +++ /dev/null @@ -1,9 +0,0 @@ -[project] -name = "novox-mesh-sdk" -version = "0.1.0" -description = "The Novox Mesh SDK for Python: a tools bundle over stdio (novox/hq ADR 0188)" -requires-python = ">=3.9" - -[build-system] -requires = ["setuptools"] -build-backend = "setuptools.build_meta" diff --git a/rust/.gitignore b/rust/.gitignore deleted file mode 100644 index 2c96eb1..0000000 --- a/rust/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -target/ -Cargo.lock diff --git a/rust/Cargo.toml b/rust/Cargo.toml deleted file mode 100644 index 1f13ffe..0000000 --- a/rust/Cargo.toml +++ /dev/null @@ -1,8 +0,0 @@ -[package] -name = "mesh_sdk" -version = "0.1.0" -edition = "2021" -description = "The Novox Mesh SDK for Rust: a tools bundle over stdio (novox/hq ADR 0188)" - -[dependencies] -serde_json = "1" diff --git a/rust/README.md b/rust/README.md deleted file mode 100644 index 7e82995..0000000 --- a/rust/README.md +++ /dev/null @@ -1,8 +0,0 @@ -# mesh-sdk for Rust - -The skeleton (novox/hq ADR 0188): `mesh_sdk::serve(name, &tools)` speaks MCP over stdio to the -node's tool runtime. A `Tool` is a name, a description, an argument schema and a function. A tool -named `.` is the seat's implementation. One dependency, `serde_json`. - -What it holds, under ADR 0039's test: the stdio loop and the tool type. Nothing else — no bus, no -client, nothing of any module's. `cargo build --example greet` and pipe a `tools/list` request in. diff --git a/rust/examples/greet.rs b/rust/examples/greet.rs deleted file mode 100644 index bc8d1a6..0000000 --- a/rust/examples/greet.rs +++ /dev/null @@ -1,19 +0,0 @@ -// A tools bundle in Rust: one tool of its own and one seat verb, served over stdio. -use mesh_sdk::{serve, Tool}; -use serde_json::{json, Map, Value}; - -fn greet(a: &Map) -> Result { - let who = a.get("who").and_then(Value::as_str).unwrap_or("world"); - Ok(json!({"greeting": format!("hello {}", who), "language": "rust"})) -} - -fn on(_: &Map) -> Result { - Ok(json!({"on": true, "language": "rust"})) -} - -fn main() -> std::io::Result<()> { - serve("rust-example", &[ - Tool { name: "greet", description: "say hello", input: json!({"who": {"type": "string", "description": "whom"}}), run: greet }, - Tool { name: "node-lamp.on", description: "the seat's verb", input: json!({}), run: on }, - ]) -} diff --git a/rust/src/lib.rs b/rust/src/lib.rs deleted file mode 100644 index f97b1b1..0000000 --- a/rust/src/lib.rs +++ /dev/null @@ -1,80 +0,0 @@ -//! The Novox Mesh SDK for Rust — the skeleton (novox/hq ADR 0188). -//! -//! A tools bundle is a process the node's runtime launches and speaks MCP over stdio to: -//! `initialize`, `tools/list` once, `tools/call` per call, newline-framed JSON-RPC on stdin and -//! stdout. This crate is the loop, so a bundle writes its tools and nothing else. A tool named -//! `.` is the module's implementation of that seat's verb; any other name is the -//! module's own. -//! -//! No transport here: the bus, the subjects and the memberships are the runtime's (ADR 0039, -//! 0175). stdout is the protocol; log to stderr. -use serde_json::{json, Map, Value}; -use std::io::{self, BufRead, Write}; - -/// The MCP version this speaks; the runtime says the same. -pub const PROTOCOL: &str = "2025-03-26"; - -/// One tool a bundle serves. `input` is the JSON schema of the arguments, or the bare property -/// map; `run` answers with any JSON or an error message. -pub struct Tool { - pub name: &'static str, - pub description: &'static str, - pub input: Value, - pub run: fn(&Map) -> Result, -} - -fn as_schema(given: &Value) -> Value { - match given.as_object() { - None => json!({"type": "object", "properties": {}}), - Some(m) if m.is_empty() => json!({"type": "object", "properties": {}}), - Some(m) if m.contains_key("type") || m.contains_key("properties") => given.clone(), - Some(_) => json!({"type": "object", "properties": given}), - } -} - -/// Serve these tools to the runtime over stdio until stdin closes. -pub fn serve(name: &str, tools: &[Tool]) -> io::Result<()> { - let stdin = io::stdin(); - let mut out = io::stdout(); - let mut say = |m: Value| -> io::Result<()> { - let mut m = m; - m["jsonrpc"] = json!("2.0"); - writeln!(out, "{}", m)?; - out.flush() - }; - for line in stdin.lock().lines() { - let line = line?; - let req: Value = match serde_json::from_str(line.trim()) { - Ok(v) => v, - Err(_) => continue, - }; - let id = req.get("id").cloned().unwrap_or(Value::Null); - let method = req.get("method").and_then(Value::as_str).unwrap_or(""); - let params = req.get("params").cloned().unwrap_or(json!({})); - match method { - "initialize" => say(json!({"id": id, "result": {"protocolVersion": PROTOCOL, - "capabilities": {"tools": {}}, "serverInfo": {"name": name, "version": "1"}}}))?, - "notifications/initialized" => {} - "ping" => { if !id.is_null() { say(json!({"id": id, "result": {}}))? } } - "tools/list" => { - let listed: Vec = tools.iter().map(|t| json!({ - "name": t.name, "description": t.description, "inputSchema": as_schema(&t.input)})).collect(); - say(json!({"id": id, "result": {"tools": listed}}))? - } - "tools/call" => { - let wanted = params.get("name").and_then(Value::as_str).unwrap_or(""); - let empty = Map::new(); - let args = params.get("arguments").and_then(Value::as_object).unwrap_or(&empty); - match tools.iter().find(|t| t.name == wanted) { - None => say(json!({"id": id, "error": {"code": -32602, "message": format!("{} has no tool {}", name, wanted)}}))?, - Some(t) => match (t.run)(args) { - Ok(v) => say(json!({"id": id, "result": {"content": [{"type": "text", "text": v.to_string()}]}}))?, - Err(e) => say(json!({"id": id, "result": {"content": [{"type": "text", "text": e}], "isError": true}}))?, - }, - } - } - _ => { if !id.is_null() { say(json!({"id": id, "error": {"code": -32601, "message": format!("no {}", method)}}))? } } - } - } - Ok(()) -} diff --git a/src/index.ts b/src/index.ts index 57175b8..0f28891 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,4 +13,3 @@ export * as messaging from "./messaging/index.js"; export * as events from "./events/index.js"; export * as primitives from "./primitives/index.js"; export * as state from "./state/index.js"; -export * as seal from "./seal/index.js"; diff --git a/src/seal/box.ts b/src/seal/box.ts deleted file mode 100644 index 30ea274..0000000 --- a/src/seal/box.ts +++ /dev/null @@ -1,90 +0,0 @@ -// The sealed box itself (novox/hq ADR 0183, ADR 0206), with its two sources of randomness as arguments so -// the tests can prove it byte for byte against the Go package's vectors. Modules import `./index.js`. - -import { - createCipheriv, - createDecipheriv, - createPrivateKey, - createPublicKey, - diffieHellman, - hkdfSync, - type KeyObject, -} from "node:crypto"; - -/** A value sealed to one recipient, as it travels in JSON; every field but `v` is standard base64. */ -export interface SealedBox { - readonly v: number; - /** The one-time public key, SPKI DER. */ - readonly eph: string; - readonly iv: string; - readonly tag: string; - readonly ct: string; -} - -/** The only box version `open` opens. */ -export const VERSION = 1; - -const INFO = "novox-mesh sealed box v1"; -const IV_BYTES = 12; -const TAG_BYTES = 16; - -function x25519(key: KeyObject, what: string): KeyObject { - if (key.asymmetricKeyType !== "x25519") throw new Error(`not an X25519 ${what} key`); - return key; -} - -export function publicFromPem(pem: string): KeyObject { - return x25519(createPublicKey({ key: pem, format: "pem" }), "public"); -} - -export function privateFromPem(pem: string): KeyObject { - return x25519(createPrivateKey({ key: pem, format: "pem" }), "private"); -} - -/** The recipient's raw 32-byte public key: the last 32 bytes of its SPKI DER. */ -function raw(pub: KeyObject): Buffer { - const der = pub.export({ format: "der", type: "spki" }); - return der.subarray(der.length - 32); -} - -function boxKey(secret: Buffer, ephDer: Buffer, recipientRaw: Buffer): Buffer { - return Buffer.from(hkdfSync("sha256", secret, Buffer.concat([ephDer, recipientRaw]), INFO, 32)); -} - -/** `seal` with the one-time private key and the iv given. */ -export function sealWith(plaintext: string, recipientPem: string, eph: KeyObject, iv: Buffer): SealedBox { - const recipient = publicFromPem(recipientPem); - x25519(eph, "one-time"); - if (iv.length !== IV_BYTES) throw new Error(`an iv of ${iv.length} bytes, not ${IV_BYTES}`); - const secret = diffieHellman({ privateKey: eph, publicKey: recipient }); - const ephDer = createPublicKey(eph).export({ format: "der", type: "spki" }); - const cipher = createCipheriv("aes-256-gcm", boxKey(secret, ephDer, raw(recipient)), iv); - const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); - return { - v: VERSION, - eph: ephDer.toString("base64"), - iv: iv.toString("base64"), - tag: cipher.getAuthTag().toString("base64"), - ct: ct.toString("base64"), - }; -} - -/** Open a box with the recipient's PEM private key; throws for a box to another key, changed, or of another version. */ -export function open(box: SealedBox, privatePem: string): string { - if (box.v !== VERSION) throw new Error(`a sealed box of version ${box.v}, not ${VERSION}`); - const priv = privateFromPem(privatePem); - const ephDer = Buffer.from(box.eph, "base64"); - const eph = x25519(createPublicKey({ key: ephDer, format: "der", type: "spki" }), "one-time"); - const iv = Buffer.from(box.iv, "base64"); - const tag = Buffer.from(box.tag, "base64"); - // node accepts a shorter tag than 16 bytes; the box never has one, so a short one is refused here. - if (iv.length !== IV_BYTES || tag.length !== TAG_BYTES) throw new Error("the box does not open with this key"); - const secret = diffieHellman({ privateKey: priv, publicKey: eph }); - const decipher = createDecipheriv("aes-256-gcm", boxKey(secret, ephDer, raw(createPublicKey(priv))), iv); - decipher.setAuthTag(tag); - try { - return Buffer.concat([decipher.update(Buffer.from(box.ct, "base64")), decipher.final()]).toString("utf8"); - } catch { - throw new Error("the box does not open with this key"); - } -} diff --git a/src/seal/index.ts b/src/seal/index.ts deleted file mode 100644 index d3b1751..0000000 --- a/src/seal/index.ts +++ /dev/null @@ -1,29 +0,0 @@ -// Sealing to one recipient (novox/hq ADR 0183, ADR 0206): X25519 for the agreement, HKDF-SHA256 for the -// key, AES-256-GCM for the box. The TypeScript twin of the Go package `go/seal`, byte for byte: the same -// fixtures prove both. A box is `{ v: 1, eph, iv, tag, ct }`; keys travel as PEM (SPKI public, PKCS #8 -// private). - -import { generateKeyPairSync, randomBytes } from "node:crypto"; -import { sealWith, open, VERSION, type SealedBox } from "./box.js"; - -export { open, VERSION, type SealedBox }; - -/** A recipient's keypair as the two PEM strings it is kept and sent as. */ -export interface KeyPair { - readonly publicKey: string; - readonly privateKey: string; -} - -/** A fresh X25519 keypair, PEM-encoded. */ -export function generateKeyPair(): KeyPair { - const { publicKey, privateKey } = generateKeyPairSync("x25519"); - return { - publicKey: publicKey.export({ format: "pem", type: "spki" }) as string, - privateKey: privateKey.export({ format: "pem", type: "pkcs8" }) as string, - }; -} - -/** Seal plaintext to the recipient's PEM public key, with a fresh one-time key and iv. */ -export function seal(plaintext: string, recipientPem: string): SealedBox { - return sealWith(plaintext, recipientPem, generateKeyPairSync("x25519").privateKey, randomBytes(12)); -} diff --git a/test/seal.test.ts b/test/seal.test.ts deleted file mode 100644 index 9eb840b..0000000 --- a/test/seal.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import { createPrivateKey } from "node:crypto"; - -import { seal, open, generateKeyPair, type SealedBox } from "../dist/seal/index.js"; -import { sealWith } from "../dist/seal/box.js"; - -// The fixtures the Go package proves itself against: boxes the licence manager's own seal.go made, and -// the one the agent module's TypeScript made. One format, three implementations. -const fixtures = new URL("../go/seal/testdata/", import.meta.url); -const lm = JSON.parse(readFileSync(new URL("sealed-by-licence-manager.json", fixtures), "utf8")) as { - recipient: { publicKey: string; privateKey: string }; - sealed: { plaintext: string; box: SealedBox }[]; - vectors: { plaintext: string; ephPrivate: string; iv: string; box: SealedBox }[]; -}; - -// PKCS #8 for an X25519 private key is this prefix and the 32 raw bytes. -const pkcs8 = (raw: Buffer) => - createPrivateKey({ key: Buffer.concat([Buffer.from("302e020100300506032b656e04220420", "hex"), raw]), format: "der", type: "pkcs8" }); - -test("seal: every box the licence manager sealed opens to its plaintext", () => { - assert.ok(lm.sealed.length > 0); - for (const s of lm.sealed) assert.equal(open(s.box, lm.recipient.privateKey), s.plaintext); -}); - -test("seal: given the same one-time key and iv, the box is byte for byte the licence manager's", () => { - assert.ok(lm.vectors.length > 0); - for (const v of lm.vectors) { - const box = sealWith(v.plaintext, lm.recipient.publicKey, pkcs8(Buffer.from(v.ephPrivate, "base64")), Buffer.from(v.iv, "base64")); - assert.deepEqual(box, v.box); - assert.equal(open(box, lm.recipient.privateKey), v.plaintext); - } -}); - -test("seal: the agent module's TypeScript box opens", () => { - const f = JSON.parse(readFileSync(new URL("sealed-by-typescript.json", fixtures), "utf8")); - assert.equal(open(f.box, f.privateKey), f.plaintext); -}); - -test("seal: a box opens for its recipient only, and two boxes share no one-time key or iv", () => { - const a = generateKeyPair(); - const b = generateKeyPair(); - const box = seal("a token", a.publicKey); - assert.equal(open(box, a.privateKey), "a token"); - assert.throws(() => open(box, b.privateKey)); - const again = seal("a token", a.publicKey); - assert.notEqual(again.eph, box.eph); - assert.notEqual(again.iv, box.iv); -}); - -test("seal: a box changed in any field, or of another version, does not open", () => { - const good = lm.sealed[1].box; - const flip = (s: string) => { - const b = Buffer.from(s, "base64"); - b[b.length - 1] ^= 1; - return b.toString("base64"); - }; - for (const box of [ - { ...good, ct: flip(good.ct) }, - { ...good, tag: flip(good.tag) }, - { ...good, iv: flip(good.iv) }, - { ...good, eph: flip(good.eph) }, - { ...good, v: 2 }, - ]) assert.throws(() => open(box as SealedBox, lm.recipient.privateKey)); -}); - -test("seal: a key that is not X25519 PEM is refused", () => { - assert.throws(() => seal("x", "not a key")); - assert.throws(() => open(lm.sealed[0].box, lm.recipient.publicKey)); -}); -- 2.54.0 From 8dc610c628e51cb3103cc41e3aba0df1d2d39b67 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 18:23:27 +0100 Subject: [PATCH 07/22] =?UTF-8?q?process:=20read=20a=20state=20its=20owner?= =?UTF-8?q?=20keeps=20one=20bucket=20per=20node=20as=20one=20state,=20so?= =?UTF-8?q?=20the=20provisioner,=20the=20assigner=20and=20the=20view=20rea?= =?UTF-8?q?d=20every=20node's=20compositions=20(ADR=200593=20=C2=A76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/process/pernode.go | 190 +++++++++++++++++++++++++ go/process/pernode_integration_test.go | 88 ++++++++++++ go/process/state.go | 25 +++- 3 files changed, 296 insertions(+), 7 deletions(-) create mode 100644 go/process/pernode.go create mode 100644 go/process/pernode_integration_test.go diff --git a/go/process/pernode.go b/go/process/pernode.go new file mode 100644 index 0000000..9494dd6 --- /dev/null +++ b/go/process/pernode.go @@ -0,0 +1,190 @@ +package process + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + "sync" + + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// **A state its owner keeps one bucket per node is read as one state** (novox/hq ADR 0593 §6: each node's engine +// writes its own `composed-`, and the provisioner, the assigner and the view read them all). A reader names it as +// its manifest's `reads` does (`.`, such as `mesh-host.composed`) with the prefix of its buckets +// (`composed-`): each bucket `` holds keys whose first part is that node, so a key names its bucket. +// Get reads one key from its node's bucket; Keys and Watch go over every bucket of the family the bus holds, and Watch +// takes in a node's bucket created after it began, as the bus says it (its creation advisory), so a node added later is +// heard with no timer. Reads only: the owner's nodes write, as their grants allow. + +// What a reader of a per-node state is granted beside each bucket's read (the grants part composes them): the listing +// of bucket names (FamilyListSubject) and the creation of any stream (FamilyCreatedAdvisory), which says a name only. +const ( + FamilyListSubject = "$JS.API.STREAM.NAMES" + FamilyCreatedAdvisory = "$JS.EVENT.ADVISORY.STREAM.CREATED.*" +) + +// NodeFamily is a per-node state read across every node's bucket. +type NodeFamily struct { + Read string + Prefix string + c *Conn +} + +// ReadPerNode is another module's state kept one bucket per node, named read in the reader's manifest, its buckets +// prefix followed by the node's name. +func (c *Conn) ReadPerNode(read, prefix string) NodeFamily { + return NodeFamily{Read: read, Prefix: prefix, c: c} +} + +// checked refuses a family with no read name or prefix. +func (f NodeFamily) checked() error { + module, name, ok := strings.Cut(f.Read, ".") + if !ok || module == "" || name == "" || strings.Contains(name, ".") { + return fmt.Errorf("%q is not a state another module keeps, .", f.Read) + } + if f.Prefix == "" || strings.ContainsAny(f.Prefix, ".*> ") { + return fmt.Errorf("the state %s names no prefix of its buckets, or one a bucket name cannot start with: %q", f.Read, f.Prefix) + } + return nil +} + +// Node is the state of one node of the family: its bucket ``, bound as Read binds a bucket. +func (f NodeFamily) Node(node string) State { + module, name, _ := strings.Cut(f.Read, ".") + return f.c.bound(module, name, f.Prefix+node, func() error { + if err := f.checked(); err != nil { + return err + } + if node == "" || strings.ContainsAny(node, ".*> ") { + return fmt.Errorf("the state %s: %q is not a node a bucket is named for", f.Read, node) + } + return nil + }) +} + +// nodeOf is the node a key of the family names: its first part. +func nodeOf(key string) (string, error) { + node, _, ok := strings.Cut(key, ".") + if !ok || node == "" { + return "", fmt.Errorf("%q names no node: a key of a per-node state starts .", key) + } + return node, nil +} + +// Nodes are the nodes whose bucket of the family the bus holds now, sorted: the names of the key-value buckets that +// start with the prefix, the prefix taken off. +func (f NodeFamily) Nodes() ([]string, error) { + if err := f.checked(); err != nil { + return nil, err + } + ctx, cancel := context.WithTimeout(context.Background(), stateTimeout) + defer cancel() + js, err := jetstream.New(f.c.nc) + if err != nil { + return nil, err + } + names := js.KeyValueStoreNames(ctx) + var nodes []string + for name := range names.Name() { + if node, ok := strings.CutPrefix(name, f.Prefix); ok && node != "" { + nodes = append(nodes, node) + } + } + if err := names.Error(); err != nil && !errors.Is(err, jetstream.ErrEndOfData) { + return nil, fmt.Errorf("the state %s: listing the buckets %s*: %w", f.Read, f.Prefix, err) + } + sort.Strings(nodes) + return nodes, nil +} + +// Get is a key's current value, read from the bucket of the node the key names, or nil when it has none or that +// node has no bucket. +func (f NodeFamily) Get(key string) (*stdio.StateEntry, error) { + node, err := nodeOf(key) + if err != nil { + return nil, err + } + e, err := f.Node(node).Get(key) + if errors.Is(err, jetstream.ErrBucketNotFound) { + return nil, nil + } + return e, err +} + +// Keys is every key with a current value in every bucket of the family, sorted. +func (f NodeFamily) Keys() ([]string, error) { + nodes, err := f.Nodes() + if err != nil { + return nil, err + } + keys := []string{} + for _, node := range nodes { + k, err := f.Node(node).Keys() + if err != nil { + return nil, err + } + keys = append(keys, k...) + } + sort.Strings(keys) + return keys, nil +} + +// Watch is State.Watch over every bucket of the family: handle hears the current values of every bucket the bus holds, +// then every change, and the current values and changes of a bucket the bus creates later. Calls to handle are one at +// a time. key narrows it as State.Watch's does, within each bucket; a key whose first part names a node watches only +// that node's bucket, but is still told of no bucket that appears later for another node. +func (f NodeFamily) Watch(key string, handle func(stdio.StateChange) error) error { + if err := f.checked(); err != nil { + return err + } + var mu sync.Mutex + one := func(c stdio.StateChange) error { + mu.Lock() + defer mu.Unlock() + return handle(c) + } + watched := map[string]bool{} + var wmu sync.Mutex + watch := func(node string) error { + wmu.Lock() + if watched[node] { + wmu.Unlock() + return nil + } + watched[node] = true + wmu.Unlock() + return f.Node(node).Watch(key, one) + } + if first, _, _ := strings.Cut(key, "."); key != "" && first != "*" && first != "**" { + return watch(first) + } + // Heard before the buckets are listed, so a bucket created between the two is not missed. A subject's wildcard is a + // whole token, so every stream's creation is heard (FamilyCreatedAdvisory) and the family's picked by name. + sub, err := f.c.nc.Subscribe(FamilyCreatedAdvisory, func(m *nats.Msg) { + bucket, isKV := strings.CutPrefix(m.Subject, strings.TrimSuffix(FamilyCreatedAdvisory, "*")+"KV_") + if node, ok := strings.CutPrefix(bucket, f.Prefix); isKV && ok && node != "" { + go func() { _ = watch(node) }() + } + }) + if err != nil { + return fmt.Errorf("the state %s: hearing new buckets %s*: %w", f.Read, f.Prefix, err) + } + nodes, err := f.Nodes() + if err != nil { + _ = sub.Unsubscribe() + return err + } + for _, node := range nodes { + if err := watch(node); err != nil { + _ = sub.Unsubscribe() + return err + } + } + return nil +} diff --git a/go/process/pernode_integration_test.go b/go/process/pernode_integration_test.go new file mode 100644 index 0000000..6eb6e8c --- /dev/null +++ b/go/process/pernode_integration_test.go @@ -0,0 +1,88 @@ +//go:build integration + +package process + +import ( + "slices" + "strings" + "sync" + "testing" + "time" + + "github.com/nats-io/nats.go/jetstream" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// ADR 0593 §6: a state its owner keeps one bucket per node (composed-) is read as one state: a key is read from +// the bucket its node part names, the keys of every node's bucket are listed together, and a watch hears every bucket, +// one created after it began included, while a bucket outside the family is never read. +func TestAPerNodeStateIsReadAcrossEveryNodesBucket(t *testing.T) { + url, _ := grantedSendProcess(t) + _, admin := adminJS(t, url) + put := func(bucket, key, value string) { + t.Helper() + kv, err := admin.CreateOrUpdateKeyValue(ctxFor(t), jetstream.KeyValueConfig{Bucket: bucket}) + if err != nil { + t.Fatal(err) + } + if _, err := kv.Put(ctxFor(t), key, []byte(value)); err != nil { + t.Fatal(err) + } + } + put("composed-ace", "ace.netcheck.aa", `{"n":1}`) + put("composed-g14", "g14.netcheck.bb", `{"n":2}`) + put("composedx-ace", "ace.other.cc", `{"n":3}`) + + conn, err := Connect(Account{URL: url, Node: "novox", Module: "mesh-provisioner", User: adminUser, Password: adminPass}) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + family := conn.ReadPerNode("mesh-host.composed", "composed-") + + if nodes, err := family.Nodes(); err != nil || !slices.Equal(nodes, []string{"ace", "g14"}) { + t.Fatalf("nodes = %v, %v", nodes, err) + } + if e, err := family.Get("g14.netcheck.bb"); err != nil || e == nil || string(e.Value) != `{"n":2}` { + t.Fatalf("get from g14's bucket: %+v, %v", e, err) + } + if e, err := family.Get("laptop.netcheck.bb"); err != nil || e != nil { + t.Fatalf("a node with no bucket: %+v, %v", e, err) + } + if keys, err := family.Keys(); err != nil || !slices.Equal(keys, []string{"ace.netcheck.aa", "g14.netcheck.bb"}) { + t.Fatalf("keys = %v, %v", keys, err) + } + + var mu sync.Mutex + heard := map[string]bool{} + err = family.Watch("", func(c stdio.StateChange) error { + mu.Lock() + defer mu.Unlock() + heard[c.Key] = true + return nil + }) + if err != nil { + t.Fatal(err) + } + put("composed-shanks", "shanks.netcheck.dd", `{"n":4}`) + put("composed-ace", "ace.netcheck.ee", `{"n":5}`) + deadline := time.Now().Add(5 * time.Second) + want := []string{"ace.netcheck.aa", "ace.netcheck.ee", "g14.netcheck.bb", "shanks.netcheck.dd"} + for { + mu.Lock() + var got []string + for k := range heard { + got = append(got, k) + } + mu.Unlock() + slices.Sort(got) + if slices.Equal(got, want) { + break + } + if time.Now().After(deadline) || slices.ContainsFunc(got, func(k string) bool { return strings.HasPrefix(k, "ace.other") }) { + t.Fatalf("heard %v, want %v", got, want) + } + time.Sleep(50 * time.Millisecond) + } +} diff --git a/go/process/state.go b/go/process/state.go index 48dbcff..4d44d44 100644 --- a/go/process/state.go +++ b/go/process/state.go @@ -28,6 +28,8 @@ const stateTimeout = 30 * time.Second type State struct { Module string Name string + // bucketName is the bucket when it is not _: one node's bucket of a per-node state (NodeFamily). + bucketName string // kv binds this state's underlying key-value store: a live connection's own (Conn.State), or, in a // unit test, a fake that never reaches a bus. kv func(ctx context.Context) (jetstream.KeyValue, error) @@ -36,14 +38,19 @@ type State struct { } // bucket is this state's name on the bus. -func (s State) bucket() string { return s.Module + "_" + s.Name } +func (s State) bucket() string { + if s.bucketName != "" { + return s.bucketName + } + return s.Module + "_" + s.Name +} // State is the named state this connection's module keeps, bound lazily to its bucket on each call so a // bucket the account is not granted fails at the call that needed it, naming the bucket. The bind itself // (js.KeyValue) is what asks $JS.API.STREAM.INFO.KV_, so it is what withRefusal watches: a bucket // not granted is refused there, promptly, as nats.ErrPermissionViolation. func (c *Conn) State(name string) State { - return c.bound(c.account.Module, name, func() error { return c.refusedUnannounced(name) }) + return c.bound(c.account.Module, name, "", func() error { return c.refusedUnannounced(name) }) } // Read is another module's state this connection's module reads, named as its manifest's `reads` names it @@ -52,7 +59,7 @@ func (c *Conn) State(name string) State { // the seat mesh-provisioner's plans). A name that is not `.` fails at the call that needed it. func (c *Conn) Read(read string) State { module, name, ok := strings.Cut(read, ".") - return c.bound(module, name, func() error { + return c.bound(module, name, "", func() error { if !ok || module == "" || name == "" || strings.Contains(name, ".") { return fmt.Errorf("%q is not a state another module keeps, .", read) } @@ -60,10 +67,14 @@ func (c *Conn) Read(read string) State { }) } -// bound is module's state name on this connection, checked by refused before every bind. -func (c *Conn) bound(module, name string, refused func() error) State { - bucket := module + "_" + name - return State{Module: module, Name: name, closed: func() bool { return c.nc.IsClosed() }, kv: func(ctx context.Context) (jetstream.KeyValue, error) { +// bound is module's state name on this connection, in its bucket _ or the bucket given, checked by +// refused before every bind. +func (c *Conn) bound(module, name, bucket string, refused func() error) State { + given := bucket + if bucket == "" { + bucket = module + "_" + name + } + return State{Module: module, Name: name, bucketName: given, closed: func() bool { return c.nc.IsClosed() }, kv: func(ctx context.Context) (jetstream.KeyValue, error) { if err := refused(); err != nil { return nil, err } -- 2.54.0 From acef75d3985a030a53b39a1b236dfad9e8c73416 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 18:24:01 +0100 Subject: [PATCH 08/22] =?UTF-8?q?membership:=20an=20issued=20state=20may?= =?UTF-8?q?=20be=20one=20bucket=20per=20node,=20so=20the=20runtime=20reads?= =?UTF-8?q?=20every=20node's=20compositions=20for=20a=20bundle=20as=20one?= =?UTF-8?q?=20state=20(ADR=200593=20=C2=A76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/membership/membership.go | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/go/membership/membership.go b/go/membership/membership.go index c942ccc..8fd93fd 100644 --- a/go/membership/membership.go +++ b/go/membership/membership.go @@ -74,11 +74,17 @@ type SeatServed struct { // StateIssued is one bucket an assignment may reach, by the name its module uses for it (novox/hq ADR 0201). // Key, when set, is the one key it may reach (novox/hq ADR 0260): this machine's. +// +// PerNode says the state is kept one bucket per node (novox/hq ADR 0593 §6: `composed-`): Bucket is then the +// buckets' prefix, each bucket the prefix and a node's name, and a key's first part names its node and so its bucket. +// The runtime reads it as one state (as process.NodeFamily does): a key from its node's bucket, the keys and a watch over +// every bucket of the family, one created later included. Read only: PerNode is never issued with Writes. type StateIssued struct { - Name string `json:"name"` - Bucket string `json:"bucket"` - Writes bool `json:"writes,omitempty"` - Key string `json:"key,omitempty"` + Name string `json:"name"` + Bucket string `json:"bucket"` + Writes bool `json:"writes,omitempty"` + Key string `json:"key,omitempty"` + PerNode bool `json:"per-node,omitempty"` } // LogIssued is one log an assignment may reach, by the name its module uses for it (novox/hq ADR 0297). -- 2.54.0 From ba82dd64b1ea536a891a22737b6164aa3fe1aaf7 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 18:25:11 +0100 Subject: [PATCH 09/22] =?UTF-8?q?delivery=20and=20catalogue=20import=20nei?= =?UTF-8?q?ther=20overlay=20nor=20buildcpu,=20so=20a=20change=20to=20the?= =?UTF-8?q?=20network=20generator=20or=20the=20build=20share=20code=20rebu?= =?UTF-8?q?ilds=20only=20their=20own=20importers=20(ADR=200593=20=C2=A71)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/buildcpu/buildcpu_agree_test.go | 14 +++++++++++++ go/catalogue/yields.go | 22 ++++++++++++++------ go/delivery/provided.go | 30 +++++++++++++++++++++++----- go/delivery/provided_overlay_test.go | 21 +++++++++++++++++++ 4 files changed, 76 insertions(+), 11 deletions(-) create mode 100644 go/buildcpu/buildcpu_agree_test.go create mode 100644 go/delivery/provided_overlay_test.go diff --git a/go/buildcpu/buildcpu_agree_test.go b/go/buildcpu/buildcpu_agree_test.go new file mode 100644 index 0000000..bf61988 --- /dev/null +++ b/go/buildcpu/buildcpu_agree_test.go @@ -0,0 +1,14 @@ +package buildcpu + +import ( + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// The share's names the composer gives are the build modules' (catalogue writes them too, so it does not import this). +func TestTheShareNamesAgreeWithTheCatalogues(t *testing.T) { + if ShareSetting != catalogue.BuildCPUShareSetting || ShareWord != catalogue.BuildCPUShareWord || DefaultPercent != catalogue.BuildCPUDefaultPercent { + t.Fatal("buildcpu's share names differ from catalogue's") + } +} diff --git a/go/catalogue/yields.go b/go/catalogue/yields.go index cd9bbfa..afedce1 100644 --- a/go/catalogue/yields.go +++ b/go/catalogue/yields.go @@ -2,18 +2,28 @@ package catalogue import ( "strconv" +) - "git.novox.be/novox/mesh-sdk/go/buildcpu" +// The build processor share's names (novox/hq ADR 0553 §3, §4), written here as well as in the build modules' package +// (BuildCPUShareSetting, ShareWord, DefaultPercent), so a manifest reader does not reach the build runner's code; a +// test of the build modules keeps them agreeing. +const ( + // BuildCPUShareSetting is the node-engine's config value of the node's processor share for its builds, in percent. + BuildCPUShareSetting = "build-cpu-share" + // BuildCPUShareWord is the word the composer gives every yielding module: the node's share, in percent. + BuildCPUShareWord = "MESH_BUILD_CPU_SHARE" + // BuildCPUDefaultPercent is the share where nothing sets one. + BuildCPUDefaultPercent = 50 ) // yieldsOf is how the runtime of its own a module is served by gives way on this node (novox/hq ADR 0553), or nil when // it does not, and the word its steps size their threads from: // // - a module holding a seat that yields (Seat.Yields, the build seats) runs at idle priority under the node's one share -// of its processors: the node-engine's own config build-cpu-share (buildcpu.ShareSetting) as this node's layers give +// of its processors: the node-engine's own config build-cpu-share (BuildCPUShareSetting) as this node's layers give // it, its default where none does. Every yielding module of the node carries the same share, so the node-engine's // one yielding slice has one quota (ADR 0553 §2, §3). -// - its word: that share (buildcpu.ShareWord), which a step divides by its own module's capacity (ADR 0553 §4). +// - its word: that share (BuildCPUShareWord), which a step divides by its own module's capacity (ADR 0553 §4). // // Never a refusal: a share out of range is the default, so a mistyped value bounds the builds at the default and never // stops the node's whole composition. @@ -27,16 +37,16 @@ func (r Resolution) yieldsOf(m Manifest, with Rendering) (map[string]any, map[st engine = e } } - share := buildcpu.DefaultPercent + share := BuildCPUDefaultPercent for _, s := range Effective(engine, with.Settings[engineModule]) { - if s.Key == buildcpu.ShareSetting { + if s.Key == BuildCPUShareSetting { if n, ok := percentOf(s.Value); ok { share = n } } } return map[string]any{"priority": "idle", "cpu-share": share}, - map[string]string{buildcpu.ShareWord: strconv.Itoa(share)} + map[string]string{BuildCPUShareWord: strconv.Itoa(share)} } // yields is whether a module claims a seat that yields. diff --git a/go/delivery/provided.go b/go/delivery/provided.go index bcb58e6..f3bcc05 100644 --- a/go/delivery/provided.go +++ b/go/delivery/provided.go @@ -5,19 +5,39 @@ import ( "fmt" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/overlay" ) +// The private network's names as its manifest says them, written here as well as in overlay (Name, Requirement, +// Addressing, TheNetwork), which delivery does not import so its many readers do not reach the network's generator; +// a test keeps them agreeing. +const ( + overlayName = "mesh-wireguard" + overlayRequirement = "private-network" + overlayAddressing = "mesh-addressing" + overlayTheNetwork = "the-private-network" +) + +// overlayManifest is overlay.Manifest(): the module the mesh provides for itself. +func overlayManifest() map[string]any { + return map[string]any{ + "module": overlayName, + "version": "1", + "computed": overlayName, + "provides": []string{overlayRequirement, overlayAddressing}, + "claims": []map[string]any{{"name": overlayTheNetwork, "scope": "node"}}, + } +} + // The modules the mesh provides itself (issue 1126): no repository holds them, so the indexer reads none; their // manifests come with mesh-sdk, built in code, and always win over any other. Today the private network alone -// (overlay.Name), built by nobody and run inside each node's own composition. One predicate for every part that judges +// (overlay's mesh-wireguard), built by nobody and run inside each node's own composition. One predicate for every part that judges // or composes a placement: CheckPlacements, the seat's holder, the composer's input (frombus) and the grants part. // ProvidedModules is every module the mesh provides itself, sorted. -func ProvidedModules() []string { return []string{overlay.Name} } +func ProvidedModules() []string { return []string{overlayName} } // ProvidedByMesh says whether the mesh provides module itself, so a placement may name it with no repository holding it. -func ProvidedByMesh(module string) bool { return module == overlay.Name } +func ProvidedByMesh(module string) bool { return module == overlayName } // ProvidedManifest is a provided module's manifest, as its readers parse it, and its raw JSON; an error for a module the // mesh does not provide or a manifest its own reader refuses. @@ -25,7 +45,7 @@ func ProvidedManifest(module string) (catalogue.Manifest, []byte, error) { if !ProvidedByMesh(module) { return catalogue.Manifest{}, nil, fmt.Errorf("delivery: the mesh provides no module %s", module) } - raw, err := json.Marshal(overlay.Manifest()) + raw, err := json.Marshal(overlayManifest()) if err != nil { return catalogue.Manifest{}, nil, err } diff --git a/go/delivery/provided_overlay_test.go b/go/delivery/provided_overlay_test.go new file mode 100644 index 0000000..05523d3 --- /dev/null +++ b/go/delivery/provided_overlay_test.go @@ -0,0 +1,21 @@ +package delivery_test + +import ( + "encoding/json" + "testing" + + "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/overlay" +) + +// The private network's manifest delivery provides is overlay's, though delivery does not import overlay. +func TestTheProvidedNetworkIsOverlays(t *testing.T) { + _, raw, err := delivery.ProvidedManifest(overlay.Name) + if err != nil { + t.Fatal(err) + } + want, _ := json.Marshal(overlay.Manifest()) + if string(raw) != string(want) { + t.Fatalf("delivery provides\n%s\noverlay says\n%s", raw, want) + } +} -- 2.54.0 From bb59fd8fa3f742a9ffc2be66d7441b4aa663df3f Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 18:25:13 +0100 Subject: [PATCH 10/22] =?UTF-8?q?delivery:=20a=20composition's=20digest=20?= =?UTF-8?q?leaves=20its=20plan=20out,=20as=20a=20have,=20a=20want=20and=20?= =?UTF-8?q?the=20apply=20name=20it=20by=20its=20resources=20(ADR=200593=20?= =?UTF-8?q?=C2=A76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/delivery/compose_candidate_test.go | 7 +++++-- go/delivery/composed_value.go | 6 ++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/go/delivery/compose_candidate_test.go b/go/delivery/compose_candidate_test.go index 4c9558c..b7d069d 100644 --- a/go/delivery/compose_candidate_test.go +++ b/go/delivery/compose_candidate_test.go @@ -81,8 +81,11 @@ func TestComposeCandidateAndComposedPlanRoundTripAndRefuse(t *testing.T) { } d1, _ := ComposedDigestOf(planned) d0, _ := ComposedDigestOf(Composed{Node: "ace", Module: "netcheck", FP: fp, Resources: []json.RawMessage{}}) - if d1 == "" || d1 == d0 { - t.Error("the digest does not move with the plan") + if d1 == "" || d1 != d0 { + t.Error("the digest moves with the plan: it is the resources' alone") + } + if SameComposed(planned, Composed{Node: "ace", Module: "netcheck", FP: fp, Resources: []json.RawMessage{}}) { + t.Error("a plan change is not a new composition value") } refused := Composed{Node: "ace", Module: "netcheck", FP: fp, Refused: "the setting domain has no value"} if raw, err = EncodeComposed(refused); err != nil { diff --git a/go/delivery/composed_value.go b/go/delivery/composed_value.go index b98f85e..787f88d 100644 --- a/go/delivery/composed_value.go +++ b/go/delivery/composed_value.go @@ -50,7 +50,8 @@ type Composed struct { // Plan is the module's whole provisioning plan as the node-engine hands it to the provisioner (templates, files, // own secrets by name, items, collections, provisions; never a value), composed node-wide (novox/hq ADR 0593 §6, // §8): the provisioner makes every plan from it. Nil when the module hands the provisioner nothing; only on a - // module's key. It is part of the encoding, so the digest moves when the plan does. + // module's key. It is part of the encoding (SameComposed sees a plan change) but not of the digest + // (ComposedDigestOf): a have, a want and the apply name a composition by its resources. Plan *catalogue.Plan // Refused is why the module cannot be composed at this fingerprint on this node, the rest of the node composed // (ADR 0593 §6): a refused composition holds no resource, filled file, data or plan, and has no digest @@ -233,7 +234,7 @@ func DecodeComposed(data []byte) (Composed, error) { return c, nil } -// ComposedDigestOf is a composition's digest, "sha256:", of its encoding without its signature. A want names it +// ComposedDigestOf is a composition's digest, "sha256:", of its encoding without its signature and its plan. A want names it // (ADR 0539 clause 1), the controller writes it beside the composition, and the signature signs it. // // A refused composition has no digest: it is refused, naming the module (ADR 0593 §6). @@ -242,6 +243,7 @@ func ComposedDigestOf(c Composed) (string, error) { return "", fmt.Errorf("delivery: the composition of %s on %s is refused (%s) and has no digest", c.Module, c.Node, c.Refused) } c.Signature = nil + c.Plan = nil raw, err := EncodeComposed(c) if err != nil { return "", err -- 2.54.0 From 029ba98527269bd99a2021de3b7643d928fae089 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 18:29:40 +0100 Subject: [PATCH 11/22] =?UTF-8?q?pernode:=20the=20per-node=20state's=20rul?= =?UTF-8?q?es=20over=20any=20connection,=20so=20the=20runtime=20and=20a=20?= =?UTF-8?q?process=20read=20compositions=20with=20one=20copy=20of=20them?= =?UTF-8?q?=20(ADR=200593=20=C2=A76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/pernode/pernode.go | 345 +++++++++++++++++++++++++++++++++++++ go/pernode/pernode_test.go | 158 +++++++++++++++++ go/process/pernode.go | 197 +++++++-------------- 3 files changed, 564 insertions(+), 136 deletions(-) create mode 100644 go/pernode/pernode.go create mode 100644 go/pernode/pernode_test.go diff --git a/go/pernode/pernode.go b/go/pernode/pernode.go new file mode 100644 index 0000000..26d4dc0 --- /dev/null +++ b/go/pernode/pernode.go @@ -0,0 +1,345 @@ +// Package pernode reads a state its owner keeps one bucket per node as one state (novox/hq ADR 0593 §6: each node's +// engine writes its own `composed-`, and the provisioner, the assigner and the view read them all), over any +// connection that satisfies Bus: a process's own (process.Conn) and the runtime's (node-tools) alike, so the rules are +// written once. +// +// A reader names the state as its manifest's `reads` does (`.`, such as `mesh-host.composed`) with the +// prefix of its buckets (`composed-`): each bucket `` holds keys whose first part is that node, so a key +// names its bucket. Get reads one key from its node's bucket; Keys and Watch go over every bucket of the family the bus +// holds, and Watch takes in a node's bucket created after it began, as the bus says it (its creation advisory), so a +// node added later is heard with no timer. It reads only: the family has no write, and the owner's nodes write as their +// grants allow. +package pernode + +import ( + "context" + "errors" + "fmt" + "regexp" + "sort" + "strings" + "sync" + + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" +) + +// What a reader of a per-node state is granted beside each bucket's read (the grants part composes them): the listing +// of stream names (ListSubject) and the creation of any stream (CreatedAdvisory), which says a name only. A subject's +// wildcard is a whole token, so `KV_composed-*` cannot be granted: every creation is heard and the family's picked. +const ( + ListSubject = "$JS.API.STREAM.NAMES" + CreatedAdvisory = "$JS.EVENT.ADVISORY.STREAM.CREATED.*" + // createdPrefix starts the subject of a key-value bucket's creation advisory, the bucket's name after it. + createdPrefix = "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_" +) + +// Bus is what a connection gives the family: the names of the key-value buckets the bus holds, a bucket opened by name +// (jetstream.ErrBucketNotFound when it is not there), and the creation advisories (CreatedAdvisory) heard, each by its +// subject, until stop. +type Bus interface { + BucketNames(ctx context.Context) ([]string, error) + OpenBucket(ctx context.Context, name string) (jetstream.KeyValue, error) + SubscribeCreated(heard func(subject string)) (stop func(), err error) +} + +// Family is a per-node state: its read name, its buckets' prefix, and the connection it is read over. +type Family struct { + Read string + Prefix string + Bus Bus +} + +// Change is one change a watch hands over: the node whose bucket it is, the entry (its operation says put, delete or +// purge), and whether it was there when that bucket's watch began. +type Change struct { + Node string + Entry jetstream.KeyValueEntry + Current bool +} + +// nodeName is a node a bucket and a subject token accept. +var nodeName = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) + +// Check refuses a family with no read name `.`, or a prefix a bucket name cannot start with or that +// does not end in `-` or `_`, since a prefix without its separator would read a look-alike family (`composed` reading +// `composedx-ace`). +func (f Family) Check() error { + module, name, ok := strings.Cut(f.Read, ".") + if !ok || module == "" || name == "" || strings.Contains(name, ".") { + return fmt.Errorf("%q is not a state another module keeps, .", f.Read) + } + if !nodeName.MatchString(strings.TrimRight(f.Prefix, "-_")) || !strings.HasSuffix(f.Prefix, "-") && !strings.HasSuffix(f.Prefix, "_") { + return fmt.Errorf("the state %s: %q is not a prefix of buckets, letters, digits, '-' and '_' ending in '-' or '_'", f.Read, f.Prefix) + } + if f.Bus == nil { + return fmt.Errorf("the state %s is read over no connection", f.Read) + } + return nil +} + +// Bucket is the bucket of a node. +func (f Family) Bucket(node string) (string, error) { + if err := f.Check(); err != nil { + return "", err + } + if !nodeName.MatchString(node) { + return "", fmt.Errorf("the state %s: %q is not a node a bucket is named for", f.Read, node) + } + return f.Prefix + node, nil +} + +// NodeOf is the node a key of the family names: its first part. +func NodeOf(key string) (string, error) { + node, _, ok := strings.Cut(key, ".") + if !ok || !nodeName.MatchString(node) { + return "", fmt.Errorf("%q names no node: a key of a per-node state starts .", key) + } + return node, nil +} + +// nodeOfBucket is the node of a bucket name of the family, and whether it is one. +func (f Family) nodeOfBucket(name string) (string, bool) { + node, ok := strings.CutPrefix(name, f.Prefix) + return node, ok && nodeName.MatchString(node) +} + +// Nodes are the nodes whose bucket of the family the bus holds now, sorted. +func (f Family) Nodes(ctx context.Context) ([]string, error) { + if err := f.Check(); err != nil { + return nil, err + } + names, err := f.Bus.BucketNames(ctx) + if err != nil { + return nil, fmt.Errorf("the state %s: listing the buckets %s*: %w", f.Read, f.Prefix, err) + } + var nodes []string + for _, name := range names { + if node, ok := f.nodeOfBucket(name); ok { + nodes = append(nodes, node) + } + } + sort.Strings(nodes) + return nodes, nil +} + +// Open is the bucket of a node, opened; nil, nil when the bus holds none. +func (f Family) Open(ctx context.Context, node string) (jetstream.KeyValue, error) { + bucket, err := f.Bucket(node) + if err != nil { + return nil, err + } + kv, err := f.Bus.OpenBucket(ctx, bucket) + if errors.Is(err, jetstream.ErrBucketNotFound) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("the state %s (bucket %s): %w", f.Read, bucket, err) + } + return kv, nil +} + +// Get is a key's current entry, read from the bucket of the node the key names, or nil when it has none or that node +// has no bucket. +func (f Family) Get(ctx context.Context, key string) (jetstream.KeyValueEntry, error) { + node, err := NodeOf(key) + if err != nil { + return nil, err + } + kv, err := f.Open(ctx, node) + if err != nil || kv == nil { + return nil, err + } + e, err := kv.Get(ctx, key) + if errors.Is(err, jetstream.ErrKeyNotFound) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("the state %s, key %s: %w", f.Read, key, err) + } + return e, nil +} + +// Keys is every key with a current value in every bucket of the family, sorted. +func (f Family) Keys(ctx context.Context) ([]string, error) { + nodes, err := f.Nodes(ctx) + if err != nil { + return nil, err + } + keys := []string{} + for _, node := range nodes { + kv, err := f.Open(ctx, node) + if err != nil { + return nil, err + } + if kv == nil { + continue + } + k, err := kv.Keys(ctx) + if errors.Is(err, jetstream.ErrNoKeysFound) { + continue + } + if err != nil { + return nil, fmt.Errorf("the state %s on %s, keys: %w", f.Read, node, err) + } + keys = append(keys, k...) + } + sort.Strings(keys) + return keys, nil +} + +// Pattern is a key narrowing as the bus names it: `*` stays one name, `**` is the rest, empty is every key. +func Pattern(key string) string { + if key == "" { + return jetstream.AllKeys + } + tokens := strings.Split(key, ".") + for i, t := range tokens { + if t == "**" { + tokens[i] = ">" + } + } + return strings.Join(tokens, ".") +} + +// Watch hands handle, one call at a time, the current values of every bucket of the family the bus holds (each +// Current, deletes left out), then every change, and the current values and changes of a bucket the bus creates later. +// It returns once the current values of the buckets there now are handled; an error before that is returned, and an +// error from handle after it ends that bucket's watch. key narrows it within each bucket (Pattern); a key whose first +// part names a node watches only that node's bucket. ctx ends every watch and the hearing of new buckets. +func (f Family) Watch(ctx context.Context, key string, handle func(Change) error) error { + if err := f.Check(); err != nil { + return err + } + var mu sync.Mutex + one := func(c Change) error { + mu.Lock() + defer mu.Unlock() + return handle(c) + } + var wmu sync.Mutex + watched := map[string]bool{} + watch := func(node string) error { + wmu.Lock() + if watched[node] { + wmu.Unlock() + return nil + } + watched[node] = true + wmu.Unlock() + kv, err := f.Open(ctx, node) + if err != nil || kv == nil { + return err + } + return f.watchBucket(ctx, kv, node, key, one) + } + if first, _, _ := strings.Cut(key, "."); key != "" && first != "*" && first != "**" { + return watch(first) + } + // Heard before the buckets are listed, so a bucket created between the two is not missed. + stop, err := f.Bus.SubscribeCreated(func(subject string) { + bucket, isKV := strings.CutPrefix(subject, createdPrefix) + if node, ok := f.nodeOfBucket(bucket); isKV && ok { + go func() { _ = watch(node) }() + } + }) + if err != nil { + return fmt.Errorf("the state %s: hearing new buckets %s*: %w", f.Read, f.Prefix, err) + } + go func() { <-ctx.Done(); stop() }() + nodes, err := f.Nodes(ctx) + if err != nil { + stop() + return err + } + for _, node := range nodes { + if err := watch(node); err != nil { + stop() + return err + } + } + return nil +} + +// watchBucket hands one bucket's current values, then its changes from a goroutine, until ctx ends or handle errs. +func (f Family) watchBucket(ctx context.Context, kv jetstream.KeyValue, node, key string, handle func(Change) error) error { + w, err := kv.Watch(ctx, Pattern(key)) + if err != nil { + return fmt.Errorf("the state %s on %s, watching %q: %w", f.Read, node, key, err) + } + for { + select { + case <-ctx.Done(): + _ = w.Stop() + return ctx.Err() + case e, open := <-w.Updates(): + if !open { + return fmt.Errorf("the state %s on %s: the watch ended", f.Read, node) + } + if e == nil { + go func() { + defer func() { _ = w.Stop() }() + for { + select { + case <-ctx.Done(): + return + case e, open := <-w.Updates(): + if !open || e == nil { + return + } + if handle(Change{Node: node, Entry: e}) != nil { + return + } + } + } + }() + return nil + } + if e.Operation() != jetstream.KeyValuePut { + continue + } + if err := handle(Change{Node: node, Entry: e, Current: true}); err != nil { + _ = w.Stop() + return err + } + } + } +} + +// OverNATS is the Bus of a plain connection: its JetStream's bucket names and buckets, and the creation advisories +// subscribed on it. A connection that words a refused open its own way (process.Conn) gives its own OpenBucket. +func OverNATS(nc *nats.Conn) Bus { return natsBus{nc: nc} } + +type natsBus struct{ nc *nats.Conn } + +func (b natsBus) BucketNames(ctx context.Context) ([]string, error) { + js, err := jetstream.New(b.nc) + if err != nil { + return nil, err + } + lister := js.KeyValueStoreNames(ctx) + var names []string + for name := range lister.Name() { + names = append(names, name) + } + if err := lister.Error(); err != nil && !errors.Is(err, jetstream.ErrEndOfData) { + return nil, err + } + return names, nil +} + +func (b natsBus) OpenBucket(ctx context.Context, name string) (jetstream.KeyValue, error) { + js, err := jetstream.New(b.nc) + if err != nil { + return nil, err + } + return js.KeyValue(ctx, name) +} + +func (b natsBus) SubscribeCreated(heard func(subject string)) (func(), error) { + sub, err := b.nc.Subscribe(CreatedAdvisory, func(m *nats.Msg) { heard(m.Subject) }) + if err != nil { + return nil, err + } + return func() { _ = sub.Unsubscribe() }, nil +} diff --git a/go/pernode/pernode_test.go b/go/pernode/pernode_test.go new file mode 100644 index 0000000..bfb7403 --- /dev/null +++ b/go/pernode/pernode_test.go @@ -0,0 +1,158 @@ +package pernode + +import ( + "context" + "slices" + "sync" + "testing" + "time" + + "github.com/nats-io/nats.go/jetstream" +) + +type fakeEntry struct { + jetstream.KeyValueEntry + key, value string +} + +func (e fakeEntry) Key() string { return e.key } +func (e fakeEntry) Value() []byte { return []byte(e.value) } +func (e fakeEntry) Revision() uint64 { return 1 } +func (e fakeEntry) Operation() jetstream.KeyValueOp { return jetstream.KeyValuePut } + +type fakeWatcher struct{ ch chan jetstream.KeyValueEntry } + +func (w fakeWatcher) Updates() <-chan jetstream.KeyValueEntry { return w.ch } +func (w fakeWatcher) Stop() error { return nil } + +type fakeKV struct { + jetstream.KeyValue + values map[string]string + watcher fakeWatcher +} + +func (k *fakeKV) Get(_ context.Context, key string) (jetstream.KeyValueEntry, error) { + v, ok := k.values[key] + if !ok { + return nil, jetstream.ErrKeyNotFound + } + return fakeEntry{key: key, value: v}, nil +} + +func (k *fakeKV) Keys(context.Context, ...jetstream.WatchOpt) ([]string, error) { + var keys []string + for key := range k.values { + keys = append(keys, key) + } + return keys, nil +} + +func (k *fakeKV) Watch(context.Context, string, ...jetstream.WatchOpt) (jetstream.KeyWatcher, error) { + k.watcher = fakeWatcher{ch: make(chan jetstream.KeyValueEntry, 16)} + for key, v := range k.values { + k.watcher.ch <- fakeEntry{key: key, value: v} + } + k.watcher.ch <- nil + return k.watcher, nil +} + +type fakeBus struct { + mu sync.Mutex + buckets map[string]*fakeKV + heard func(string) + opened []string +} + +func (b *fakeBus) BucketNames(context.Context) ([]string, error) { + b.mu.Lock() + defer b.mu.Unlock() + var names []string + for n := range b.buckets { + names = append(names, n) + } + return names, nil +} + +func (b *fakeBus) OpenBucket(_ context.Context, name string) (jetstream.KeyValue, error) { + b.mu.Lock() + defer b.mu.Unlock() + b.opened = append(b.opened, name) + kv, ok := b.buckets[name] + if !ok { + return nil, jetstream.ErrBucketNotFound + } + return kv, nil +} + +func (b *fakeBus) SubscribeCreated(heard func(string)) (func(), error) { + b.heard = heard + return func() {}, nil +} + +// A per-node state over a fake connection (ADR 0593 §6): a key is read from its node's bucket, the keys of every +// node's bucket are listed together, a look-alike bucket is never opened, a bucket created during a watch is heard, +// and a family whose prefix would read a look-alike is refused. +func TestAFamilyReadsEveryNodesBucketOverAnyConnection(t *testing.T) { + bus := &fakeBus{buckets: map[string]*fakeKV{ + "composed-ace": {values: map[string]string{"ace.netcheck.aa": "1"}}, + "composed-g14": {values: map[string]string{"g14.netcheck.bb": "2"}}, + "composedx-ace": {values: map[string]string{"ace.other.cc": "3"}}, + }} + f := Family{Read: "mesh-host.composed", Prefix: "composed-", Bus: bus} + ctx := context.Background() + if nodes, err := f.Nodes(ctx); err != nil || !slices.Equal(nodes, []string{"ace", "g14"}) { + t.Fatalf("nodes = %v, %v", nodes, err) + } + if e, err := f.Get(ctx, "g14.netcheck.bb"); err != nil || e == nil || string(e.Value()) != "2" { + t.Fatalf("get: %v, %v", e, err) + } + if e, err := f.Get(ctx, "laptop.netcheck.bb"); err != nil || e != nil { + t.Fatalf("a node with no bucket: %v, %v", e, err) + } + if keys, err := f.Keys(ctx); err != nil || !slices.Equal(keys, []string{"ace.netcheck.aa", "g14.netcheck.bb"}) { + t.Fatalf("keys = %v, %v", keys, err) + } + var mu sync.Mutex + var heard []string + wctx, cancel := context.WithCancel(ctx) + defer cancel() + if err := f.Watch(wctx, "", func(c Change) error { + mu.Lock() + defer mu.Unlock() + heard = append(heard, c.Node+":"+c.Entry.Key()) + return nil + }); err != nil { + t.Fatal(err) + } + bus.mu.Lock() + bus.buckets["composed-shanks"] = &fakeKV{values: map[string]string{"shanks.netcheck.dd": "4"}} + bus.mu.Unlock() + bus.heard("$JS.EVENT.ADVISORY.STREAM.CREATED.KV_composed-shanks") + bus.heard("$JS.EVENT.ADVISORY.STREAM.CREATED.KV_composedx-g14") + want := []string{"ace:ace.netcheck.aa", "g14:g14.netcheck.bb", "shanks:shanks.netcheck.dd"} + for deadline := time.Now().Add(2 * time.Second); ; { + mu.Lock() + got := slices.Sorted(slices.Values(heard)) + mu.Unlock() + if slices.Equal(got, want) { + break + } + if time.Now().After(deadline) { + t.Fatalf("heard %v, want %v", got, want) + } + time.Sleep(10 * time.Millisecond) + } + bus.mu.Lock() + for _, o := range bus.opened { + if o == "composedx-ace" || o == "composedx-g14" { + t.Fatalf("a look-alike bucket was opened: %v", bus.opened) + } + } + bus.mu.Unlock() + for _, bad := range []Family{{Read: "mesh-host.composed", Prefix: "composed", Bus: bus}, {Read: "composed", Prefix: "composed-", Bus: bus}, + {Read: "mesh-host.composed", Prefix: "composed-"}} { + if _, err := bad.Nodes(ctx); err == nil { + t.Errorf("%+v was read", bad) + } + } +} diff --git a/go/process/pernode.go b/go/process/pernode.go index 9494dd6..5d844a8 100644 --- a/go/process/pernode.go +++ b/go/process/pernode.go @@ -2,34 +2,26 @@ package process import ( "context" - "errors" - "fmt" - "sort" + "encoding/json" "strings" - "sync" - "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/pernode" ) -// **A state its owner keeps one bucket per node is read as one state** (novox/hq ADR 0593 §6: each node's engine -// writes its own `composed-`, and the provisioner, the assigner and the view read them all). A reader names it as -// its manifest's `reads` does (`.`, such as `mesh-host.composed`) with the prefix of its buckets -// (`composed-`): each bucket `` holds keys whose first part is that node, so a key names its bucket. -// Get reads one key from its node's bucket; Keys and Watch go over every bucket of the family the bus holds, and Watch -// takes in a node's bucket created after it began, as the bus says it (its creation advisory), so a node added later is -// heard with no timer. Reads only: the owner's nodes write, as their grants allow. +// A state its owner keeps one bucket per node (novox/hq ADR 0593 §6: `composed-`), read as one state on this +// connection: the rules are pernode's, this connection only adapts to its Bus, opening a bucket as State does (a bucket +// not granted refused promptly, naming it). -// What a reader of a per-node state is granted beside each bucket's read (the grants part composes them): the listing -// of bucket names (FamilyListSubject) and the creation of any stream (FamilyCreatedAdvisory), which says a name only. +// What a reader of a per-node state is granted beside each bucket's read (pernode.ListSubject, CreatedAdvisory). const ( - FamilyListSubject = "$JS.API.STREAM.NAMES" - FamilyCreatedAdvisory = "$JS.EVENT.ADVISORY.STREAM.CREATED.*" + FamilyListSubject = pernode.ListSubject + FamilyCreatedAdvisory = pernode.CreatedAdvisory ) -// NodeFamily is a per-node state read across every node's bucket. +// NodeFamily is a per-node state read across every node's bucket on this connection. type NodeFamily struct { Read string Prefix string @@ -42,149 +34,82 @@ func (c *Conn) ReadPerNode(read, prefix string) NodeFamily { return NodeFamily{Read: read, Prefix: prefix, c: c} } -// checked refuses a family with no read name or prefix. -func (f NodeFamily) checked() error { - module, name, ok := strings.Cut(f.Read, ".") - if !ok || module == "" || name == "" || strings.Contains(name, ".") { - return fmt.Errorf("%q is not a state another module keeps, .", f.Read) - } - if f.Prefix == "" || strings.ContainsAny(f.Prefix, ".*> ") { - return fmt.Errorf("the state %s names no prefix of its buckets, or one a bucket name cannot start with: %q", f.Read, f.Prefix) - } - return nil +// connBus is pernode.Bus over this connection. +type connBus struct { + pernode.Bus + f NodeFamily } -// Node is the state of one node of the family: its bucket ``, bound as Read binds a bucket. -func (f NodeFamily) Node(node string) State { - module, name, _ := strings.Cut(f.Read, ".") - return f.c.bound(module, name, f.Prefix+node, func() error { - if err := f.checked(); err != nil { - return err - } - if node == "" || strings.ContainsAny(node, ".*> ") { - return fmt.Errorf("the state %s: %q is not a node a bucket is named for", f.Read, node) - } - return nil - }) +func (b connBus) OpenBucket(ctx context.Context, name string) (jetstream.KeyValue, error) { + module, state, _ := cutRead(b.f.Read) + return b.f.c.bound(module, state, name, func() error { return nil }).kv(ctx) } -// nodeOf is the node a key of the family names: its first part. -func nodeOf(key string) (string, error) { - node, _, ok := strings.Cut(key, ".") - if !ok || node == "" { - return "", fmt.Errorf("%q names no node: a key of a per-node state starts .", key) - } - return node, nil +func cutRead(read string) (string, string, bool) { return strings.Cut(read, ".") } + +func (f NodeFamily) family() pernode.Family { + return pernode.Family{Read: f.Read, Prefix: f.Prefix, Bus: connBus{Bus: pernode.OverNATS(f.c.nc), f: f}} } -// Nodes are the nodes whose bucket of the family the bus holds now, sorted: the names of the key-value buckets that -// start with the prefix, the prefix taken off. +// Nodes are the nodes whose bucket of the family the bus holds now, sorted. func (f NodeFamily) Nodes() ([]string, error) { - if err := f.checked(); err != nil { - return nil, err - } ctx, cancel := context.WithTimeout(context.Background(), stateTimeout) defer cancel() - js, err := jetstream.New(f.c.nc) - if err != nil { - return nil, err - } - names := js.KeyValueStoreNames(ctx) - var nodes []string - for name := range names.Name() { - if node, ok := strings.CutPrefix(name, f.Prefix); ok && node != "" { - nodes = append(nodes, node) - } - } - if err := names.Error(); err != nil && !errors.Is(err, jetstream.ErrEndOfData) { - return nil, fmt.Errorf("the state %s: listing the buckets %s*: %w", f.Read, f.Prefix, err) - } - sort.Strings(nodes) - return nodes, nil + return f.family().Nodes(ctx) } -// Get is a key's current value, read from the bucket of the node the key names, or nil when it has none or that -// node has no bucket. +// Node is the state of one node of the family, its bucket ``, bound as Read binds a bucket. +func (f NodeFamily) Node(node string) State { + module, name, _ := cutRead(f.Read) + bucket, err := f.family().Bucket(node) + return f.c.bound(module, name, bucket, func() error { return err }) +} + +// Get is a key's current value, read from the bucket of the node the key names, or nil when it has none or that node +// has no bucket. func (f NodeFamily) Get(key string) (*stdio.StateEntry, error) { - node, err := nodeOf(key) - if err != nil { + ctx, cancel := context.WithTimeout(context.Background(), stateTimeout) + defer cancel() + e, err := f.family().Get(ctx, key) + if err != nil || e == nil { return nil, err } - e, err := f.Node(node).Get(key) - if errors.Is(err, jetstream.ErrBucketNotFound) { - return nil, nil - } - return e, err + return &stdio.StateEntry{Key: e.Key(), Value: json.RawMessage(e.Value()), Revision: e.Revision()}, nil } // Keys is every key with a current value in every bucket of the family, sorted. func (f NodeFamily) Keys() ([]string, error) { - nodes, err := f.Nodes() - if err != nil { - return nil, err - } - keys := []string{} - for _, node := range nodes { - k, err := f.Node(node).Keys() - if err != nil { - return nil, err - } - keys = append(keys, k...) - } - sort.Strings(keys) - return keys, nil + ctx, cancel := context.WithTimeout(context.Background(), stateTimeout) + defer cancel() + return f.family().Keys(ctx) } -// Watch is State.Watch over every bucket of the family: handle hears the current values of every bucket the bus holds, -// then every change, and the current values and changes of a bucket the bus creates later. Calls to handle are one at -// a time. key narrows it as State.Watch's does, within each bucket; a key whose first part names a node watches only -// that node's bucket, but is still told of no bucket that appears later for another node. +// Watch is pernode.Family.Watch on this connection, each change handed as a State change: every bucket's current values, +// then its changes, a bucket created later included, one call at a time; the connection closing ends it. func (f NodeFamily) Watch(key string, handle func(stdio.StateChange) error) error { - if err := f.checked(); err != nil { - return err - } - var mu sync.Mutex - one := func(c stdio.StateChange) error { - mu.Lock() - defer mu.Unlock() - return handle(c) - } - watched := map[string]bool{} - var wmu sync.Mutex - watch := func(node string) error { - wmu.Lock() - if watched[node] { - wmu.Unlock() - return nil + ctx, cancel := context.WithCancel(context.Background()) + go func() { + for !f.c.nc.IsClosed() { + select { + case <-ctx.Done(): + return + case <-f.c.nc.StatusChanged(): + } } - watched[node] = true - wmu.Unlock() - return f.Node(node).Watch(key, one) - } - if first, _, _ := strings.Cut(key, "."); key != "" && first != "*" && first != "**" { - return watch(first) - } - // Heard before the buckets are listed, so a bucket created between the two is not missed. A subject's wildcard is a - // whole token, so every stream's creation is heard (FamilyCreatedAdvisory) and the family's picked by name. - sub, err := f.c.nc.Subscribe(FamilyCreatedAdvisory, func(m *nats.Msg) { - bucket, isKV := strings.CutPrefix(m.Subject, strings.TrimSuffix(FamilyCreatedAdvisory, "*")+"KV_") - if node, ok := strings.CutPrefix(bucket, f.Prefix); isKV && ok && node != "" { - go func() { _ = watch(node) }() + cancel() + }() + _, name, _ := cutRead(f.Read) + err := f.family().Watch(ctx, key, func(c pernode.Change) error { + sc := stdio.StateChange{State: name, Key: c.Entry.Key(), Op: stdio.OpPut, Revision: c.Entry.Revision(), Current: c.Current} + if c.Entry.Operation() == jetstream.KeyValuePut { + sc.Value = json.RawMessage(c.Entry.Value()) + } else { + sc.Op = stdio.OpDelete } + return handle(sc) }) if err != nil { - return fmt.Errorf("the state %s: hearing new buckets %s*: %w", f.Read, f.Prefix, err) + cancel() } - nodes, err := f.Nodes() - if err != nil { - _ = sub.Unsubscribe() - return err - } - for _, node := range nodes { - if err := watch(node); err != nil { - _ = sub.Unsubscribe() - return err - } - } - return nil + return err } -- 2.54.0 From 52bd3d087ccd49c1c4b9df1c0d741510ce928fae Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 18:34:56 +0100 Subject: [PATCH 12/22] =?UTF-8?q?delivery,=20catalogue:=20compose-candidat?= =?UTF-8?q?e=20takes=20input=20as=20a=20switch=20and=20answers=20unready?= =?UTF-8?q?=20for=20a=20node=20that=20cannot=20compose=20yet,=20so=20the?= =?UTF-8?q?=20checker=20decides=20by=20the=20verdict,=20never=20by=20words?= =?UTF-8?q?=20(ADR=200593=20=C2=A77,=20=C2=A79)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/catalogue/engine_compose_candidate_test.go | 19 +++++++++++++++++++ go/catalogue/engine_seat.go | 4 +++- go/delivery/compose_candidate.go | 14 +++++++++++--- go/delivery/compose_candidate_test.go | 14 ++++++++++++++ 4 files changed, 47 insertions(+), 4 deletions(-) create mode 100644 go/catalogue/engine_compose_candidate_test.go diff --git a/go/catalogue/engine_compose_candidate_test.go b/go/catalogue/engine_compose_candidate_test.go new file mode 100644 index 0000000..fb34000 --- /dev/null +++ b/go/catalogue/engine_compose_candidate_test.go @@ -0,0 +1,19 @@ +package catalogue + +import "testing" + +// The node-engine's compose-candidate verb (ADR 0593 §7) is optional, asks the candidate, and takes input as a switch. +func TestTheEngineServesComposeCandidate(t *testing.T) { + for _, v := range EngineVerbs() { + if v.Name != "compose-candidate" { + continue + } + props, _ := v.Input["properties"].(map[string]any) + input, _ := props["input"].(map[string]any) + if !v.Optional || props["candidate"] == nil || input == nil || input["enum"] == nil { + t.Fatalf("compose-candidate reads as %+v", v) + } + return + } + t.Fatal("the seat node-engine serves no compose-candidate") +} diff --git a/go/catalogue/engine_seat.go b/go/catalogue/engine_seat.go index 518e8e7..e5b6e3c 100644 --- a/go/catalogue/engine_seat.go +++ b/go/catalogue/engine_seat.go @@ -76,7 +76,9 @@ func EngineVerbs() []Verb { "composes (with its digest and whether the candidate changes it), is refused by the candidate or is held " + "without it, each with why, and the verdict of the machine's own composition (novox/hq ADR 0593 §7). Read-only: " + "applies nothing and writes nothing. Answered by this machine's engine alone, within the asker's bound.", - Input: schema(map[string]string{"candidate": "the key of the indexer's candidate, ."}, []string{"candidate"})}, + Input: schema(map[string]string{"candidate": "the key of the indexer's candidate, .", + "input": "\"true\": also answer the composition input the candidate was composed from, for the sandbox path"}, + []string{"candidate"}, "input")}, } } diff --git a/go/delivery/compose_candidate.go b/go/delivery/compose_candidate.go index ad382a8..25d2a17 100644 --- a/go/delivery/compose_candidate.go +++ b/go/delivery/compose_candidate.go @@ -27,6 +27,10 @@ const ( // refused by the candidate, or at least one is (or the node could not compose with it at all, Refused). ComposeCandidateComposed = "composed" ComposeCandidateRefused = "refused" + // ComposeCandidateUnready is the verdict of a node that cannot compose yet (its shadow not ready, its agent + // starting): no module is named, Refused says why in words, and the checker takes it as a node that did not answer + // (ComposeNoAnswer, ADR 0593 §9), deciding by this verdict and never by the words. + ComposeCandidateUnready = "unready" // The states of one module's composition with the candidate: composed (Digest set), refused by the candidate // (Words set), or held, refused without the candidate too (Words set), which counts toward no verdict. @@ -47,7 +51,7 @@ type ComposeCandidateAsk struct { // ComposeCandidateAnswer is the verb's answer: the node, the candidate, the verdict, every module placed on the node // sorted by name, the node's own composition (Own, Module "node"), why the node could not compose with the candidate at -// all (Refused: Modules is then empty and Verdict refused), how long the engine took in milliseconds, and, when asked +// all (Refused: Modules is then empty and Verdict refused, or unready when it cannot compose yet), how long the engine took in milliseconds, and, when asked // (ComposeCandidateAsk.Input), the composition input it composed from. type ComposeCandidateAnswer struct { V int `json:"v"` @@ -111,9 +115,13 @@ func (a ComposeCandidateAnswer) check() error { if a.Took < 0 { return fmt.Errorf("delivery: the answer of %s took %d ms", a.Node, a.Took) } + if a.Verdict == ComposeCandidateUnready && a.Refused == "" { + return fmt.Errorf("delivery: %s is not ready to compose and its answer says no why", a.Node) + } if a.Refused != "" { - if len(a.Modules) > 0 || a.Verdict != ComposeCandidateRefused { - return fmt.Errorf("delivery: %s could not compose the candidate (%s), and its answer names modules or no refusal", a.Node, a.Refused) + if len(a.Modules) > 0 || a.Verdict != ComposeCandidateRefused && a.Verdict != ComposeCandidateUnready { + return fmt.Errorf("delivery: %s could not compose the candidate (%s), and its answer names modules or is neither %s nor %s", + a.Node, a.Refused, ComposeCandidateRefused, ComposeCandidateUnready) } return nil } diff --git a/go/delivery/compose_candidate_test.go b/go/delivery/compose_candidate_test.go index b7d069d..196ac89 100644 --- a/go/delivery/compose_candidate_test.go +++ b/go/delivery/compose_candidate_test.go @@ -64,6 +64,20 @@ func TestComposeCandidateAndComposedPlanRoundTripAndRefuse(t *testing.T) { if _, err := EncodeComposeCandidateAnswer(whole); err != nil { t.Errorf("a whole refusal was not encoded: %v", err) } + unready := ComposeCandidateAnswer{Node: "ace", Candidate: key, Verdict: ComposeCandidateUnready, Refused: "the agent is starting"} + if raw, err := EncodeComposeCandidateAnswer(unready); err != nil { + t.Errorf("an unready answer was not encoded: %v", err) + } else if back, err := DecodeComposeCandidateAnswer(raw); err != nil || back.Verdict != ComposeCandidateUnready { + t.Errorf("an unready answer read back as %+v, %v", back, err) + } + unready.Refused = "" + if _, err := EncodeComposeCandidateAnswer(unready); err == nil { + t.Error("an unready answer saying no why was encoded") + } + unready.Refused, unready.Modules = "starting", good.Modules + if _, err := EncodeComposeCandidateAnswer(unready); err == nil { + t.Error("an unready answer naming modules was encoded") + } if _, err := DecodeComposeCandidateAnswer([]byte(strings.Replace(string(raw), `"took"`, `"other":1,"took"`, 1))); err == nil { t.Error("an answer with an unknown key was read") } -- 2.54.0 From 318b0dee69c221d0f9ad606b0ec9b3b52b8826c8 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 18:45:00 +0100 Subject: [PATCH 13/22] =?UTF-8?q?Remove=20the=20composition=20from=20mesh-?= =?UTF-8?q?sdk:=20compose,=20its=20bus=20and=20runtime=20readers,=20takein?= =?UTF-8?q?,=20validate,=20the=20composer's=20run=20and=20command,=20overl?= =?UTF-8?q?ay's=20generator=20and=20delivery's=20move=20judge=20now=20live?= =?UTF-8?q?=20in=20the=20node-engine,=20and=20the=20placement=20judge=20in?= =?UTF-8?q?=20the=20assigner=20(ADR=200593=20=C2=A71,=20=C2=A76,=20=C2=A77?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/catalogue/busy_test.go | 10 - go/catalogue/health_test.go | 14 +- go/catalogue/runtime_health_test.go | 9 - go/cmd/mesh-compose/main.go | 154 --- go/cmd/mesh-compose/main_test.go | 329 ----- go/cmd/mesh-compose/reads-outside | 1 - go/compose/composed.go | 559 -------- go/compose/composed_test.go | 200 --- go/compose/credentials.go | 498 ------- go/compose/credentials_needed.go | 69 - go/compose/credentials_test.go | 549 -------- go/compose/fixture_test.go | 90 -- go/compose/frombus/answer.go | 582 -------- go/compose/frombus/answer_rows_test.go | 595 --------- go/compose/frombus/export.go | 112 -- go/compose/frombus/frombus.go | 1167 ----------------- go/compose/frombus/frombus_test.go | 869 ------------ go/compose/frombus/frombustest/frombustest.go | 183 --- .../frombus/frombustest/frombustest_test.go | 272 ---- ...5aa5e6-mesh-provisioner-client.module.json | 60 - .../backups.conf | 2 - .../catalogue-c05aa5e6-restic.files/data.conf | 2 - .../retention.conf | 6 - .../catalogue-c05aa5e6-restic.module.json | 199 --- go/compose/frombus/held_rows_test.go | 298 ----- go/compose/frombus/states.go | 204 --- ...5aa5e6-mesh-provisioner-client.module.json | 60 - .../backups.conf | 2 - .../catalogue-c05aa5e6-restic.files/data.conf | 2 - .../retention.conf | 6 - .../catalogue-c05aa5e6-restic.module.json | 199 --- .../testdata/de-spiegel-e25e1beb.module.json | 129 -- .../testdata/f1-circuits-c0ea2c94.module.json | 82 -- go/compose/frombus/twice.go | 147 --- go/compose/fromruntime/fromruntime.go | 101 -- go/compose/input.go | 365 ------ go/compose/judge/judge.go | 372 ------ go/compose/judge/judge_test.go | 89 -- go/compose/network.go | 307 ----- go/compose/network_test.go | 306 ----- go/compose/node.go | 351 ----- go/compose/node_test.go | 424 ------ go/compose/one_machine_alone_test.go | 167 --- go/compose/render.go | 150 --- go/compose/rendering.go | 543 -------- go/compose/rendering_test.go | 329 ----- go/compose/resolve.go | 347 ----- go/compose/resolve_test.go | 388 ------ go/compose/rest.go | 255 ---- go/compose/rest_test.go | 231 ---- go/compose/seats_test.go | 22 - go/composer/composer.go | 187 +-- go/composer/composer_test.go | 118 -- go/delivery/answer_paths.go | 41 - go/delivery/judge.go | 365 ------ go/delivery/judge_test.go | 94 -- go/delivery/placement_test.go | 6 - go/overlay/declaration.go | 197 --- go/overlay/declaration_test.go | 311 ----- go/overlay/doc.go | 8 +- go/overlay/generator.go | 195 --- go/overlay/generator_test.go | 30 - go/overlay/graph.go | 231 ---- go/overlay/graph_test.go | 342 ----- go/overlay/opens_test.go | 113 -- go/overlay/overlay.go | 79 ++ go/takein/takein.go | 134 -- go/validate/sdk_test.go | 31 - go/validate/validate.go | 42 - go/validate/validate_test.go | 50 - 70 files changed, 92 insertions(+), 14889 deletions(-) delete mode 100644 go/cmd/mesh-compose/main.go delete mode 100644 go/cmd/mesh-compose/main_test.go delete mode 100644 go/cmd/mesh-compose/reads-outside delete mode 100644 go/compose/composed.go delete mode 100644 go/compose/composed_test.go delete mode 100644 go/compose/credentials.go delete mode 100644 go/compose/credentials_needed.go delete mode 100644 go/compose/credentials_test.go delete mode 100644 go/compose/fixture_test.go delete mode 100644 go/compose/frombus/answer.go delete mode 100644 go/compose/frombus/answer_rows_test.go delete mode 100644 go/compose/frombus/export.go delete mode 100644 go/compose/frombus/frombus.go delete mode 100644 go/compose/frombus/frombus_test.go delete mode 100644 go/compose/frombus/frombustest/frombustest.go delete mode 100644 go/compose/frombus/frombustest/frombustest_test.go delete mode 100644 go/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-mesh-provisioner-client.module.json delete mode 100644 go/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/backups.conf delete mode 100644 go/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/data.conf delete mode 100644 go/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/retention.conf delete mode 100644 go/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.module.json delete mode 100644 go/compose/frombus/held_rows_test.go delete mode 100644 go/compose/frombus/states.go delete mode 100644 go/compose/frombus/testdata/catalogue-c05aa5e6-mesh-provisioner-client.module.json delete mode 100644 go/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/backups.conf delete mode 100644 go/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/data.conf delete mode 100644 go/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/retention.conf delete mode 100644 go/compose/frombus/testdata/catalogue-c05aa5e6-restic.module.json delete mode 100644 go/compose/frombus/testdata/de-spiegel-e25e1beb.module.json delete mode 100644 go/compose/frombus/testdata/f1-circuits-c0ea2c94.module.json delete mode 100644 go/compose/frombus/twice.go delete mode 100644 go/compose/fromruntime/fromruntime.go delete mode 100644 go/compose/input.go delete mode 100644 go/compose/judge/judge.go delete mode 100644 go/compose/judge/judge_test.go delete mode 100644 go/compose/network.go delete mode 100644 go/compose/network_test.go delete mode 100644 go/compose/node.go delete mode 100644 go/compose/node_test.go delete mode 100644 go/compose/one_machine_alone_test.go delete mode 100644 go/compose/render.go delete mode 100644 go/compose/rendering.go delete mode 100644 go/compose/rendering_test.go delete mode 100644 go/compose/resolve.go delete mode 100644 go/compose/resolve_test.go delete mode 100644 go/compose/rest.go delete mode 100644 go/compose/rest_test.go delete mode 100644 go/compose/seats_test.go delete mode 100644 go/delivery/judge.go delete mode 100644 go/delivery/judge_test.go delete mode 100644 go/overlay/declaration.go delete mode 100644 go/overlay/declaration_test.go delete mode 100644 go/overlay/generator.go delete mode 100644 go/overlay/generator_test.go delete mode 100644 go/overlay/graph.go delete mode 100644 go/overlay/graph_test.go delete mode 100644 go/overlay/opens_test.go create mode 100644 go/overlay/overlay.go delete mode 100644 go/takein/takein.go delete mode 100644 go/validate/sdk_test.go delete mode 100644 go/validate/validate.go delete mode 100644 go/validate/validate_test.go diff --git a/go/catalogue/busy_test.go b/go/catalogue/busy_test.go index 42ca6d4..fbaa553 100644 --- a/go/catalogue/busy_test.go +++ b/go/catalogue/busy_test.go @@ -3,8 +3,6 @@ package catalogue import ( "encoding/json" "testing" - - "git.novox.be/novox/mesh-sdk/go/validate" ) // A long-running container may say it is busy (novox/hq ADR 0335 §1): `module check` holds the field to its @@ -52,14 +50,6 @@ func TestABusyIsSentOnlyToAnEngineThatReadsIt(t *testing.T) { if got["command"] != "mesh-builder busy" || got["timeout"] != "10s" { t.Fatalf("busy composed as %v", got) } - // And the engine's own validator takes the container with it. - body, err := json.Marshal(map[string]any{"declaration": validate.Version, "resources": newer}) - if err != nil { - t.Fatal(err) - } - if problems := validate.Declaration(body); len(problems) > 0 { - t.Fatalf("the node-engine's validator refuses the composed busy: %v", problems) - } if _, has := m.Resources[len(m.Resources)-1][BusyField].(map[string]any)["timeout"]; has { t.Error("composing wrote the timeout into the catalogue's own manifest") } diff --git a/go/catalogue/health_test.go b/go/catalogue/health_test.go index 3b50ec9..0914830 100644 --- a/go/catalogue/health_test.go +++ b/go/catalogue/health_test.go @@ -4,8 +4,6 @@ import ( "encoding/json" "strings" "testing" - - "git.novox.be/novox/mesh-sdk/go/validate" ) // A module says how each long-running resource is ready (novox/hq ADR 0240 rule 2, to-be 48 §8): `module @@ -147,17 +145,9 @@ func TestEveryComposedHealthIsOneTheNodeEngineTakes(t *testing.T) { t.Fatal(err) } m.Resources[1]["health"] = map[string]any{"kind": "unit"} - out, err := Resolution{Node: "laptop", Modules: []Manifest{m}}.Declaration(Rendering{ReadsHealth: true, - Ports: map[string]map[int]int{"app": {8080: 31001}}}) - if err != nil { + if _, err := (Resolution{Node: "laptop", Modules: []Manifest{m}}).Declaration(Rendering{ReadsHealth: true, + Ports: map[string]map[int]int{"app": {8080: 31001}}}); err != nil { t.Fatal(err) } - body, err := json.Marshal(map[string]any{"declaration": validate.Version, "resources": out}) - if err != nil { - t.Fatal(err) - } - if problems := validate.Declaration(body); len(problems) > 0 { - t.Errorf("%v composed into something the node-engine refuses: %v", h, problems) - } } } diff --git a/go/catalogue/runtime_health_test.go b/go/catalogue/runtime_health_test.go index d2b075f..3d50cc2 100644 --- a/go/catalogue/runtime_health_test.go +++ b/go/catalogue/runtime_health_test.go @@ -3,8 +3,6 @@ package catalogue import ( "encoding/json" "testing" - - "git.novox.be/novox/mesh-sdk/go/validate" ) // novox/hq ADR 0366: a module that runs as its own account declares its runtime's health under one manifest @@ -88,13 +86,6 @@ func TestARuntimeHealthIsComposedOntoTheModulesOwnRuntime(t *testing.T) { if machine := fileNamed(out, RuntimeModule+"."+RuntimeProcessID()); machine[HealthField] != nil { t.Errorf("the machine's runtime was given the module's health: %v", machine[HealthField]) } - body, err := json.Marshal(map[string]any{"declaration": validate.Version, "resources": out}) - if err != nil { - t.Fatal(err) - } - if problems := validate.Declaration(body); len(problems) > 0 { - t.Errorf("composed into something the node-engine refuses: %v", problems) - } } // The catalogue's own manifest is not written into. if _, ok := keeper.RuntimeHealth.(map[string]any)["grace"]; ok { diff --git a/go/cmd/mesh-compose/main.go b/go/cmd/mesh-compose/main.go deleted file mode 100644 index 9c38a8f..0000000 --- a/go/cmd/mesh-compose/main.go +++ /dev/null @@ -1,154 +0,0 @@ -// mesh-compose is mesh-sdk's composer command (novox/hq ADR 0482 clause 4, issue 1621): it reads a composer.Request on -// standard input, validates every manifest with this mesh-sdk's reader, composes the machines named in each case and -// writes one composer.Verdict on standard output. Built at any commit of mesh-sdk (or at the mesh-sdk a module pins), -// it answers what that commit's composition answers, so a program that links one mesh-sdk can have another's judged. -// -// mesh-compose < request.json > verdict.json -// mesh-compose -request request.json > verdict.json -// mesh-compose -catalogue [-node ]... [-depth resolve|compose] [-declarations] [< request.json] -// -// -catalogue reads /modules/*/module.json and a module.json at 's root as the first case's manifests (a -// checkout or `git archive` of the catalogue repository at a ref: the command fetches nothing). -node adds a machine to -// compose; -declarations answers each machine's declared paths with their owners and modes. -// -// It opens no network connection, bus, store or file beyond its standard input, the -request file and the -catalogue -// directory, and reads -// no environment. Exit codes: 0 the verdict is written (a machine that does not compose is a verdict, not an error); -// 1 the request cannot be read or run, said on standard error; 2 the flags are wrong. -package main - -import ( - "encoding/json" - "errors" - "flag" - "fmt" - "io" - "os" - "path/filepath" - "sort" - "strings" - - "git.novox.be/novox/mesh-sdk/go/composer" -) - -func main() { - os.Exit(run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) -} - -type nodes []string - -func (n *nodes) String() string { return strings.Join(*n, ",") } -func (n *nodes) Set(v string) error { *n = append(*n, v); return nil } - -// run is the command: its arguments, standard input, output and error; it answers the exit code. -func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { - fs := flag.NewFlagSet("mesh-compose", flag.ContinueOnError) - fs.SetOutput(stderr) - catalogueDir := fs.String("catalogue", "", "a catalogue checkout: /modules/*/module.json are the first case's manifests") - var machines nodes - fs.Var(&machines, "node", "a machine to compose (repeatable)") - depth := fs.String("depth", "", "resolve or compose (default: the request's, else compose)") - declarations := fs.Bool("declarations", false, "answer each composed machine's declared paths, owners and modes") - requestFile := fs.String("request", "", "read the request from this file instead of standard input (a run step that hands no standard input)") - if err := fs.Parse(args); err != nil { - return 2 - } - if fs.NArg() > 0 { - fmt.Fprintf(stderr, "mesh-compose: unexpected arguments %q\n", fs.Args()) - return 2 - } - var raw []byte - var err error - if *requestFile != "" { - raw, err = os.ReadFile(*requestFile) - } else { - raw, err = io.ReadAll(stdin) - } - if err != nil { - fmt.Fprintln(stderr, "mesh-compose: the request cannot be read:", err) - return 1 - } - var req composer.Request - if strings.TrimSpace(string(raw)) == "" { - if *catalogueDir == "" { - fmt.Fprintln(stderr, "mesh-compose: no request on standard input and no -catalogue") - return 1 - } - req = composer.Request{V: composer.Version} - } else if req, err = composer.Decode(raw); err != nil { - fmt.Fprintln(stderr, "mesh-compose:", err) - return 1 - } - if *catalogueDir != "" { - read, err := readCatalogue(*catalogueDir) - if err != nil { - fmt.Fprintln(stderr, "mesh-compose:", err) - return 1 - } - if len(req.Cases) == 0 { - req.Cases = []composer.Case{{Name: "catalogue", Manifests: map[string]composer.Manifest{}}} - } - if req.Cases[0].Manifests == nil { - req.Cases[0].Manifests = map[string]composer.Manifest{} - } - for name, m := range read { - if _, given := req.Cases[0].Manifests[name]; !given { - req.Cases[0].Manifests[name] = m - } - } - // No list of predefined keys is read: the composer judges no manifest (novox/hq ADR 0578 §5). - } - req.Machines = append(req.Machines, machines...) - if *depth != "" { - req.Depth = *depth - } - if *declarations { - req.Declarations = true - } - v, err := composer.Run(req) - if err != nil { - fmt.Fprintln(stderr, "mesh-compose:", err) - return 1 - } - enc := json.NewEncoder(stdout) - if err := enc.Encode(v); err != nil { - fmt.Fprintln(stderr, "mesh-compose: the verdict cannot be written:", err) - return 1 - } - return 0 -} - -// readCatalogue reads every module.json of a catalogue checkout by its module's name: the root's and each of -// modules/*/. A manifest whose name cannot be read is kept under its directory's name, so the reader's refusal is said. -func readCatalogue(dir string) (map[string]composer.Manifest, error) { - paths, err := filepath.Glob(filepath.Join(dir, "modules", "*", "module.json")) - if err != nil { - return nil, err - } - if _, err := os.Stat(filepath.Join(dir, "module.json")); err == nil { - paths = append(paths, filepath.Join(dir, "module.json")) - } - if len(paths) == 0 { - return nil, fmt.Errorf("the catalogue %s holds no module.json", dir) - } - sort.Strings(paths) - out := map[string]composer.Manifest{} - for _, p := range paths { - raw, err := os.ReadFile(p) - if err != nil { - return nil, err - } - var probe struct { - Module string `json:"module"` - } - name := filepath.Base(filepath.Dir(p)) - if json.Unmarshal(raw, &probe) == nil && probe.Module != "" { - name = probe.Module - } - if _, twice := out[name]; twice { - return nil, errors.New("the catalogue " + dir + " defines " + name + " twice") - } - out[name] = composer.Manifest{Raw: raw} - } - return out, nil -} diff --git a/go/cmd/mesh-compose/main_test.go b/go/cmd/mesh-compose/main_test.go deleted file mode 100644 index 48d4b77..0000000 --- a/go/cmd/mesh-compose/main_test.go +++ /dev/null @@ -1,329 +0,0 @@ -package main - -import ( - "bytes" - "encoding/json" - "go/build" - "os" - "os/exec" - "path/filepath" - "reflect" - "runtime" - "sort" - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/compose" - "git.novox.be/novox/mesh-sdk/go/composer" - "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/internal/beside" -) - -// captured is every manifest of the catalogue captured in testdata/beside (internal/beside), raw, by module name. -func captured(t *testing.T) map[string]composer.Manifest { - t.Helper() - read, err := readCatalogue(filepath.Dir(beside.Catalogue(t))) - if err != nil { - t.Fatal(err) - } - if len(read) < 100 { - t.Fatalf("the captured catalogue holds %d manifests, fewer than the catalogue's", len(read)) - } - return read -} - -// meshOf is a mesh of three machines over the captured catalogue, each assigned real modules, the input the assigner -// composes with (nodes and their assignments, holdings from the claims): anchor runs the bus and the store, laptop a -// desktop's modules, ace a module whose requirement is provided elsewhere. -func meshOf() compose.Input { - return compose.Input{Nodes: []compose.Node{ - {Name: "anchor", Assigned: []string{"nats", "postgres"}}, - {Name: "laptop", Assigned: []string{"sudo", "zsh"}}, - {Name: "ace", Assigned: []string{"mesh-reads", "nats"}}, - }} -} - -func request(t *testing.T, depth string) composer.Request { - t.Helper() - return composer.Request{V: composer.Version, Input: rawInput(meshOf()), Depth: depth, HoldingsFromClaims: true, - Machines: []string{"anchor", "laptop", "ace"}, - Cases: []composer.Case{{Name: "trunk", Manifests: captured(t)}}} -} - -func runJSON(t *testing.T, args []string, in []byte) (int, composer.Verdict, string) { - t.Helper() - var out, errOut bytes.Buffer - code := run(args, bytes.NewReader(in), &out, &errOut) - var v composer.Verdict - if code == 0 { - if err := json.Unmarshal(out.Bytes(), &v); err != nil { - t.Fatalf("the verdict is not JSON: %v\n%s", err, out.String()) - } - } - return code, v, errOut.String() -} - -func encode(t *testing.T, r composer.Request) []byte { - t.Helper() - raw, err := json.Marshal(r) - if err != nil { - t.Fatal(err) - } - return raw -} - -// 1621/AC1: the command reads the plain input and the manifests on standard input, validates every manifest, composes -// the machines named and writes one verdict: per machine composes or the reason, per manifest accepted or the field -// refused. -func TestTheCommandWritesOneVerdictPerManifestAndMachine(t *testing.T) { - r := request(t, composer.DepthResolve) - r.Cases[0].Manifests["later"] = composer.Manifest{Raw: json.RawMessage(`{"module":"later","version":"1","shares":["x"]}`)} - r.Cases[0].Manifests["refused"] = composer.Manifest{Raw: json.RawMessage(`{"module":"refused","version":"1"}`)} - r.Cases[0].Verdicts = map[string]delivery.ManifestVerdict{"refused": {State: delivery.VerdictRefused, Problems: []string{"it is wrong"}}} - code, v, stderr := runJSON(t, nil, encode(t, r)) - if code != 0 { - t.Fatalf("exit %d: %s", code, stderr) - } - if len(v.Cases) != 1 || v.Cases[0].Name != "trunk" || v.SDK == "" { - t.Fatalf("verdict = %+v", v) - } - c := v.Cases[0] - // ADR 0578 §5, §6: the composer judges nothing; it sets an unknown field aside and names it, and holds a manifest - // its verdict refuses, named in the indexer's words. - if got := c.Manifests["later"]; !got.Accepted || !strings.Contains(got.SetAside, `"shares"`) { - t.Errorf("the manifest with an unknown field reads %+v, want accepted naming the field set aside", got) - } - if got := c.Manifests["refused"]; got.Accepted || !strings.Contains(got.Refused, "refused the manifest of refused: it is wrong") { - t.Errorf("the refused manifest reads %+v, want refused in the verdict's words", got) - } - if got := c.Manifests["nats"]; !got.Accepted { - t.Errorf("nats reads %+v, want accepted", got) - } - if len(c.Machines) != 3 { - t.Fatalf("machines = %v, want the three named", c.Machines) - } - for name, m := range c.Machines { - if !m.Composes && m.Reason == "" { - t.Errorf("%s does not compose and says no reason", name) - } - } -} - -// 1621/AC3: over the captured catalogue and a mesh of three machines, the command's verdict equals, machine for -// machine and manifest for manifest, what the linked reader, resolution and composition answer, at both depths. -func TestTheCommandAnswersWhatTheLinkedCompositionAnswers(t *testing.T) { - for _, depth := range []string{composer.DepthResolve, composer.DepthCompose} { - r := request(t, depth) - code, v, stderr := runJSON(t, nil, encode(t, r)) - if code != 0 { - t.Fatalf("%s: exit %d: %s", depth, code, stderr) - } - in := meshOf() - in.Catalogue = map[string]catalogue.Manifest{} - for name, m := range r.Cases[0].Manifests { - // A request without a list of predefined keys reads a fallback as the indexer does (Request.Predefined). - parsed, err := catalogue.Decode(m.Raw) - got := v.Cases[0].Manifests[name] - if (err == nil) != got.Accepted || (err != nil && err.Error() != got.Refused) { - t.Errorf("%s: the manifest of %s: linked %v, command %+v", depth, name, err, got) - } - if err == nil { - in.Catalogue[name] = parsed - } - } - in.Holdings = composer.HoldingsOf(in.Catalogue, in.Nodes) - composed := 0 - for _, node := range r.Machines { - got := v.Cases[0].Machines[node] - var want composer.Machine - if depth == composer.DepthResolve { - if _, err := compose.Resolve(in, node); err != nil { - want = composer.Machine{Reason: err.Error()} - } else { - want = composer.Machine{Composes: true} - } - } else { - d, problems, err := compose.Compose(in, node) - switch { - case err != nil: - want = composer.Machine{Reason: err.Error()} - default: - want = composer.Machine{Composes: len(problems) == 0, Problems: problems, Refusals: d.Refusals()} - if len(problems) > 0 { - want.Reason = "the node-engine's validator refuses the declaration" - } - } - } - if !reflect.DeepEqual(got, want) { - t.Errorf("%s: %s: command %+v, linked %+v", depth, node, got, want) - } - if got.Composes { - composed++ - } else { - t.Logf("%s: %s does not compose: %s %v", depth, node, got.Reason, got.Problems) - } - } - t.Logf("%s: %d of %d machines compose", depth, composed, len(r.Machines)) - } -} - -// 1621 note 6: a person composes a catalogue checkout (repository@ref checked out or archived: the command fetches -// nothing) for a node and reads its declaration: each path with its kind, owner and mode. -func TestACatalogueCheckoutIsComposedForANode(t *testing.T) { - dir := t.TempDir() - if err := os.MkdirAll(filepath.Join(dir, "modules", "demo"), 0o755); err != nil { - t.Fatal(err) - } - manifest := `{"module": "demo", "version": "1", "resources": [{"id": "state", "type": "directory", ` + - `"path": "/var/lib/demo", "mode": "0750", "owner": "root"}]}` - if err := os.WriteFile(filepath.Join(dir, "modules", "demo", "module.json"), []byte(manifest), 0o644); err != nil { - t.Fatal(err) - } - in := encode(t, composer.Request{V: composer.Version, Input: rawInput(compose.Input{Nodes: []compose.Node{{Name: "laptop", Assigned: []string{"demo"}}}})}) - code, v, stderr := runJSON(t, []string{"-catalogue", dir, "-node", "laptop", "-declarations"}, in) - if code != 0 { - t.Fatalf("exit %d: %s", code, stderr) - } - m := v.Cases[0].Machines["laptop"] - want := []composer.Path{{Path: "/var/lib/demo", Kind: "directory", Owner: "root", Mode: "0750", Module: ""}} - if !m.Composes || len(m.Declared) != 1 || m.Declared[0].Path != want[0].Path || m.Declared[0].Mode != "0750" || - m.Declared[0].Owner != "root" || m.Declared[0].Kind != "directory" { - t.Fatalf("laptop = %+v, want it composed declaring %+v", m, want) - } -} - -// A run step hands the command no standard input: the request is read from the file -request names, and a file that is -// not there is a request it cannot read. -func TestTheRequestIsReadFromAFile(t *testing.T) { - file := filepath.Join(t.TempDir(), "request.json") - r := composer.Request{V: composer.Version, Cases: []composer.Case{{Name: "only", Manifests: map[string]composer.Manifest{ - "demo": {Raw: json.RawMessage(`{"module":"demo","version":"1"}`)}}}}} - if err := os.WriteFile(file, encode(t, r), 0o600); err != nil { - t.Fatal(err) - } - code, v, stderr := runJSON(t, []string{"-request", file}, nil) - if code != 0 || len(v.Cases) != 1 || !v.Cases[0].Manifests["demo"].Accepted { - t.Fatalf("exit %d (%s), verdict %+v", code, stderr, v) - } - if code, _, stderr := runJSON(t, []string{"-request", file + ".gone"}, nil); code != 1 || !strings.Contains(stderr, "cannot be read") { - t.Errorf("a request file that is not there: exit %d, %q", code, stderr) - } -} - -// The exit codes: 1 for a request that cannot be read or run, 2 for wrong flags, each said on standard error. -func TestTheExitCodesSayWhatFailed(t *testing.T) { - unfilled := encode(t, composer.Request{V: composer.Version, Cases: []composer.Case{{Name: "c", Manifests: map[string]composer.Manifest{"x": {Digest: "abc"}}}}}) - for _, c := range []struct { - name string - args []string - in string - code int - says string - }{ - {"not JSON", nil, "{", 1, "not JSON"}, - {"another version", nil, `{"v":2}`, 1, "version 2"}, - {"a digest left unfilled", nil, string(unfilled), 1, "digest"}, - {"an unknown depth", nil, `{"v":1,"depth":"deep"}`, 1, "depth"}, - {"nothing to read", nil, "", 1, "no request"}, - {"a wrong flag", []string{"-nope"}, "", 2, "nope"}, - {"an argument", []string{"extra"}, "", 2, "unexpected"}, - } { - var out, errOut bytes.Buffer - if got := run(c.args, strings.NewReader(c.in), &out, &errOut); got != c.code || !strings.Contains(errOut.String(), c.says) { - t.Errorf("%s: exit %d saying %q, want %d saying %q", c.name, got, errOut.String(), c.code, c.says) - } - } -} - -// sdkPackages is every package of this module the command imports, directly or not, with the packages each imports. -func sdkPackages(t *testing.T) map[string][]string { - t.Helper() - _, file, _, _ := runtime.Caller(0) - root := filepath.Join(filepath.Dir(file), "..", "..") - const module = "git.novox.be/novox/mesh-sdk/go" - out := map[string][]string{} - var walk func(path string) - walk = func(path string) { - if _, done := out[path]; done { - return - } - rel := strings.TrimPrefix(strings.TrimPrefix(path, module), "/") - pkg, err := build.Default.ImportDir(filepath.Join(root, rel), 0) - if err != nil { - t.Fatalf("%s: %v", path, err) - } - out[path] = pkg.Imports - for _, imp := range pkg.Imports { - if strings.HasPrefix(imp, module+"/") { - walk(imp) - } - } - } - walk(composer.Package) - return out -} - -// 1621/AC2 and AC4: the command imports, through every package of mesh-sdk it reaches, only the standard library and -// mesh-sdk's own packages (so `go build` from mesh-sdk's module needs nothing beyond its lock), and none that reaches -// a bus, a store, a network service or another process. -func TestTheCommandImportsNoBusStoreNetworkOrProcess(t *testing.T) { - forbidden := []string{"net/http", "net/rpc", "net/smtp", "os/exec", "database/sql", "plugin", "syscall"} - for path, imports := range sdkPackages(t) { - for _, imp := range imports { - std := !strings.Contains(strings.SplitN(imp, "/", 2)[0], ".") - if !std && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-sdk/go/") { - t.Errorf("%s imports %s, which is neither the standard library nor mesh-sdk", path, imp) - } - for _, f := range forbidden { - if imp == f { - t.Errorf("%s imports %s", path, imp) - } - } - } - } -} - -// 1621/AC2: run with an empty network namespace, the command still writes its verdict: it needs no network. The test -// runs its own binary again under `unshare --net` as an unprivileged user's namespace; where the kernel or the sandbox -// allows no new namespace (a build module's run step is already one with no network), it says so and skips. -func TestTheCommandRunsWithNoNetwork(t *testing.T) { - if os.Getenv("MESH_COMPOSE_HELPER") == "1" { - os.Exit(run(nil, os.Stdin, os.Stdout, os.Stderr)) - } - unshare, err := exec.LookPath("unshare") - if err != nil { - t.Skip("no unshare on this machine, so no empty network namespace can be made here") - } - if out, err := exec.Command(unshare, "--net", "--map-root-user", "true").CombinedOutput(); err != nil { - t.Skipf("this machine makes no unprivileged network namespace (%v: %s); a run step's own has no network", err, out) - } - r := request(t, composer.DepthResolve) - cmd := exec.Command(unshare, "--net", "--map-root-user", os.Args[0], "-test.run=^TestTheCommandRunsWithNoNetwork$") - cmd.Env = []string{"MESH_COMPOSE_HELPER=1"} - cmd.Stdin = bytes.NewReader(encode(t, r)) - var out, errOut bytes.Buffer - cmd.Stdout, cmd.Stderr = &out, &errOut - if err := cmd.Run(); err != nil { - t.Fatalf("the command in an empty network namespace: %v: %s", err, errOut.String()) - } - var v composer.Verdict - if err := json.Unmarshal(out.Bytes(), &v); err != nil || len(v.Cases) != 1 || len(v.Cases[0].Machines) != 3 { - t.Fatalf("no verdict from the command with no network: %v\n%s", err, out.String()) - } - names := make([]string, 0, len(v.Cases[0].Machines)) - for n := range v.Cases[0].Machines { - names = append(names, n) - } - sort.Strings(names) - t.Logf("composed with no network: %v", names) -} - -// rawInput is a composition input as a request carries it. -func rawInput(in compose.Input) json.RawMessage { - raw, err := json.Marshal(in) - if err != nil { - panic(err) - } - return raw -} diff --git a/go/cmd/mesh-compose/reads-outside b/go/cmd/mesh-compose/reads-outside deleted file mode 100644 index f6b2d87..0000000 --- a/go/cmd/mesh-compose/reads-outside +++ /dev/null @@ -1 +0,0 @@ -These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935). diff --git a/go/compose/composed.go b/go/compose/composed.go deleted file mode 100644 index 07258d3..0000000 --- a/go/compose/composed.go +++ /dev/null @@ -1,559 +0,0 @@ -package compose - -import ( - "encoding/json" - "errors" - "fmt" - "slices" - "sort" - "strings" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/declaration" - "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/overlay" -) - -// Compositions is one node's compositions (novox/hq ADR 0539 clauses 1 and 2): its declaration composed over in, as -// the controller composes it, split into each module's composition at the fingerprint fps gives it and the node's own -// (`.node`), by key (delivery.ComposedKey). Each resource says its module, whatever the node reported it reads. -// A module fps gives no fingerprint for is not composed and is named in skipped, so a caller composing one module -// gets that one; but for a module the node runs inside its own composition (Node.InOwn, and the -// private network's always), which fps names at no fingerprint of the new form: its resources go into the node's own, -// their module taken off and their ids still naming it, and its filled paths with them, as the controller's composed -// writer folds them (issue 1126). Each module's Data says whether its manifest declares data. The compositions are not -// signed and not yet by digest: the writer does both (delivery.SignComposed, Composed.ByDigest). -func Compositions(in Input, node string, fps map[string]string, mode Mode) (out map[string]delivery.Composed, skipped []string, err error) { - out, skipped, _, err = compositions(in, node, fps, mode, false) - return out, skipped, err -} - -// ErrStoodIn is a composition that holds a stand-in for a value the input does not carry (an own secret, a grant's -// sealed copy, a vault box, the certificate, the bus's user list) or a port it does not give: what a send would make, -// never what may be applied. -var ErrStoodIn = errors.New("compose: the composition stands in for what its input does not carry") - -// BusUsersStandIn is what a composition says it stood in for when its input carries no bus user list. -const BusUsersStandIn = "the bus's user list" - -// CompositionsApplied is Compositions for a node that applies what it composed itself (novox/hq ADR 0547 §2, issue -// 1126): a declaration that stands in for anything (Declared.StoodIn, Foreseen, ForeseenPorts) is refused with -// ErrStoodIn, naming each, so a stand-in is never applied as if it were the value; but for the bus's user list -// (BusUsersStandIn), which the bus node holds out of every apply of a composition it made itself. -func CompositionsApplied(in Input, node string, fps map[string]string) (map[string]delivery.Composed, []string, error) { - out, skipped, _, err := compositions(in, node, fps, Reading, true) - return out, skipped, err -} - -// CompositionsShadow is CompositionsApplied for a composition that is compared and never applied (a node's answer to a -// placement candidate, novox/hq ADR 0559 §4): composed in the Foreseeing mode, so an own secret never issued (a module -// the candidate adds) is a named stand-in rather than an error, and what it stands in for is answered (stood, sorted, -// the bus's user list left out) rather than refused, so the caller judges each stand-in: a value not yet issued for a -// module the candidate adds, or one missing for a module the node runs. A module whose every value is held composes -// as CompositionsApplied composes it: the modes differ only in an own secret never issued. -func CompositionsShadow(in Input, node string, fps map[string]string) (out map[string]delivery.Composed, skipped, stood []string, err error) { - return compositions(in, node, fps, Foreseeing, false) -} - -// CompositionsIn is the node's compositions in mode with what they stand in for answered (stood, sorted, the bus's user -// list left out) rather than refused: CompositionsShadow in Foreseeing, and in Reading what CompositionsApplied refuses -// before it is refused, so a caller that applies refuses each stand-in to the module it is for alone (issue 1126). -func CompositionsIn(in Input, node string, fps map[string]string, mode Mode) (out map[string]delivery.Composed, skipped, stood []string, err error) { - return compositions(in, node, fps, mode, false) -} - -func compositions(in Input, node string, fps map[string]string, mode Mode, applied bool) (out map[string]delivery.Composed, skipped, stood []string, err error) { - reading := in - reading.Nodes = append([]Node(nil), in.Nodes...) - found := false - inOwn := map[string]bool{} - held := map[string]bool{} - for i := range reading.Nodes { - if reading.Nodes[i].Name == node { - reading.Nodes[i].ReadsModules = true - found = true - for m, ok := range reading.Nodes[i].InOwn { - inOwn[m] = ok - } - for m, ok := range reading.Nodes[i].Held { - held[m] = ok - } - } - } - // The private network's module is built by nobody: no fingerprint of the new form ever names it. - inOwn[overlay.Name] = true - folds := func(module string) bool { - return module != "" && inOwn[module] && !strings.HasPrefix(fps[module], delivery.SourcePrefix2) - } - if !found { - return nil, nil, nil, fmt.Errorf("compose: the mesh has no node %q", node) - } - // A folded module built by a build of the old form (Node.RunsOwn) is left to the node: composed from what runs of it - // (Carried), so what it provides and requires still shapes the rest and what the others read of it (its published - // ports, the paths it owns) is what runs, and its resources are then the ones the node runs. - carried := map[string]bool{} - var runsOwn *delivery.Composed - for i := range reading.Nodes { - n := &reading.Nodes[i] - if n.Name != node || n.RunsOwn == nil { - continue - } - runsOwn = n.RunsOwn - candidates := map[string]bool{} - for m := range inOwn { - candidates[m] = true - } - for m := range held { - candidates[m] = true - } - for m := range candidates { - // A held module is carried as it runs wherever the node's own runs it, folded or leaving the fold for a - // composition of its own (its want new, its names still unset): its stub is never what the node's own applies, - // and what the node's own applies never drops what runs of it (issue 1126, review of the apply; issue 1945). - heldHere := held[m] && RunsAnyOf(runsOwn, m) - if !folds(m) && !heldHere { - continue - } - man, ok := n.Kept[m] - if !ok { - man, ok = reading.Catalogue[m] - } - if !ok || (!NamesBuild(man) && !held[m]) { - continue - } - carried[m] = true - kept := make(map[string]catalogue.Manifest, len(n.Kept)+1) - for k, v := range n.Kept { - kept[k] = v - } - kept[m] = Carried(man, m, runsOwn) - 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) - } - return carried[module] - } - d, problems, err := ComposeIn(reading, node, mode) - if err != nil { - return nil, nil, nil, err - } - if len(problems) > 0 { - return nil, nil, nil, fmt.Errorf("compose: %s's declaration is refused by the node-engine's validator: %v", node, problems) - } - for _, x := range d.StoodIn { - // The bus's user list is the bus node's own to follow, written alone and never from a composition the node - // made itself (ADR 0547 §7, §8): its stand-in is held out of the apply, not applied. - if x != BusUsersStandIn { - stood = append(stood, x) - } - } - stood = append(stood, d.Foreseen...) - for _, p := range d.ForeseenPorts { - stood = append(stood, "port "+p) - } - sort.Strings(stood) - stood = slices.Compact(stood) // an own secret is both stood in for and foreseen - if applied && len(stood) > 0 { - return nil, nil, nil, fmt.Errorf("%w on %s: %s", ErrStoodIn, node, strings.Join(stood, ", ")) - } - body, err := d.Body() - if err != nil { - return nil, nil, nil, err - } - var envelope struct { - Resources []json.RawMessage `json:"resources"` - Adoption *declaration.Adoption `json:"adoption"` - Filled []string `json:"filled_files"` - Provisioner string `json:"provisioner_account"` - } - if err := json.Unmarshal(body, &envelope); err != nil { - return nil, nil, nil, err - } - var kept []json.RawMessage - keptPaths := map[string]bool{} - foldedPaths := map[string]bool{} - left := map[string]bool{} - for _, raw := range envelope.Resources { - var head struct { - ID string `json:"id"` - Module string `json:"module"` - Type string `json:"type"` - Path string `json:"path"` - } - if err := json.Unmarshal(raw, &head); err != nil { - return nil, nil, nil, err - } - if isCarried(head.Module, head.ID) { - continue // what the stub composed of it: the node's own carries what runs of it instead - } - if folds(head.Module) { - // Into the node's own, in its place among the node's resources, its module taken off (mesh-controller's - // compositionsOf): the node's own composition is then the controller's, byte for byte. - var fields map[string]json.RawMessage - if err := json.Unmarshal(raw, &fields); err != nil { - return nil, nil, nil, err - } - delete(fields, "module") - if raw, err = json.Marshal(fields); err != nil { - return nil, nil, nil, err - } - if head.Type == string(declaration.TypeFile) { - foldedPaths[head.Path] = true - } - kept = append(kept, raw) - continue - } - if _, ok := fps[head.Module]; head.Module != "" && !ok { - left[head.Module] = true - continue - } - if head.Type == string(declaration.TypeFile) { - keptPaths[head.Path] = true - } - kept = append(kept, raw) - } - // What runs of each carried module, in the node's own composition, each after the resource it runs after. - if len(carried) > 0 { - var err error - if kept, foldedPaths, err = carry(kept, foldedPaths, runsOwn, isCarried); err != nil { - return nil, nil, nil, err - } - for _, p := range runsOwn.Filled { - if foldedPaths[p] && !slices.Contains(envelope.Filled, p) { - envelope.Filled = append(envelope.Filled, p) - } - } - } - // A filled file of a module's own composition goes with that module (SplitComposed); one of a folded module goes with - // the node's own, after them, in the declaration's order; one of a module left out is dropped. - var filled, ownFilled []string - for _, p := range envelope.Filled { - switch { - case foldedPaths[p]: - ownFilled = append(ownFilled, p) - case keptPaths[p]: - filled = append(filled, p) - } - } - out, err = delivery.SplitComposed(node, kept, fps, filled, - delivery.NodeOwn{Adoption: envelope.Adoption, Provisioner: envelope.Provisioner}) - if err != nil { - return nil, nil, nil, err - } - if len(ownFilled) > 0 { - own, err := delivery.ComposedNodeKey(node) - if err != nil { - return nil, nil, nil, err - } - c := out[own] - c.Filled = append(c.Filled, ownFilled...) - out[own] = c - } - for key, c := range out { - if held[c.Module] { - delete(out, key) // its stub's: the node keeps what it last applied of it (Node.Held) - continue - } - if m, ok := in.Catalogue[c.Module]; ok && c.Module != "" { - c.Data = len(m.DataItems()) > 0 - out[key] = c - } - } - for m := range left { - skipped = append(skipped, m) - } - sort.Strings(skipped) - return out, skipped, stood, nil -} - -// RunsAnyOf says runs (a composition as it runs) holds a resource of module, by its module or the module its id names. -func RunsAnyOf(runs *delivery.Composed, module string) bool { - if runs == nil { - return false - } - for _, raw := range runs.Resources { - var head struct { - ID string `json:"id"` - Module string `json:"module"` - } - if json.Unmarshal(raw, &head) != nil { - continue - } - owner := head.Module - if owner == "" { - owner, _ = declaration.ModuleGuess(head.ID) - } - if owner == module { - return true - } - } - 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). -func NamesBuild(m catalogue.Manifest) bool { - if m.Build != nil { - return true - } - for _, r := range m.Resources { - if _, ok := r["artifact"]; ok { - return true - } - } - return false -} - -// Stub is m with nothing to build and no resource but its directories: what it provides, requires, serves and grants -// still shape the rest of the composition, the places its own secrets, data and grants name (${dir:…}) still resolve, -// and nothing else of its own is composed (issue 1126: nats's ${dir:mesh-state} on novox). A module refused in a -// node's answer is composed so; a folded one the node runs is Carried. -func Stub(m catalogue.Manifest) catalogue.Manifest { - // The paths it owns stay its manifest's, as the controller tells the holder of node-files them (issue 1126, seed-8). - if m.OwnsPaths == nil { - 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 - // (seat contributions, shell blocks, seat data) still reach their collectors. - if m.Handed != nil { - h := *m.Handed - h.Templates = nil - m.Handed = &h - } - var dirs []map[string]any - for _, r := range m.Resources { - if r["type"] == "directory" { - dirs = append(dirs, r) - } - } - m.Resources = dirs - return m -} - -// Carried is m, folded into its node's own composition at a build of the old form, as the node runs it (issue 1126, -// C5): nothing to build; its containers and directories those the node's own composition (runs) carries of it, their -// module's word taken off their ids, with the manifest's directories it does not run. So what other modules read of it -// is what runs: the machine ports its containers publish (the filter's rules, the routes reaching it: MachineSide). The -// paths it owns, which the holder of node-files is told, are its manifest's, as for its Stub: the controller composes -// them from the manifest, not from every path a resource of it runs at (issue 1126, seed-8: f1-circuits, snake and -// txt-game on ace, whose manifests leave their directories to the mesh). Its other resources are not taken, since the -// composition makes some of them again from its manifest (its secrets' files) and one id twice is refused; none of them -// is composed into what is applied either way (compositions leaves a carried module's out, and carries the ones that -// run). What it provides, requires, listens on and grants is its manifest's. A module whose resources runs holds none -// of is its Stub, as before. -func Carried(m catalogue.Manifest, module string, runs *delivery.Composed) catalogue.Manifest { - if runs == nil { - return Stub(m) - } - var taken []map[string]any - runsAny := false - ids := map[string]bool{} - for _, raw := range runs.Resources { - var r map[string]any - if json.Unmarshal(raw, &r) != nil { - continue - } - id, _ := r["id"].(string) - owner, _ := r["module"].(string) - if owner == "" { - owner, _ = declaration.ModuleGuess(id) - } - if owner != module { - continue - } - runsAny = true - if typ := r["type"]; typ != "container" && typ != "directory" { - continue - } - r["id"] = strings.TrimPrefix(id, module+".") - delete(r, "module") - ids[r["id"].(string)] = true - taken = append(taken, r) - } - if !runsAny { - return Stub(m) - } - stub := Stub(m) - for _, r := range stub.Resources { - if id, _ := r["id"].(string); !ids[id] { - taken = append(taken, r) - } - } - stub.Resources = taken - return stub -} - -// carry puts into the node's resources (kept, in the declaration's order) the resources of the node's own composition as -// it runs (runs) that belong to a carried module, each after the resource it runs after, or first when that one is -// not among them; a file among them is a folded path. -func carry(kept []json.RawMessage, foldedPaths map[string]bool, runs *delivery.Composed, carried func(module, id string) bool) ([]json.RawMessage, map[string]bool, error) { - idOf := func(raw json.RawMessage) (string, string, string, string, error) { - var head struct { - ID string `json:"id"` - Module string `json:"module"` - Type string `json:"type"` - Path string `json:"path"` - } - err := json.Unmarshal(raw, &head) - return head.ID, head.Module, head.Type, head.Path, err - } - present := map[string]bool{} - for _, raw := range kept { - if id, module, _, _, err := idOf(raw); err == nil && module == "" { - present[id] = true - } - } - // By the id of the resource of the node's own it runs after, "" for first: the last one before it that is composed. - after := map[string][]json.RawMessage{} - anchor := "" - for _, raw := range runs.Resources { - id, module, typ, path, err := idOf(raw) - if err != nil { - return nil, nil, fmt.Errorf("compose: a resource the node runs in its own composition: %w", err) - } - if !carried(module, id) { - if module == "" && present[id] { - anchor = id - } - continue - } - after[anchor] = append(after[anchor], raw) - if typ == string(declaration.TypeFile) { - foldedPaths[path] = true - } - } - out := make([]json.RawMessage, 0, len(kept)+len(runs.Resources)) - out = append(out, after[""]...) - for _, raw := range kept { - out = append(out, raw) - if id, module, _, _, err := idOf(raw); err == nil && module == "" { - out = append(out, after[id]...) - } - } - return out, foldedPaths, nil -} diff --git a/go/compose/composed_test.go b/go/compose/composed_test.go deleted file mode 100644 index a96ea85..0000000 --- a/go/compose/composed_test.go +++ /dev/null @@ -1,200 +0,0 @@ -package compose - -import ( - "encoding/json" - "sort" - "strings" - "testing" - - "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) } - -// 1943/AC5: a node's compositions are its declaration as Compose gives it, split by the module each resource belongs -// to: every resource once, each in its module's composition at the fingerprint given, the module's ownership said on -// it; a module given no fingerprint is named as skipped and composed nowhere. -func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) { - in := aSmallMesh(t) - d, _, err := Compose(in, "laptop") - if err != nil { - t.Fatal(err) - } - var want []string - for _, r := range d.Resources { - want = append(want, r["type"].(string)+":"+r["id"].(string)) - } - sort.Strings(want) - - fps := map[string]string{"mesh-wireguard": fpFor('a'), "files": fpFor('b')} - out, skipped, err := Compositions(in, "laptop", fps, Foreseeing) - if err != nil || len(skipped) != 0 { - t.Fatalf("Compositions = %v, skipped %v", err, skipped) - } - var got []string - for key, c := range out { - node, module, fp, isNode, err := delivery.ParseComposedKey(key) - if err != nil || node != "laptop" || module != c.Module || fp != c.FP || (isNode != (module == "")) { - t.Errorf("key %s holds %s at %s (%v)", key, c.Module, c.FP, err) - } - if !isNode && fp != fps[module] { - t.Errorf("%s is composed at %s, given %s", module, fp, fps[module]) - } - if _, err := delivery.EncodeComposed(c); err != nil { - t.Errorf("%s: %v", key, err) - } - for _, raw := range c.Resources { - var r struct{ ID, Type, Module string } - _ = json.Unmarshal(raw, &r) - if r.Module != module { - t.Errorf("%s holds %s of %q", key, r.ID, r.Module) - } - got = append(got, r.Type+":"+r.ID) - } - } - sort.Strings(got) - if strings.Join(got, " ") != strings.Join(want, " ") { - t.Errorf("the compositions hold %v, the declaration %v", got, want) - } - - one, skipped, err := Compositions(in, "laptop", map[string]string{"mesh-wireguard": fpFor('a')}, Foreseeing) - if err != nil || strings.Join(skipped, ",") != "files" { - t.Fatalf("one module: %v, skipped %v", err, skipped) - } - for _, c := range one { - if c.Module == "files" { - t.Errorf("a module given no fingerprint was composed") - } - } - - // Issue 1126: a module the node runs inside its own composition (Node.InOwn), and the private network's always, - // given no fingerprint of the new form, is folded into the node's own as the controller's composed writer folds it: - // every resource in its place, its module taken off, its id still naming it, nothing skipped. - folding := in - folding.Nodes = append([]Node(nil), in.Nodes...) - for i := range folding.Nodes { - if folding.Nodes[i].Name == "laptop" { - folding.Nodes[i].InOwn = map[string]bool{"files": true} - } - } - all, skipped, err := Compositions(folding, "laptop", nil, Foreseeing) - nodeKey, _ := delivery.ComposedNodeKey("laptop") - if err != nil || len(skipped) != 0 || len(all) != 1 || len(all[nodeKey].Resources) != len(want) { - t.Fatalf("folded: %v, skipped %v, %d compositions, %d of %d resources in the node's own", err, skipped, len(all), len(all[nodeKey].Resources), len(want)) - } - for i, raw := range all[nodeKey].Resources { - var r map[string]any - _ = json.Unmarshal(raw, &r) - if _, says := r["module"]; says || r["id"] != d.Resources[i]["id"] { - t.Errorf("folded resource %d is %v, the declaration's %v", i, r["id"], d.Resources[i]["id"]) - } - } - if _, err := delivery.EncodeComposed(all[nodeKey]); err != nil { - t.Errorf("the node's own with the folded modules: %v", err) - } - 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. -func TestTheComposedPoliciesAreTheCatalogues(t *testing.T) { - for _, p := range []string{catalogue.PolicyRoll, catalogue.PolicyTogether, catalogue.PolicyRecord} { - for _, from := range []string{catalogue.FromPerson, catalogue.FromModule, catalogue.FromBus, catalogue.FromData, catalogue.FromDefault} { - v := delivery.ComposedDigest{Digest: "sha256:" + strings.Repeat("a", 64), Judgement: delivery.JudgedNeither, Policy: p, PolicyFrom: from} - if _, err := delivery.EncodeComposedDigest(v); err != nil { - t.Errorf("%s from %s: %v", p, from, err) - } - } - } - if delivery.PolicyRecord != catalogue.PolicyRecord { - t.Errorf("delivery.PolicyRecord = %q, the catalogue's %q", delivery.PolicyRecord, catalogue.PolicyRecord) - } -} diff --git a/go/compose/credentials.go b/go/compose/credentials.go deleted file mode 100644 index 480c988..0000000 --- a/go/compose/credentials.go +++ /dev/null @@ -1,498 +0,0 @@ -package compose - -import ( - "crypto/ecdh" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "slices" - "sort" - "strings" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// Mode says what a composition does about what a send would make. Both modes read what the Input holds and make nothing; they -// differ as the controller's Reading and Foreseeing modes do. -type Mode int - -const ( - // Reading composes what is held and leaves out an own secret the Input holds none for: that machine cannot be running it. - Reading Mode = iota - // 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. -// Never sent. -const ( - ForeseenCertificate = "foreseen: issued by the next send" - ForeseenBusUsers = "foreseen: composed from the bus records by the next send" -) - -// grantsFor is every credential this node must create, because something elsewhere uses it (plan.go grantsFor): the mirror of -// what a consumer is given. A consumer whose identity overflows the provision's bound is left out of the grants and returned -// beside them (novox/hq ADR 0225); one whose resolution binds it to another provider is withdrawn and returned beside them -// (issue 274). Every other is granted. -func (c *composer) grantsFor(node string) ([]catalogue.Grant, []catalogue.Overflow, []catalogue.Unbound, []string, error) { - issued, err := c.rowsFor(node) - if err != nil { - return nil, nil, nil, nil, err - } - onNetwork, err := c.whereEveryoneIs() - if err != nil { - return nil, nil, nil, nil, err - } - out := make([]catalogue.Grant, 0, len(issued)) - var withheld []catalogue.Overflow - var unbound []catalogue.Unbound - var foreseen []string - for _, s := range issued { - resolved, err := c.planFor(s.Consumer) - switch { - case unresolvable(err): - // Their set does not resolve. Skipped rather than fatal: this node is not the place to report another machine's problem. - continue - case err != nil: - // The mesh could not be asked what they wanted, which is not the same as their wanting nothing. - return nil, nil, nil, nil, fmt.Errorf("what %s asked of %s cannot be read: %w", s.Consumer, s.Provision, err) - } - plan := resolved.Plan - values, asks, err := plan.ContributionsFrom(s.Provision, s.ConsumerModule, resolved.Settings) - if err != nil { - return nil, nil, nil, nil, err - } - // A port in there is the consumer's software port until this: the assignment that moved it is that machine's. - consumer, err := c.in.node(s.Consumer) - if err != nil { - return nil, nil, nil, nil, err - } - values = catalogue.AtPublishedPort(values, s.ConsumerModule, consumer.Ports[s.ConsumerModule]) - from := s.ConsumerModule - if !asks { - // That module no longer wants this: left empty, which is how a login is withdrawn rather than kept working for ever. - from = "" - } - if bound := plan.BindsFrom(s.Provision, s.ConsumerModule, s.Local); from != "" && !slices.Contains(bound, node) { - // It still asks, and not of this node: withdrawn exactly as a credential nobody asks for, and said. - from = "" - unbound = append(unbound, catalogue.Unbound{Provision: s.Provision, Provider: node, - Consumer: s.Consumer, Module: s.ConsumerModule, Local: s.Local, BoundTo: bound}) - } - // The consumer's identity slug, from its own manifest, carried on the grant so the provider derives the same login the - // consumer does (novox/hq ADR 0049). - slug := "" - for _, mm := range plan.Modules { - if mm.Module == s.ConsumerModule { - slug = mm.Slug - break - } - } - if from != "" { - bound := boundOfGrant(plan, s, node) - source := catalogue.IdentitySource(slug, s.ConsumerModule) - if catalogue.CheckIdentityWithin(s.Consumer, source, bound) != nil { - withheld = append(withheld, catalogue.Overflow{Provision: s.Provision, Provider: node, - Consumer: s.Consumer, Module: s.ConsumerModule, - Identity: catalogue.ConsumerIdentity(s.Consumer, source), Bound: bound}) - continue - } - } - sealed := s.ForProvider - switch { - case credentiallessRow(plan, s, node): - // Its offer presents no credential (form none): the grant carries the consumer and nothing sealed, and - // nothing stands in for it, so neither end waits for a value nobody makes (issue 1126). - sealed = "" - case sealed == "": - sealed = catalogue.ForeseenSealed - foreseen = append(foreseen, fmt.Sprintf("the grant of %s for %s on %s", s.Provision, s.ConsumerModule, s.Consumer)) - } - out = append(out, catalogue.Grant{ - Provision: s.Provision, Consumer: s.Consumer, At: onNetwork[s.Consumer], - From: from, Values: values, Slug: slug, Sealed: sealed, Local: s.Local}) - } - return out, withheld, unbound, foreseen, nil -} - -// credentialless is whether a need's provider offers it with no credential (catalogue.CredentialFormNone): the vault -// mints nothing for it, so nothing is asked of the vault, sealed into a grant or a file, or stood in for (issue 1126). -func credentialless(n catalogue.Needed) bool { return n.CredentialForm == catalogue.CredentialFormNone } - -// credentiallessRow is whether the consumer's resolution answers row's need from provider with an offer of form none. -func credentiallessRow(consumer catalogue.Resolution, row Issued, provider string) bool { - return slices.ContainsFunc(consumer.Needs, func(n catalogue.Needed) bool { - return n.Name == row.Provision && n.For == row.ConsumerModule && n.Local == row.Local && n.From == provider && - credentialless(n) - }) -} - -// rowsFor is every pair credential this node's declaration must carry a grant for, in the order the controller reads them -// (SecretsFrom: by provision, consumer, consumer module and local name): the ones the Input says were made, and the ones the same send -// would make before it composes this node -- every need of a node that resolves (this one too), whose provider is this node, that is not -// answered by a record and not a credential only a person gives and nobody gave. Those have no sealed copy (ForProvider empty), so the -// grant is composed with a stand-in and named foreseen (plan.go: planFor makes the secret in every mode before grantsFor reads it). -func (c *composer) rowsFor(node string) ([]Issued, error) { - var rows []Issued - for _, row := range c.in.Issued { - if row.Provider == node { - rows = append(rows, row) - } - } - for _, other := range c.in.sorted() { - resolved, err := c.planFor(other.Name) - switch { - case unresolvable(err): - continue - case err != nil: - return nil, fmt.Errorf("what %s asked of %s cannot be read: %w", other.Name, node, err) - } - shelf := c.shelfFor(other) - for _, n := range resolved.Plan.Needs { - if n.From != node || n.ByRecord { - continue - } - cred, held := c.credential(n, other.Name) - if _, outside := issuedOutsideBy(shelf, n); cred.State == CredentialNotGiven || (outside && !(held && cred.State == CredentialHeld)) { - continue - } - if slices.ContainsFunc(rows, func(r Issued) bool { - return r.Provision == n.Name && r.Consumer == other.Name && r.ConsumerModule == n.For && r.Local == n.Local - }) { - continue - } - rows = append(rows, Issued{Provision: n.Name, Consumer: other.Name, ConsumerModule: n.For, Local: n.Local, Provider: node}) - } - } - sort.SliceStable(rows, func(i, j int) bool { - a, b := rows[i], rows[j] - switch { - case a.Provision != b.Provision: - return a.Provision < b.Provision - case a.Consumer != b.Consumer: - return a.Consumer < b.Consumer - case a.ConsumerModule != b.ConsumerModule: - return a.ConsumerModule < b.ConsumerModule - } - return a.Local < b.Local - }) - return rows, nil -} - -// boundOfGrant is the identity bound the consumer's own resolution states for the requirement this grant answers. A requirement -// not found there is held to the tightest bound the mesh knows rather than to none. -func boundOfGrant(consumer catalogue.Resolution, s Issued, provider string) catalogue.IdentityBound { - for _, n := range consumer.Needs { - if n.Name == s.Provision && n.For == s.ConsumerModule && n.From == provider { - return n.Identity - } - } - return catalogue.DefaultIdentityBound -} - -// ownSecretRow is the row for one own secret of a module on a node. -func (c *composer) ownSecretRow(node, module, name string) (OwnSecretRow, bool) { - for _, r := range c.in.OwnSecrets { - if r.Node == node && r.Module == module && r.Name == name { - return r, true - } - } - return OwnSecretRow{}, false -} - -// givenMember is one own secret a person gave on a machine and whether it is sealed to the key the machine holds now. -type givenMember struct { - Name string - Sealed string - Current bool -} - -// givenMembers is every member of a module's secret family given on a machine, by name (inv.GivenMembers): the mesh never -// makes a member, so only a value a person gave is one; a machine with no sealing key holds none. -func (c *composer) givenMembers(node Node, module, family string) []givenMember { - if node.SealingKey == "" { - return nil - } - prefix := catalogue.FamilyPrefix(family) - var out []givenMember - for _, r := range c.in.OwnSecrets { - if r.Node == node.Name && r.Module == module && r.Origin == "accepted" && strings.HasPrefix(r.Name, prefix) { - out = append(out, givenMember{Name: r.Name, Sealed: r.Sealed, Current: r.SealedTo == node.SealingKey}) - } - } - sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) - return out -} - -// givenOwnSecret is an own secret issued outside the mesh as a person gave it on a machine (inv.GivenOwnSecret): not given when -// the value held was made by the mesh or the machine has no sealing key. -func (c *composer) givenOwnSecret(node Node, module, name string) (givenMember, bool) { - if node.SealingKey == "" { - return givenMember{}, false - } - r, ok := c.ownSecretRow(node.Name, module, name) - if !ok || r.Origin != "accepted" { - return givenMember{}, false - } - return givenMember{Name: name, Sealed: r.Sealed, Current: r.SealedTo == node.SealingKey}, true -} - -// moduleSecretIfIssued is the read half of the controller's SecretForModule (inv.ModuleSecretIfIssued): the secret when one is -// held and sealed to the key the machine holds now, and otherwise not held. A key it was sealed to that the machine no longer -// has is reported as absent rather than as an error. -func (c *composer) moduleSecretIfIssued(node Node, module, name string) (sealed string, held bool) { - if node.SealingKey == "" { - return "", false - } - r, ok := c.ownSecretRow(node.Name, module, name) - if !ok || r.SealedTo != node.SealingKey { - return "", false - } - return r.Sealed, true -} - -// wouldMakeSecretForModule says what the send would do about an own secret without doing it: nil when it would make one (or one -// is held), and otherwise the very refusal it would meet (inv.WouldMakeSecretForModule). -func (c *composer) wouldMakeSecretForModule(node Node, module, name string) error { - if node.SealingKey == "" { - return fmt.Errorf("%s needs a secret and %s has no sealing key, so nothing can be sealed to it", module, node.Name) - } - r, ok := c.ownSecretRow(node.Name, module, name) - switch { - case !ok: - return nil - case r.SealedTo != node.SealingKey && r.Origin == "accepted": - return fmt.Errorf("%s on %s holds %q, which was given to the mesh rather than made by it, and %s has "+ - "since generated a new sealing key. The mesh cannot make another; issue it again", module, node.Name, name, node.Name) - } - 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 "+ - "since generated a new sealing key. The mesh cannot make another; give it again", module, node, name, node) -} - -// ownSecretsFor is each module's own secrets as this node composes them (plan.go renderingFor): the needed ones sealed to the -// node, and, in the Foreseeing mode, the ones the next send would make named foreseen. A module handed to the provisioner has its -// secrets from the vault through the client, so none of them is composed here. -func (c *composer) ownSecretsFor(node Node, plan catalogue.Resolution, handing catalogue.Rendering, mode Mode) ( - needed map[string]map[string]string, foreseen map[string]map[string]bool, err error) { - needed = map[string]map[string]string{} - foreseen = map[string]map[string]bool{} - mark := func(module, name string) { - if foreseen[module] == nil { - foreseen[module] = map[string]bool{} - } - foreseen[module][name] = true - } - for _, m := range plan.Modules { - if handing.Handing(m) { - continue - } - // A secret family is never made (novox/hq ADR 0283): each member a person gave on this machine is placed, and one not - // given is nothing -- the module says it waits for it. - for _, family := range m.OwnSecrets.Families() { - for _, g := range c.givenMembers(node, m.Module, family) { - if _, fam, ok := m.OwnSecrets.Lookup(g.Name); !ok || fam != family { - continue // a longer family's member, or a name no longer of this family - } - if !g.Current { - continue - } - if needed[m.Module] == nil { - needed[m.Module] = map[string]string{} - } - needed[m.Module][g.Name] = g.Sealed - if g.Sealed == "" { - // Given, and its value is not in the Input: placed with a stand-in, named. - needed[m.Module][g.Name] = catalogue.ForeseenSealed - mark(m.Module, g.Name) - } - } - } - names := make([]string, 0) - for name := range m.OwnSecrets.Plain() { - names = append(names, name) - } - sort.Strings(names) - for _, name := range names { - own := m.OwnSecrets.Plain()[name] - // An own secret issued outside the mesh is never made (novox/hq issue 401, ADR 0228; ADR 0283 for a family): only a - // value a person gave is placed. One not given is nothing, and a value the mesh made for it before is not one either. - if own.IssuedBy == catalogue.IssuedOutside { - g, given := c.givenOwnSecret(node, m.Module, name) - if !given || !g.Current { - continue - } - if g.Sealed == "" { - mark(m.Module, name) - continue - } - if needed[m.Module] == nil { - needed[m.Module] = map[string]string{} - } - needed[m.Module][name] = g.Sealed - 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). 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 - } - mark(m.Module, name) - continue - } - if !held { - // Never issued, so this machine cannot be running it: left out rather than invented. - continue - } - if sealed == "" { - // Held, and its value is not in the Input: composed with a stand-in, named. - mark(m.Module, name) - continue - } - if needed[m.Module] == nil { - needed[m.Module] = map[string]string{} - } - needed[m.Module][name] = sealed - } - } - return needed, foreseen, nil -} - -// CoreBoxWanted is a core credential whose box the Input does not hold for a machine: the next send asks the vault for it and -// keeps the answer. It is returned for the caller to ask and keep; this package asks and keeps nothing. -type CoreBoxWanted struct { - Node string - Module string - Name string -} - -// coreModulesOf is every module of the plan on the core's list whose own secrets this machine's declaration carries, with their -// plain own secret names, sorted: one handed to the provisioner has its secrets from the vault through the client, and one issued -// outside the mesh is never the vault's to seal here. -func coreModulesOf(modules []catalogue.Manifest, handed func(catalogue.Manifest) bool) map[string][]string { - out := map[string][]string{} - for _, m := range modules { - if !catalogue.CarriesVaultSealed(m.Module) || (handed != nil && handed(m)) { - continue - } - var names []string - for name, own := range m.OwnSecrets.Plain() { - if own.IssuedBy != catalogue.IssuedOutside { - names = append(names, name) - } - } - if len(names) > 0 { - sort.Strings(names) - out[m.Module] = names - } - } - return out -} - -// sealingKeyValid says a machine's sealing key, recorded raw in base64, is a raw X25519 key. -func sealingKeyValid(raw string) error { - b, err := base64.StdEncoding.DecodeString(strings.TrimSpace(raw)) - if err != nil || len(b) != 32 { - return errors.New("the machine's sealing key is not a raw X25519 key") - } - _, err = ecdh.X25519().NewPublicKey(b) - return err -} - -// coreBoxesFor decides each core credential of a machine in a composition that sends nothing and asks no vault (core_box.go -// coreBoxesOf, in the modes that are not a send): the kept box while it is sealed to the key the machine holds now, and otherwise a -// stand-in that the next send replaces by asking the vault -- returned in wanted for the caller to ask and keep. A machine with no -// sealing key, or one whose key is not a key, leaves its module out, said. -func (c *composer) coreBoxesFor(node Node, modules []catalogue.Manifest, handing catalogue.Rendering) ( - sealed map[string]map[string]json.RawMessage, notPlaced map[string]string, wanted []CoreBoxWanted) { - core := coreModulesOf(modules, func(m catalogue.Manifest) bool { return handing.Handing(m) }) - sealed, notPlaced = map[string]map[string]json.RawMessage{}, map[string]string{} - mods := make([]string, 0, len(core)) - for m := range core { - mods = append(mods, m) - } - sort.Strings(mods) - for _, module := range mods { - if node.SealingKey == "" { - notPlaced[module] = fmt.Sprintf("%s has no sealing key, so the vault can seal %s's credentials to "+ - "nothing there (novox/hq ADR 0349 §16)", node.Name, module) - continue - } - if err := sealingKeyValid(node.SealingKey); err != nil { - notPlaced[module] = fmt.Sprintf("%s's credentials cannot be asked of the vault: %v", module, err) - continue - } - boxes := map[string]json.RawMessage{} - for _, name := range core[module] { - var kept *CoreBox - for i := range c.in.KeptCoreBoxes { - k := &c.in.KeptCoreBoxes[i] - if k.Node == node.Name && k.Module == module && k.Name == name { - kept = k - } - } - if kept != nil && kept.SealedTo == node.SealingKey && len(kept.Box) > 0 { - boxes[name] = kept.Box - continue - } - boxes[name] = catalogue.ForeseenVaultBox - wanted = append(wanted, CoreBoxWanted{Node: node.Name, Module: module, Name: name}) - } - sealed[module] = boxes - } - return sealed, notPlaced, wanted -} - -// certificateFor is what the mesh certifies about one machine's internal name (plan.go certificateFor): refused for a machine that -// never told the mesh what key it serves with, and for one not on the private network, which has no name to be certified for. -// The certificate and the authority are public values the Input may carry; without them stand-ins are composed, and named. -func (c *composer) certificateFor(node Node) (certificate, authority string, foreseen bool, err error) { - if node.ServingKey == "" { - return "", "", false, fmt.Errorf( - "%s wants a certificate and has never told the mesh what key it serves with; it "+ - "joins again to report one", node.Name) - } - where, err := c.whereEveryoneIs() - if err != nil { - return "", "", false, err - } - if where[node.Name] == "" { - return "", "", false, fmt.Errorf( - "%s wants a certificate and is not on the private network, so it has no name inside "+ - "the mesh to be certified for", node.Name) - } - certificate, authority = node.Certificate, c.in.Authority - if certificate == "" || authority == "" { - return ForeseenCertificate, ForeseenCertificate, true, nil - } - return certificate, authority, false, nil -} - -// holdsTheBus says whether a node's set includes the module that asks for the bus's user list. -func holdsTheBus(modules []catalogue.Manifest) bool { - for _, m := range modules { - if m.BusUsers != "" && m.ClaimsSeat("mesh-broker") { - return true - } - } - return false -} diff --git a/go/compose/credentials_needed.go b/go/compose/credentials_needed.go deleted file mode 100644 index ec36c71..0000000 --- a/go/compose/credentials_needed.go +++ /dev/null @@ -1,69 +0,0 @@ -package compose - -import ( - "fmt" - "sort" -) - -// CredentialKey names one pair credential by the ends it joins: the provision, the consumer's node and module, the -// provider's node, and the local name where a module keeps several (Credential's and Issued's key). -type CredentialKey struct { - Provision string `json:"provision"` - Consumer string `json:"consumer"` - ConsumerModule string `json:"consumer_module"` - Provider string `json:"provider"` - Local string `json:"local,omitempty"` -} - -// CredentialsNeeded is every pair credential node's composition reads (novox/hq ADR 0547 §2, ADR 0550 §5, issue 1126): -// as a consumer, each need of its resolution a provider's credential answers (a need answered by a record is a licence's, -// not a pair's); as a provider, each credential it must carry a grant for (rowsFor). A node that composes its own asks the -// vault for exactly these, sealed to its key, and composes with them; one it does not get is stood in for, which the -// node's own apply refuses (CompositionsApplied). A need whose provider offers it with no credential (form none) is -// in neither list: the vault mints nothing for it (issue 1126). -func CredentialsNeeded(in Input, node string) (asConsumer, asProvider []CredentialKey, err error) { - c := newComposer(in) - resolved, err := c.resolution(node) - if err != nil { - return nil, nil, err - } - for _, n := range resolved.Needs { - if n.ByRecord || n.From == "" || credentialless(n) { - continue - } - asConsumer = append(asConsumer, CredentialKey{Provision: n.Name, Consumer: node, ConsumerModule: n.For, - Provider: n.From, Local: n.Local}) - } - rows, err := c.rowsFor(node) - if err != nil { - return nil, nil, fmt.Errorf("compose: the credentials %s provides: %w", node, err) - } - for _, r := range rows { - // A grant whose offer presents no credential (form none) has none to ask for (issue 1126). - if consumer, err := c.planFor(r.Consumer); err == nil && credentiallessRow(consumer.Plan, r, node) { - continue - } - asProvider = append(asProvider, CredentialKey{Provision: r.Provision, Consumer: r.Consumer, - ConsumerModule: r.ConsumerModule, Provider: node, Local: r.Local}) - } - sortKeys(asConsumer) - sortKeys(asProvider) - return asConsumer, asProvider, nil -} - -func sortKeys(ks []CredentialKey) { - sort.Slice(ks, func(i, j int) bool { - a, b := ks[i], ks[j] - switch { - case a.Provision != b.Provision: - return a.Provision < b.Provision - case a.Provider != b.Provider: - return a.Provider < b.Provider - case a.Consumer != b.Consumer: - return a.Consumer < b.Consumer - case a.ConsumerModule != b.ConsumerModule: - return a.ConsumerModule < b.ConsumerModule - } - return a.Local < b.Local - }) -} diff --git a/go/compose/credentials_test.go b/go/compose/credentials_test.go deleted file mode 100644 index 94cd354..0000000 --- a/go/compose/credentials_test.go +++ /dev/null @@ -1,549 +0,0 @@ -package compose - -import ( - "encoding/json" - "errors" - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/overlay" -) - -// secretValue is a value the Input carries that must never appear in anything the composition says. -const secretValue = "SECRET-VALUE-THAT-MUST-NOT-LEAK" - -func rendered(t *testing.T, in Input, node string, mode Mode) Rendered { - t.Helper() - got, err := Render(in, node, mode) - if err != nil { - t.Fatalf("%v", err) - } - return got -} - -// anAssistant is mesh-controller's outside_not_given_test.go anAssistant (cmd/mesh-controller at 6c2a8d62): a module with a secret -// the mesh makes and one only an outside party issues. -func anAssistant() catalogue.Manifest { - return catalogue.Manifest{Module: "assistant", Version: "1", - OwnSecrets: catalogue.OwnSecrets{ - "password": {Path: "/var/lib/mesh/assistant/password", Taken: catalogue.TakenAtStart}, - "vendor-key": {Path: "/var/lib/mesh/assistant/vendor-key", Taken: catalogue.TakenAtStart, IssuedBy: catalogue.IssuedOutside}, - }, - Resources: []map[string]any{{"id": "state", "type": "directory", "path": "/var/lib/mesh/assistant", "mode": "0700"}}} -} - -func withAssistant(t *testing.T) Input { - t.Helper() - in := aMesh(t) - in.Catalogue["assistant"] = anAssistant() - laptop := nodeOf(t, &in, "laptop") - laptop.Assigned = append(laptop.Assigned, "assistant") - laptop.SealingKey = keyOf(t, "laptop") - return in -} - -// Own secrets (plan.go renderingFor, novox/hq issue 275 and 401): one held and sealed to the machine's key is carried; one not held is -// foreseen in the Foreseeing mode and left out in the Reading mode, and nothing is made. -func TestAnOwnSecretHeldIsCarriedAndOneNotHeldIsForeseenOrLeftOut(t *testing.T) { - mk := func() Input { - in := withAssistant(t) - in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "password", Origin: "made", - SealedTo: nodeOf(t, &in, "laptop").SealingKey, Sealed: secretValue}} - return in - } - in := mk() - got := rendered(t, in, "laptop", Foreseeing) - if got.Rendering.Needed["assistant"]["password"] != secretValue || len(got.Rendering.Foreseen) != 0 { - t.Errorf("a held secret: needed %v foreseen %v", got.Rendering.Needed, got.Rendering.Foreseen) - } - // Not held: foreseen with a stand-in (Foreseeing), left out (Reading). - in.OwnSecrets = nil - got = rendered(t, in, "laptop", Foreseeing) - if !got.Rendering.Foreseen["assistant"]["password"] || got.Rendering.Needed["assistant"]["password"] != "" || - len(got.Foreseen) != 1 || got.Foreseen[0] != "assistant/password" { - t.Errorf("a secret the send makes: foreseen %v / %v needed %v", got.Rendering.Foreseen, got.Foreseen, got.Rendering.Needed) - } - got = rendered(t, in, "laptop", Reading) - if len(got.Rendering.Needed["assistant"]) != 0 || len(got.Foreseen) != 0 || got.Rendering.Foreseen != nil { - t.Errorf("Reading composed a secret that was never issued: %v %v", got.Rendering.Needed, got.Foreseen) - } - // Held but sealed to a key the machine no longer has is not held. - in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "password", Origin: "made", SealedTo: "an-old-key", Sealed: secretValue}} - if got := rendered(t, in, "laptop", Foreseeing); !got.Rendering.Foreseen["assistant"]["password"] { - t.Errorf("a secret sealed to an old key is held: %v", got.Rendering.Needed) - } - // Held with no value in the Input: a stand-in, named. - in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "password", Origin: "made", SealedTo: nodeOf(t, &in, "laptop").SealingKey}} - if got := rendered(t, in, "laptop", Reading); !got.Rendering.Foreseen["assistant"]["password"] { - t.Errorf("a held secret with no value is not named: %v", got.Rendering.Foreseen) - } - unchanged(t, mk, func(in Input) { rendered(t, in, "laptop", Foreseeing) }) - - // 1126 (ADR 0547 §2): a node applying what it composed itself never applies a stand-in: a held secret with no value in the - // Input is refused, naming it; held with its value, it composes. - fps := map[string]string{"assistant": "src2:" + strings.Repeat("a", 64), overlay.Name: "src2:" + strings.Repeat("b", 64)} - if _, _, err := CompositionsApplied(in, "laptop", fps); !errors.Is(err, ErrStoodIn) || !strings.Contains(err.Error(), "assistant/password") { - t.Errorf("a stand-in was not refused for a node's own apply: %v", err) - } - if out, _, err := CompositionsApplied(mk(), "laptop", fps); err != nil || len(out) == 0 { - t.Errorf("a held secret with its value is refused for a node's own apply: %v", err) - } -} - -// An own secret issued outside the mesh that nobody gave is never made (novox/hq issue 401, ADR 0228): nothing is composed or -// foreseen for it; a value the mesh made for it is not one a person gave; a value given is placed once it is sealed to the key held now. -func TestAnOutsideSecretIsPlacedOnlyOnceAPersonGaveIt(t *testing.T) { - in := withAssistant(t) - key := nodeOf(t, &in, "laptop").SealingKey - for _, mode := range []Mode{Reading, Foreseeing} { - got := rendered(t, in, "laptop", mode) - if _, there := got.Rendering.Needed["assistant"]["vendor-key"]; there || got.Rendering.Foreseen["assistant"]["vendor-key"] { - t.Fatalf("%v: a secret nobody gave is composed: %v %v", mode, got.Rendering.Needed, got.Rendering.Foreseen) - } - } - in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "vendor-key", Origin: "made", SealedTo: key, Sealed: secretValue}} - if _, there := rendered(t, in, "laptop", Foreseeing).Rendering.Needed["assistant"]["vendor-key"]; there { - t.Error("a value the mesh made for a secret issued outside was placed") - } - in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "vendor-key", Origin: "accepted", SealedTo: key, Sealed: "the vendor's key, sealed"}} - if got := rendered(t, in, "laptop", Foreseeing).Rendering.Needed["assistant"]["vendor-key"]; got != "the vendor's key, sealed" { - t.Errorf("a value given is placed as %q", got) - } - in.OwnSecrets[0].SealedTo = "an-old-key" - if _, there := rendered(t, in, "laptop", Foreseeing).Rendering.Needed["assistant"]["vendor-key"]; there { - t.Error("a value given under an old key was placed") - } -} - -// 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: "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["mesh-self-check"] = broker - laptop := nodeOf(t, &in, "laptop") - 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: "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 { - t.Fatalf("got %v, want %q", err, wantOld) - } - if strings.Contains(err.Error(), secretValue) { - t.Error("a secret value is in an error") - } - // A machine with no sealing key cannot be sealed to. - laptop.SealingKey = "" - in.OwnSecrets = nil - if _, err := Render(in, "laptop", Foreseeing); err == nil || !strings.Contains(err.Error(), "laptop has no sealing key, so nothing can be sealed to it") { - t.Fatalf("no sealing key: %v", err) - } -} - -// aGrantMesh is the objects provider on anchor and its consumer files on laptop, with the pair credential made. -func aGrantMesh(t *testing.T) Input { - t.Helper() - in := theObjectsMesh(t, objects()) - in.Issued = []Issued{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", ForProvider: "sealed-to-anchor"}} - return in -} - -// A credential made for a consumer is a grant on its provider's machine, carrying the consumer's values and the sealed copy; without -// the sealed copy a stand-in is composed and named (novox/hq plan.go grantsFor). -func TestAGrantIsCreatedForEachCredentialMadeForAConsumer(t *testing.T) { - in := aGrantMesh(t) - // 1126 (ADR 0550 §5): the credentials each node's composition reads, by their ends: laptop's files consumes anchor's - // s3-bucket, and anchor grants it; the node composing its own asks the vault for exactly these. - pair := CredentialKey{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor"} - if consumer, provider, err := CredentialsNeeded(in, "laptop"); err != nil || len(provider) != 0 || len(consumer) != 1 || consumer[0] != pair { - t.Errorf("laptop needs %v as consumer and %v as provider (%v), want %v as consumer", consumer, provider, err, pair) - } - if consumer, provider, err := CredentialsNeeded(in, "anchor"); err != nil || len(consumer) != 0 || len(provider) != 1 || provider[0] != pair { - t.Errorf("anchor needs %v as consumer and %v as provider (%v), want %v as provider", consumer, provider, err, pair) - } - got := rendered(t, in, "anchor", Foreseeing) - if len(got.Rendering.Grants) != 1 { - t.Fatalf("grants are %+v", got.Rendering.Grants) - } - g := got.Rendering.Grants[0] - if g.Provision != "s3-bucket" || g.Consumer != "laptop" || g.From != "files" || g.At != "laptop.internal" || g.Sealed != "sealed-to-anchor" { - t.Errorf("the grant is %+v", g) - } - if len(got.Foreseen) != 0 || len(got.Withheld) != 0 || len(got.Unbound) != 0 { - t.Errorf("foreseen %v withheld %v unbound %v", got.Foreseen, got.Withheld, got.Unbound) - } - in.Issued[0].ForProvider = "" - got = rendered(t, in, "anchor", Foreseeing) - if got.Rendering.Grants[0].Sealed != catalogue.ForeseenSealed || len(got.Foreseen) != 1 { - t.Errorf("a grant with no sealed copy: %+v foreseen %v", got.Rendering.Grants, got.Foreseen) - } - // A credential made for a consumer on another provider is not this node's grant. - if len(rendered(t, in, "laptop", Foreseeing).Rendering.Grants) != 0 { - t.Error("the consumer is granted its own credential") - } - // A consumer whose set does not resolve is skipped, not fatal. - nodeOf(t, &in, "laptop").Assigned = append(nodeOf(t, &in, "laptop").Assigned, "files") - in.Catalogue["files"] = catalogue.Manifest{Module: "files", Version: "1", Requires: []string{"s3-bucket"}, - Claims: []catalogue.Claim{{Name: "x", Scope: catalogue.ScopeNode}}} - in.Catalogue["twin"] = catalogue.Manifest{Module: "twin", Version: "1", Claims: []catalogue.Claim{{Name: "x", Scope: catalogue.ScopeNode}}} - nodeOf(t, &in, "laptop").Assigned = append(nodeOf(t, &in, "laptop").Assigned, "twin") - if got, err := Render(in, "anchor", Foreseeing); err != nil || len(got.Rendering.Grants) != 0 { - t.Errorf("a consumer that does not resolve: %v %+v", err, got.Rendering.Grants) - } -} - -// 1364/AC2: a consumer the grants leave out because its identity overflows the provision's bound is returned beside them, naming the -// consumer and the provision (novox/hq ADR 0225), and is not granted. -func TestAConsumerWhoseIdentityOverflowsIsLeftOutOfTheGrantsAndNamed(t *testing.T) { - in := aGrantMesh(t) - in.Nodes[1].Name = "a-machine-with-a-very-long-name" - in.Issued[0].Consumer = "a-machine-with-a-very-long-name" - got := rendered(t, in, "anchor", Foreseeing) - if len(got.Rendering.Grants) != 0 || len(got.Withheld) != 1 { - t.Fatalf("grants %+v withheld %+v", got.Rendering.Grants, got.Withheld) - } - w := got.Withheld[0] - if w.Consumer != "a-machine-with-a-very-long-name" || w.Provision != "s3-bucket" || w.Provider != "anchor" || w.Module != "files" || - !strings.Contains(w.String(), "a-machine-with-a-very-long-name") || !strings.Contains(w.String(), "s3-bucket") { - t.Errorf("the consumer left out is %+v / %s", w, w.String()) - } - if len(got.Rendering.Withheld) != 1 { - t.Errorf("the Rendering does not carry it: %+v", got.Rendering.Withheld) - } -} - -// 1364/AC3: a credential on record for a consumer that its own resolution binds to another provider is withdrawn, named with where it is -// bound (novox/hq issue 274): the grant stays, with no consumer module asking. -func TestACredentialBoundToAnotherProviderIsWithdrawnAndSaidWhereItIsBound(t *testing.T) { - in := aGrantMesh(t) - second := objects() - second.Module = "objects2" - in.Catalogue["objects2"] = second - in.Nodes = append(in.Nodes, Node{Name: "ace", Place: Place{Site: "there", Address: "10.77.0.3"}, - Assigned: []string{overlay.Name, "objects2"}, Capabilities: map[string]bool{"container-runtime": true, "wireguard": true, "systemd": true}}) - nodeOf(t, &in, "laptop").Pins = map[string]catalogue.Chosen{"s3-bucket": {Node: "ace", Module: "objects2"}} - got := rendered(t, in, "anchor", Foreseeing) - if len(got.Unbound) != 1 { - t.Fatalf("unbound %+v grants %+v", got.Unbound, got.Rendering.Grants) - } - u := got.Unbound[0] - if u.Provision != "s3-bucket" || u.Provider != "anchor" || u.Consumer != "laptop" || u.Module != "files" || - len(u.BoundTo) != 1 || u.BoundTo[0] != "ace" || !strings.Contains(u.String(), "ace") { - t.Errorf("unbound is %+v / %s", u, u.String()) - } - if len(got.Rendering.Grants) != 1 || got.Rendering.Grants[0].From != "" { - t.Errorf("the withdrawn credential is still asked for: %+v", got.Rendering.Grants) - } -} - -func aCertificateMesh(t *testing.T) Input { - t.Helper() - in := aMesh(t) - in.Catalogue["secure"] = catalogue.Manifest{Module: "secure", Version: "1", Certificate: &catalogue.Certificate{Into: "/etc/secure/tls.pem", Authority: "/etc/secure/ca.pem"}} - laptop := nodeOf(t, &in, "laptop") - laptop.Assigned = append(laptop.Assigned, "secure") - return in -} - -// 1364/AC4: with no certificate and authority in the Input a stand-in is composed and named; given them, the composition carries them as -// given. A machine that never reported a serving key, and one not on the private network, are refused in the controller's words. -func TestACertificateIsCarriedAsGivenOrStoodInForAndNamed(t *testing.T) { - in := aCertificateMesh(t) - laptop := nodeOf(t, &in, "laptop") - if _, err := Render(in, "laptop", Foreseeing); err == nil || err.Error() != "laptop wants a certificate and has never told the mesh what key it serves with; it joins again to report one" { - t.Fatalf("no serving key: %v", err) - } - laptop.ServingKey = "serving-key" - got := rendered(t, in, "laptop", Foreseeing) - if got.Rendering.Certificate != ForeseenCertificate || got.Rendering.Authority != ForeseenCertificate || len(got.Foreseen) != 1 { - t.Errorf("no certificate in the Input: %q %q foreseen %v", got.Rendering.Certificate, got.Rendering.Authority, got.Foreseen) - } - laptop.Certificate, in.Authority = "the-certificate", "the-authority" - got = rendered(t, in, "laptop", Foreseeing) - if got.Rendering.Certificate != "the-certificate" || got.Rendering.Authority != "the-authority" || len(got.Foreseen) != 0 { - t.Errorf("a certificate given: %q %q foreseen %v", got.Rendering.Certificate, got.Rendering.Authority, got.Foreseen) - } - laptop.Place.Address = "" // placed nowhere: no name inside the mesh - if _, err := Render(in, "laptop", Foreseeing); err == nil || err.Error() != "laptop wants a certificate and is not on the private network, so it has no name inside the mesh to be certified for" { - t.Fatalf("off the network: %v", err) - } -} - -func aValidKey(t *testing.T, name string) string { return keyOf(t, name) } - -// 1364/AC5 and AC6: a core credential the node needs and the Input holds no box for is composed with a stand-in and returned as one -// to keep; a kept box sealed to the key held now is carried; a node-engine below the vault-box contract is sent none (and nothing is -// asked of it); a machine with no sealing key has its core module left out, said. -func TestACoreBoxTheInputHoldsNoneOfIsReturnedToKeepAndNothingIsWritten(t *testing.T) { - mk := func() Input { - in := aMesh(t) - in.Catalogue["nats"] = catalogue.Manifest{Module: "nats", Version: "1", - OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh/nats/broker", Taken: catalogue.TakenAtStart}}} - laptop := nodeOf(t, &in, "laptop") - laptop.Assigned = append(laptop.Assigned, "nats") - laptop.SealingKey = aValidKey(t, "laptop") - laptop.Stated, laptop.Contract = true, VaultBoxContract - in.BusMinted = []string{"laptop.nats"} - return in - } - in := mk() - got := rendered(t, in, "laptop", Foreseeing) - box := got.Rendering.VaultSealed["nats"]["broker"] - if string(box) != string(catalogue.ForeseenVaultBox) || len(got.CoreBoxesWanted) != 1 || - got.CoreBoxesWanted[0] != (CoreBoxWanted{Node: "laptop", Module: "nats", Name: "broker"}) { - t.Errorf("no box held: %s wanted %v", box, got.CoreBoxesWanted) - } - kept := json.RawMessage(`{"v":1,"eph":"e","iv":"i","tag":"t","ct":"c"}`) - in.KeptCoreBoxes = []CoreBox{{Node: "laptop", Module: "nats", Name: "broker", Generation: 3, SealedTo: nodeOf(t, &in, "laptop").SealingKey, Box: kept}} - got = rendered(t, in, "laptop", Foreseeing) - if string(got.Rendering.VaultSealed["nats"]["broker"]) != string(kept) || len(got.CoreBoxesWanted) != 0 { - t.Errorf("a kept box: %s wanted %v", got.Rendering.VaultSealed["nats"]["broker"], got.CoreBoxesWanted) - } - in.KeptCoreBoxes[0].SealedTo = "an-old-key" - if got = rendered(t, in, "laptop", Foreseeing); len(got.CoreBoxesWanted) != 1 { - t.Errorf("a box sealed to an old key is kept: %v", got.CoreBoxesWanted) - } - // Below the vault-box contract: none, and the controller's own seal as before. - nodeOf(t, &in, "laptop").Contract = VaultBoxContract - 1 - if got = rendered(t, in, "laptop", Foreseeing); got.Rendering.VaultSealed != nil || len(got.CoreBoxesWanted) != 0 { - t.Errorf("an engine below the contract is sent boxes: %v %v", got.Rendering.VaultSealed, got.CoreBoxesWanted) - } - // No sealing key: the module is left out, said. - nodeOf(t, &in, "laptop").Contract, nodeOf(t, &in, "laptop").SealingKey = VaultBoxContract, "" - got = rendered(t, in, "laptop", Reading) - if why := got.Rendering.NotPlaced["nats"]; !strings.Contains(why, "laptop has no sealing key, so the vault can seal nats's credentials to nothing there") { - t.Errorf("not placed: %v", got.Rendering.NotPlaced) - } - unchanged(t, mk, func(in Input) { rendered(t, in, "laptop", Foreseeing) }) -} - -// 1364/AC1: the bus's user list is carried for the machine that runs the bus and for no other; without it a stand-in is composed and -// named; the users left out for want of a credential are said; each machine's membership is carried. (The list is composed by the -// controller from the bus records, which stay the controller's: see the commit message.) -func TestTheBusUserListIsCarriedForTheMachineThatRunsTheBus(t *testing.T) { - in := aMesh(t) - in.Catalogue["bus"] = catalogue.Manifest{Module: "bus", Version: "1", BusUsers: "/etc/bus/accounts.conf", - Claims: []catalogue.Claim{{Name: "mesh-broker", Scope: catalogue.ScopeMesh}}} - anchor := nodeOf(t, &in, "anchor") - anchor.Assigned = append(anchor.Assigned, "bus") - in.BusUsers = "accounts { anchor.bus: hash }" - in.BusMissing = []string{"laptop.files", "laptop.web"} - in.BusMemberships = map[string]string{"laptop": "sealed-membership"} - got := rendered(t, in, "anchor", Foreseeing) - if got.Rendering.BusUsers != in.BusUsers || len(got.Foreseen) != 0 { - t.Errorf("the list is %q foreseen %v", got.Rendering.BusUsers, got.Foreseen) - } - if len(got.Said) != 1 || got.Said[0] != "the bus's user list leaves out 2 user(s) the mesh has minted no credential for: laptop.files, laptop.web. Each is a user that cannot connect until one is issued" { - t.Errorf("said %v", got.Said) - } - if other := rendered(t, in, "laptop", Foreseeing); other.Rendering.BusUsers != "" || other.Rendering.BusMembership != "sealed-membership" { - t.Errorf("laptop: list %q membership %q", other.Rendering.BusUsers, other.Rendering.BusMembership) - } - in.BusUsers = "" - got = rendered(t, in, "anchor", Foreseeing) - if got.Rendering.BusUsers != ForeseenBusUsers || len(got.Foreseen) != 1 { - t.Errorf("no list in the Input: %q foreseen %v", got.Rendering.BusUsers, got.Foreseen) - } -} - -// 1364/AC7: nothing the composition says carries a secret value the Input holds. -func TestNoSecretValueOfTheInputIsInWhatTheCompositionSays(t *testing.T) { - in := aGrantMesh(t) - in.Issued[0].ForProvider = secretValue - laptop := nodeOf(t, &in, "laptop") - in.Catalogue["assistant"] = anAssistant() - laptop.Assigned = append(laptop.Assigned, "assistant") - laptop.SealingKey = keyOf(t, "laptop") - in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "password", Origin: "made", SealedTo: laptop.SealingKey, Sealed: secretValue}} - in.Credentials = []Credential{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", State: CredentialHeld, Sealed: secretValue}} - for _, node := range []string{"anchor", "laptop"} { - got := rendered(t, in, node, Foreseeing) - for _, said := range append(append([]string{}, got.Foreseen...), got.Said...) { - if strings.Contains(said, secretValue) { - t.Errorf("%s: %q carries a secret value", node, said) - } - } - } -} - -// A consumer no credential was made for yet is granted with a stand-in and named foreseen, for every form whose credential -// the vault mints or takes in; an offer of form none has no credential (issue 1126, 2026-11-02: mesh-cli on ace held for -// "the grant of mcp-endpoint", whose provider node-tools offers form none): its grant carries the consumer and no -// credential, nothing stands in on either side, and neither end asks the vault for one. -func TestAConsumerNoCredentialWasMadeForYetIsGrantedWithAStandInUnlessItsOfferHasNone(t *testing.T) { - for _, tc := range []struct { - form string - cred *catalogue.OfferCredential - }{ - {"undeclared", nil}, - {catalogue.CredentialFormPair, &catalogue.OfferCredential{Form: catalogue.CredentialFormPair}}, - {catalogue.CredentialFormShared, &catalogue.OfferCredential{Own: "access-key"}}, - {catalogue.CredentialFormNone, &catalogue.OfferCredential{Form: catalogue.CredentialFormNone}}, - } { - in := aGrantMesh(t) - in.Issued = nil - provider := in.Catalogue["objects"] - provider.Provides[0].Credential = tc.cred - if tc.cred != nil && tc.cred.Own != "" { - provider.OwnSecrets = catalogue.OwnSecrets{"access-key": {Path: "/var/lib/mesh/objects/access-key"}} - } - in.Catalogue["objects"] = provider - none := tc.form == catalogue.CredentialFormNone - got := rendered(t, in, "anchor", Foreseeing) - if len(got.Rendering.Grants) != 1 { - t.Fatalf("%s: grants are %+v", tc.form, got.Rendering.Grants) - } - g, wantSealed := got.Rendering.Grants[0], catalogue.ForeseenSealed - if none { - wantSealed = "" - } - if g.Provision != "s3-bucket" || g.Consumer != "laptop" || g.From != "files" || g.Sealed != wantSealed { - t.Errorf("%s: the grant is %+v", tc.form, g) - } - if has(got.Foreseen, "the grant of s3-bucket for files on laptop") == none { - t.Errorf("%s: foreseen %v", tc.form, got.Foreseen) - } - consumer, asProvider, err := CredentialsNeeded(in, "laptop") - _, anchorProvides, err2 := CredentialsNeeded(in, "anchor") - if err != nil || err2 != nil || len(asProvider) != 0 || (len(consumer) == 0) != none || (len(anchorProvides) == 0) != none { - t.Errorf("%s: laptop asks %v, anchor provides %v (%v %v)", tc.form, consumer, anchorProvides, err, err2) - } - resolved, err := Resolve(in, "laptop") - if err != nil { - t.Fatalf("%s: %v", tc.form, err) - } - if n := theFilesNeed(t, resolved); (n.Sealed == "") != none || (len(resolved.Foreseen) == 0) != none { - t.Errorf("%s: the consumer's credential is %q, foreseen %v", tc.form, n.Sealed, resolved.Foreseen) - } - if lap := rendered(t, in, "laptop", Foreseeing); none && len(lap.Foreseen) != 0 { - t.Errorf("%s: the consumer stands in for %v", tc.form, lap.Foreseen) - } - // Only the provider's own: the consumer is granted nothing, and nothing in the Input changed. - if len(rendered(t, in, "laptop", Foreseeing).Rendering.Grants) != 0 { - t.Errorf("%s: the consumer is granted its own credential", tc.form) - } - if len(in.Issued) != 0 { - t.Errorf("%s: a row was added to the Input", tc.form) - } - // A row made long ago for an offer of form none (the controller's) carries no credential either. - in.Issued = []Issued{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", ForProvider: "sealed-to-anchor"}} - if g := rendered(t, in, "anchor", Foreseeing).Rendering.Grants; none && (len(g) != 1 || g[0].Sealed != "") { - t.Errorf("%s: an old row's grant is %+v", tc.form, g) - } - } - // A credential only a person gives and nobody has is no grant. - in := aGrantMesh(t) - in.Issued = nil - in.Credentials = []Credential{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", State: CredentialNotGiven}} - if got := rendered(t, in, "anchor", Foreseeing); len(got.Rendering.Grants) != 0 { - t.Errorf("a credential nobody gave is granted: %+v", got.Rendering.Grants) - } -} - -// And a consumer whose identity overflows the provision's bound is judged before any credential is made for it (issue 263). -func TestAnOverflowingConsumerNoCredentialWasMadeForYetIsWithheld(t *testing.T) { - in := aGrantMesh(t) - in.Issued = nil - in.Catalogue["resolver"] = storeManifests()[1] - in.Catalogue["networkmanager"] = catalogue.Manifest{Module: "networkmanager", Version: "1", Requires: []string{"wildcard-resolution", "s3-bucket"}} - nodeOf(t, &in, "laptop").Assigned = append(nodeOf(t, &in, "laptop").Assigned, "networkmanager") - nodeOf(t, &in, "anchor").Assigned = append(nodeOf(t, &in, "anchor").Assigned, "resolver") - in.Holdings = []catalogue.Held{{Claim: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Node: "anchor", Module: "resolver", Site: "here"}} - got := rendered(t, in, "anchor", Foreseeing) - if len(got.Withheld) != 1 || got.Withheld[0].Module != "networkmanager" { - t.Fatalf("withheld %+v", got.Withheld) - } -} - -// The grants come in the order the controller reads them, whatever order the Input gives its rows. -func TestGrantsComeInTheOrderOfProvisionConsumerModuleAndLocal(t *testing.T) { - in := aGrantMesh(t) - in.Issued = []Issued{ - {Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Local: "b", Provider: "anchor", ForProvider: "x"}, - {Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Local: "a", Provider: "anchor", ForProvider: "x"}, - } - rows, err := newComposer(in).rowsFor("anchor") - // The credential the same send would make (no local name) comes first, then the two the Input gives, a before b. - if err != nil || len(rows) != 3 || rows[0].Local != "" || rows[1].Local != "a" || rows[2].Local != "b" { - t.Errorf("rows are %+v %v", rows, err) - } -} - -// Round 1, finding 4: a member of a secret family a person gave, whose sealed value the Input does not carry, is placed with a -// stand-in and named foreseen, not dropped as if nobody gave it. -func TestAGivenFamilyMemberWithoutItsValueIsAStandInAndNamedForeseen(t *testing.T) { - in := aMesh(t) - in.Catalogue["mailer"] = catalogue.Manifest{Module: "mailer", Version: "1", - OwnSecrets: catalogue.OwnSecrets{"credential-*": {Path: "/var/lib/mailer/credential-*.secret", Taken: catalogue.TakenAtStart, IssuedBy: catalogue.IssuedOutside}}} - laptop := nodeOf(t, &in, "laptop") - laptop.Assigned = append(laptop.Assigned, "mailer") - laptop.SealingKey = keyOf(t, "laptop") - in.OwnSecrets = []OwnSecretRow{ - {Node: "laptop", Module: "mailer", Name: "credential-a", Origin: "accepted", SealedTo: laptop.SealingKey}, - {Node: "laptop", Module: "mailer", Name: "credential-b", Origin: "accepted", SealedTo: laptop.SealingKey, Sealed: "b-sealed"}, - } - got := rendered(t, in, "laptop", Foreseeing) - needed := got.Rendering.Needed["mailer"] - if needed["credential-a"] != catalogue.ForeseenSealed || needed["credential-b"] != "b-sealed" { - t.Errorf("needed %v", needed) - } - if !has(got.Foreseen, "mailer/credential-a") || has(got.Foreseen, "mailer/credential-b") { - t.Errorf("foreseen %v", got.Foreseen) - } -} - -// Round 1, finding 5: the suffix internal names end in is a field of the Input, and the environment is never read. -func TestTheInternalSuffixIsTheInputsAndNeverTheEnvironments(t *testing.T) { - t.Setenv(overlay.SuffixVar, "from-the-environment") - in := aGrantMesh(t) - got := rendered(t, in, "anchor", Foreseeing) - if got.Rendering.Suffix != "internal" || got.Rendering.Grants[0].At != "laptop.internal" { - t.Errorf("by default: suffix %q, grant at %q", got.Rendering.Suffix, got.Rendering.Grants[0].At) - } - in.InternalSuffix = ".lan" - got = rendered(t, in, "anchor", Foreseeing) - if got.Rendering.Suffix != "lan" || got.Rendering.Grants[0].At != "laptop.lan" { - t.Errorf("given: suffix %q, grant at %q", got.Rendering.Suffix, got.Rendering.Grants[0].At) - } - where, err := newComposer(in).whereEveryoneIs() - if err != nil || where["anchor"] != "anchor.lan" { - t.Errorf("where everyone is: %v %v", where, err) - } - if names := newComposer(in).namesInTheMesh(); names["anchor.lan"] == "" || names["anchor.internal"] != "" { - t.Errorf("names: %v", names) - } -} - -// Round 2: a need answered on the same machine is a grant too (catalogue resolve.go gives it From: the node's own name). -func TestAConsumerOnTheProvidersOwnNodeIsGrantedWithAStandInAndNamedForeseen(t *testing.T) { - in := aGrantMesh(t) - in.Issued = nil - laptop, anchor := nodeOf(t, &in, "laptop"), nodeOf(t, &in, "anchor") - laptop.Assigned = []string{overlay.Name} - anchor.Assigned = append(anchor.Assigned, "files") - got := rendered(t, in, "anchor", Foreseeing) - if len(got.Rendering.Grants) != 1 || got.Rendering.Grants[0].Consumer != "anchor" || got.Rendering.Grants[0].Sealed != catalogue.ForeseenSealed { - t.Fatalf("grants are %+v", got.Rendering.Grants) - } - if !has(got.Foreseen, "the grant of s3-bucket for files on anchor") { - t.Errorf("foreseen %v", got.Foreseen) - } -} diff --git a/go/compose/fixture_test.go b/go/compose/fixture_test.go deleted file mode 100644 index c6f1188..0000000 --- a/go/compose/fixture_test.go +++ /dev/null @@ -1,90 +0,0 @@ -package compose - -import ( - "crypto/ecdh" - "crypto/rand" - "encoding/base64" - "encoding/json" - "reflect" - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/overlay" -) - -// aPublicKey is a key a machine could have reported, made as mesh-controller's mesh_for_test.go aPublicKey makes it. -func aPublicKey(t *testing.T) string { - t.Helper() - k, err := ecdh.X25519().GenerateKey(rand.Reader) - if err != nil { - t.Fatal(err) - } - return base64.StdEncoding.EncodeToString(k.PublicKey().Bytes()) -} - -// keys are made once per run, so two Inputs made the same way are equal. -var keys = map[string]string{} - -func keyOf(t *testing.T, name string) string { - t.Helper() - if keys[name] == "" { - keys[name] = aPublicKey(t) - } - return keys[name] -} - -// theOverlayModule is the module the mesh provides for itself, as mesh-controller's providedModules makes it -// (cmd/mesh-controller/modules.go at 6c2a8d62). -func theOverlayModule(t *testing.T) catalogue.Manifest { - t.Helper() - var m catalogue.Manifest - b, err := json.Marshal(overlay.Manifest()) - if err != nil { - t.Fatal(err) - } - if err := json.Unmarshal(b, &m); err != nil { - t.Fatal(err) - } - return m -} - -// aMesh is the Input of mesh-controller's aMesh (cmd/mesh-controller/mesh_for_test.go at 6c2a8d62): two placed -// machines, `anchor` the hub, both reporting container-runtime, wireguard and systemd, both assigned what puts them on the -// private network, each at .example:51820 on the site `here`, anchor at 10.77.0.1 and laptop at 10.77.0.2. -func aMesh(t *testing.T) Input { - t.Helper() - in := Input{Catalogue: map[string]catalogue.Manifest{overlay.Name: theOverlayModule(t)}} - for i, name := range []string{"anchor", "laptop"} { - in.Nodes = append(in.Nodes, Node{ - Name: name, - Place: Place{Key: keyOf(t, name), Endpoint: name + ".example:51820", Site: "here", - Hub: name == "anchor", Address: []string{"10.77.0.1", "10.77.0.2"}[i]}, - Assigned: []string{overlay.Name}, - Capabilities: map[string]bool{"container-runtime": true, "wireguard": true, "systemd": true}, - }) - } - return in -} - -// nodeOf is the named node of an Input, for a test to change. -func nodeOf(t *testing.T, in *Input, name string) *Node { - t.Helper() - for i := range in.Nodes { - if in.Nodes[i].Name == name { - return &in.Nodes[i] - } - } - t.Fatalf("no node %s", name) - return nil -} - -// unchanged runs call on one Input and compares it with another made the same way: the composition changes nothing in what -// it is given (deep equality, nothing shared). -func unchanged(t *testing.T, mk func() Input, call func(Input)) { - t.Helper() - used, same := mk(), mk() - call(used) - if !reflect.DeepEqual(used, same) { - t.Fatalf("the call changed its Input") - } -} diff --git a/go/compose/frombus/answer.go b/go/compose/frombus/answer.go deleted file mode 100644 index 64eb6d2..0000000 --- a/go/compose/frombus/answer.go +++ /dev/null @@ -1,582 +0,0 @@ -package frombus - -import ( - "context" - "errors" - "fmt" - "maps" - "regexp" - "slices" - "sort" - "strings" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/compose" - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -// Answer is a node's answer to its proposal's placement candidates (novox/hq ADR 0567 §3, ADR 0559 §4): s.Node composed in shadow with -// s.Placements laid over the trunk's placement (compose.CompositionsShadow, its own sealed values and boxes through r as -// for its own composition), compared with what it runs (delivery.CompareComposed). It applies nothing and writes -// nothing; the proposal and the revision are the candidate's, said back in the answer. -// -// The node's own composition is compared only where the node runs one. fps gives the build each module is composed at: what the node runs, and for a module the candidate places that the -// node does not run, the build the trunk holds; a placed module with none is added, with no build of it known. What the -// composition stands in for is judged: a value of a module the proposal adds (on this node or another: its credential, own -// secrets and boxes are made only once it is placed, ADR 0549 §5) is not yet issued, which is no refusal; any other -// stand-in is a value missing for a module the node runs, a refusal naming it ("node" when it is the node's own). -func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]string, running []delivery.Composed, proposal string, revision uint64) delivery.CandidateAnswer { - a := delivery.CandidateAnswer{Proposal: proposal, Revision: revision} - refuse := func(err error) delivery.CandidateAnswer { - a.Refused = err.Error() - a.Verdict = delivery.CandidateRefused - return a - } - trunk, err := r.Placements(ctx) - if err != nil { - return refuse(fmt.Errorf("frombus: placement: %w", err)) - } - if s.Running == nil { - s.Running = running - } - // 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() }, nil) - if err != nil { - return refuse(err) - } - asConsumer, asProvider, err := compose.CredentialsNeeded(in, s.Node) - if err != nil { - return refuse(err) - } - // What the node runs, by module. A consumer's credential is asked only for a module it runs: the vault refuses the - // whole ask for a consuming module not assigned to the node (mesh-vault's own), and a module the candidate adds is - // assigned only after the merge (ADR 0549 §5), so its credential is left missing, a stand-in answered as not yet - // issued under Added (review of L2b, round 1, finding 1). - runs := map[string]bool{} - for _, c := range running { - if c.Module != "" { - runs[c.Module] = true - } - } - var consumed []compose.CredentialKey - for _, k := range asConsumer { - if runs[k.ConsumerModule] { - consumed = append(consumed, k) - } - } - asConsumer = consumed - if len(asConsumer)+len(asProvider) > 0 { - if in.Credentials, in.Issued, err = r.Credentials(ctx, s.Node, asConsumer, asProvider); err != nil { - 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, 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 { - return refuse(err) - } - failed[delivery.HaveNodeWord] = err.Error() - composed, skipped, stood = nil, nil, nil - } - // The node's own composition is judged only where the node runs one: one that runs none applies it only once a want - // names it (the node agent's own composer hands it on that want alone), so a candidate does not add it. - runsOwn := false - for _, c := range running { - runsOwn = runsOwn || c.Module == "" - } - if !runsOwn { - for key, c := range composed { - if c.Module == "" { - delete(composed, key) - } - } - } - if _, whole := failed[delivery.HaveNodeWord]; whole { - // Nothing composed to compare: every refusal is said, and nothing else. - for _, m := range sortedKeys(failed) { - a.Refusals = append(a.Refusals, delivery.ModuleRefused{Module: m, Why: failed[m]}) - } - heldAsToday(&a, s.HeldToday) - a.Verdict = delivery.VerdictOf(a) - return a - } - // A module the composition folds inside the node's own is judged there: the agent also says it runs by module, from - // the declaration it kept before the switch (agent.Everything), which is no removal (issue 1126, seed #531). - if len(s.InOwn) > 0 { - var judged []delivery.Composed - for _, c := range running { - if c.Module == "" || !slices.Contains(s.InOwn, c.Module) { - judged = append(judged, c) - } - } - running = judged - } - cmp := delivery.CompareComposedWith(composed, running, s.SameValue) - // The answer rides in the node's have key: its shown bodies are bounded, the rest named only. - budget := MaxShown - for i := range cmp.Differs { - kept := cmp.Differs[i].Bodies[:0] - for _, b := range cmp.Differs[i].Bodies { - if n := len(b.Composed) + len(b.Running); n <= budget { - budget -= n - kept = append(kept, b) - } else { - kept = append(kept, delivery.ResourceBodies{ID: b.ID, Why: b.Why}) - a.Note = "some differing resources are named without their bodies: the answer holds at most " + - fmt.Sprint(MaxShown) + " bytes of them" - } - } - cmp.Differs[i].Bodies = kept - } - // A refused module was composed as a stub: what it composed is no difference; its refusal is said. - notFailed := func(m string) bool { _, ok := failed[m]; return !ok } - a.Same = slices.DeleteFunc(cmp.Same, func(m string) bool { return !notFailed(m) }) - a.Removed = slices.DeleteFunc(cmp.Removed, func(m string) bool { return !notFailed(m) }) - a.Differs = slices.DeleteFunc(cmp.Differs, func(d delivery.ModuleDiffers) bool { return !notFailed(d.Module) }) - cmp.Added = slices.DeleteFunc(cmp.Added, func(d delivery.ModuleAdded) bool { return !notFailed(d.Module) }) - skipped = slices.DeleteFunc(skipped, func(m string) bool { return !notFailed(m) }) - - // What the proposal adds, by node and module: placed by a candidate and not by the trunk on that node. - added := map[string]bool{} - for node, p := range s.Placements { - if p == nil { - continue - } - was := map[string]bool{} - for _, m := range trunk[node].Modules { - was[m.Module] = true - } - for _, m := range p.Modules { - if !was[m.Module] { - added[node+"/"+m.Module] = true - } - } - } - unissued := map[string][]string{} - refusals := map[string][]string{} - for _, x := range stood { - node, module := standsInFor(x, s.Node) - switch { - case node == s.Node && runs[module]: - // A value missing for a module the node runs is a refusal, whatever the trunk places (the seed's trunk is - // empty, and every module counts as the group's). - refusals[module] = append(refusals[module], x) - case module != "" && added[node+"/"+module]: - if node == s.Node { - unissued[module] = append(unissued[module], x) - } // another node's addition: the module here that carries it differs, which the assigner judges - case module == "" || node != s.Node: - // The node's own, or a value this node holds for a module elsewhere (a grant it provides): said as the node's. - refusals[delivery.HaveNodeWord] = append(refusals[delivery.HaveNodeWord], x) - default: - refusals[module] = append(refusals[module], x) - } - } - addedHere := map[string]delivery.ModuleAdded{} - for _, m := range cmp.Added { - addedHere[m.Module] = m - } - for _, m := range skipped { // placed, and no build given to compose it at - addedHere[m] = delivery.ModuleAdded{Module: m, Unissued: []string{"no build of " + m + " is known to compose it at"}} - } - for m, xs := range unissued { - got := addedHere[m] - got.Module = m - got.Unissued = append(got.Unissued, xs...) - addedHere[m] = got - } - for _, m := range sortedKeys(addedHere) { - a.Added = append(a.Added, addedHere[m]) - } - for _, m := range sortedKeys(refusals) { - if _, isAdded := addedHere[m]; isAdded { - continue // a module the candidate adds here: what it lacks is said under Added - } - if _, isFailed := failed[m]; isFailed { - continue // said below, as why it could not be composed - } - a.Refusals = append(a.Refusals, delivery.ModuleRefused{Module: m, Why: "stands in for " + strings.Join(refusals[m], ", ")}) - } - for _, m := range sortedKeys(failed) { - a.Refusals = append(a.Refusals, delivery.ModuleRefused{Module: m, Why: failed[m]}) - } - sort.Slice(a.Refusals, func(i, j int) bool { return a.Refusals[i].Module < a.Refusals[j].Module }) - heldAsToday(&a, s.HeldToday) - a.Verdict = delivery.VerdictOf(a) - return a -} - -// 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): 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 - } - 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 - if len(a.Refusals) == 0 { - a.Refusals = nil - } -} - -// maxCulprits bounds the modules one refusal is tried on, each a whole composition. -const maxCulprits = 8 - -// MaxShown bounds the bytes of shown resource bodies in one answer (delivery.ModuleDiffers.Bodies). -const MaxShown = 256 << 10 - -var ( - grantStandIn = regexp.MustCompile(`^the grant of \S+ for (\S+) on (\S+)$`) - // 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 -// `port module:port`, a grant `the grant of for on `, a need ` on needs ...`; -// the module empty when the words name none (the certificate, the authority). -func standsInFor(x, node string) (string, string) { - if m := grantStandIn.FindStringSubmatch(x); m != nil { - return m[2], m[1] - } - if m := needsStandIn.FindStringSubmatch(x); m != nil { - return m[2], m[1] - } - if rest, ok := strings.CutPrefix(x, "port "); ok { - if module, _, ok := strings.Cut(rest, ":"); ok { - return node, module - } - } - if module, _, ok := strings.Cut(x, "/"); ok && !strings.Contains(x, " ") { - return node, module - } - return node, "" -} - -// composeEach is the one rule, for the node that applies and for its answer to a candidate alike, by which a module's -// own refusal refuses that module and never the node (issue 1126: seed-8 fixed it in the answer alone, and the -// applying path still refused all of novox for de-spiegel's unset route names). The node is composed; a refusal is laid -// on the module whose stub (compose.Stub) changes it (culprit), that module is held (compose.Node.Held) and said in -// failed with the refusal's words, and the rest is composed again, until it composes or the refusal is no single -// module's, which is answered as the node's (err). Applied composes as the node applies (compose.Reading) and holds the -// module placed here each stand-in is for; one for no module placed here (the node's own, a value this node holds for a -// module elsewhere) refuses the node (compose.ErrStoodIn). Not applied, it composes in compose.Foreseeing and answers -// every stand-in for the caller to judge (Answer). -// -// **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. -// -// **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 - } - runs := map[string]*delivery.Composed{} - for i := range running { - if m := running[i].Module; m != "" { - runs[m] = &running[i] - } - } - 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 again keeps the words it was first held for - } - if _, ok := src[m]; !ok { - man, ok := manifestOn(in, node, m) - if !ok { - return - } - if ran != nil { - if was, ok := ran(m); ok { - man.Contributes, man.ContributesMany, man.Listens = was.Contributes, was.ContributesMany, was.Listens - } - } - src[m] = man - } - 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], "") - } - for { - out, skipped, stood, err = compose.CompositionsIn(in, node, fps, mode) - if err != nil { - module, leave := culprit(in, node, fps, mode, failed, err) - if module == "" { - return nil, nil, nil, firstReason(err, failed) - } - hold(module, err.Error(), leave) - continue - } - again := false - if applied && len(stood) > 0 { - placed := map[string]bool{} - for _, m := range assignedOn(in, node) { - placed[m] = true - } - of := map[string][]string{} - var nodes []string - for _, x := range stood { - if n, m := standsInFor(x, node); n == node && placed[m] { - of[m] = append(of[m], x) - } else { - nodes = append(nodes, x) - } - } - for _, m := range sortedKeys(of) { - if _, done := failed[m]; !done { - 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 { - break - } - } - skipped = slices.DeleteFunc(skipped, func(m string) bool { _, held := failed[m]; return held }) - return out, skipped, stood, nil -} - -// 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 strings.Contains(err.Error(), m) { - named = append(named, m+" is held: "+failed[m]) - } - } - if len(named) == 0 { - return err - } - return fmt.Errorf("%s; its stand-in is then refused: %w", strings.Join(named, "; "), err) -} - -// 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 again == nil || again.Error() != err.Error() -} - -// 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 { - for _, n := range in.Nodes { - if n.Name == node { - return n.Assigned - } - } - return nil -} - -// manifestOn is the manifest node composes module from: its Kept, else the catalogue's. -func manifestOn(in compose.Input, node, module string) (catalogue.Manifest, bool) { - for _, n := range in.Nodes { - if n.Name == node { - if man, ok := n.Kept[module]; ok { - return man, true - } - } - } - man, ok := in.Catalogue[module] - return man, ok -} - -// 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). - var order []string - for _, m := range assigned { - if strings.Contains(err.Error(), m) && len(order) < maxCulprits { - order = append(order, m) - } - } - 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, "") { - return m, "" - } - } - for _, m := range order { - man, ok := manifestOn(in, node, m) - if !ok { - continue - } - // 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 "", "" -} - -// 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, 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 - } - 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; 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 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 { - kept[k] = v - } - kept[module] = compose.Carried(man, module, runs) - out.Nodes[i].Kept = kept - held := make(map[string]bool, len(out.Nodes[i].Held)+1) - for k, v := range out.Nodes[i].Held { - held[k] = v - } - held[module] = true - out.Nodes[i].Held = held - } - return out -} diff --git a/go/compose/frombus/answer_rows_test.go b/go/compose/frombus/answer_rows_test.go deleted file mode 100644 index 167bb27..0000000 --- a/go/compose/frombus/answer_rows_test.go +++ /dev/null @@ -1,595 +0,0 @@ -package frombus - -import ( - "context" - "encoding/json" - "fmt" - "os" - "reflect" - "slices" - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/compose" - "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/overlay" -) - -// Issue 1126 (the seed's shadow, round 2): a node's answer names every module it cannot compose, not the first only; -// and a module folded into its own composition that names a build of the old form (de-spiegel on novox, nats, photos, -// systemd-resolved: their haves name a build-... id and no fingerprint) is left to the node, carried as it runs. -func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { - ctx := context.Background() - image := "alpine@sha256:" + strings.Repeat("a", 64) - manifest := func(module string) []byte { - // Its data names its directory, as nats's names ${dir:mesh-state} (issue 1126: novox's own composition refused - // "nats says ${dir:mesh-state}, and nats declares no directory mesh-state" when its stub held no directory). - return []byte(`{"module":"` + module + `","version":"1","capabilities":["container-runtime"],` + - `"own-secrets":{"token":"${dir:state}/token"},` + - `"resources":[{"id":"state","type":"directory","path":"/var/lib/` + module + `","mode":"0700"},` + - `{"id":"server","type":"container","name":"` + module + `","artifact":"site"}],` + - `"build":{"artifacts":[{"name":"site","kind":"upstream","from":"` + image + `"}]}}`) - } - lemurs := []byte(`{"module":"lemurs","version":"1","resources":[{"id":"service","type":"service","unit":"lemurs.service","state":"running","boot":"enabled"}]}`) - fp := func(c string) string { return "src2:" + strings.Repeat(c, 64) } - mk := func(placed ...string) *bus { - p := delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}} - for _, m := range placed { - p.Modules = append(p.Modules, delivery.PlacedModule{Module: m}) - } - b := &bus{placements: map[string]delivery.Placement{"n1": p}, - records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t), SealingKey: "sk", Capabilities: []string{"systemd", "container-runtime"}}}, - modules: map[string]delivery.Module{"lemurs": {Manifest: delivery.ManifestDigest(lemurs)}}, - manifests: map[string][]byte{delivery.ManifestDigest(lemurs): lemurs}, - builds: map[string]*delivery.BuilderBuild{}} - for _, m := range []string{"web", "api", "spiegel"} { - b.own = append(b.own, compose.OwnSecretRow{Node: "n1", Module: m, Name: "token", Origin: "made", SealedTo: "sk", Sealed: `"box"`}) - } - for _, m := range []string{"web", "api", "spiegel"} { - raw := manifest(m) - b.manifests[delivery.ManifestDigest(raw)] = raw - b.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)} - } - // web and api are built, their keys done and waiting for the reference of their image; spiegel has no build of - // the new form at all. - for i, m := range []string{"web", "api"} { - mod := b.modules[m] - mod.FP2 = fp(string(rune('1' + i))) - b.modules[m] = mod - b.builds[m+" "+mod.FP2] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest, - Artifacts: []delivery.BuiltArtifact{{Name: "site", Kind: "image", Digest: "sha256:" + strings.Repeat("b", 64)}}} - } - return b - } - given := func(b *bus) map[string]*delivery.Placement { - p := b.placements["n1"] - return map[string]*delivery.Placement{"n1": &p} - } - - // Every refusal: two builds taken in, and a module the composition refuses (its image never built), each named. - b := mk("lemurs", "web", "api", "spiegel") - a := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b)}, - map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "p-h1", 3) - var refused []string - for _, r := range a.Refusals { - refused = append(refused, r.Module) - } - if a.Verdict != delivery.CandidateRefused || a.Refused != "" || !reflect.DeepEqual(refused, []string{"api", "spiegel", "web"}) { - t.Errorf("every refusal: %+v, want api, spiegel and web refused, each named", a) - } - for _, r := range a.Refusals { - want := map[string]string{"api": "waits for the reference of site", "web": "waits for the reference of site", - "spiegel": "spiegel has not been built for this mesh"}[r.Module] - if !strings.Contains(r.Why, want) { - t.Errorf("%s refused as %q, want %q", r.Module, r.Why, want) - } - } - if raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Candidates: map[string]delivery.CandidateAnswer{"g": a}}); err != nil { - t.Errorf("the answer is not one a have holds: %v (%s)", err, raw) - } - if lemursOK := slices.ContainsFunc(a.Added, func(m delivery.ModuleAdded) bool { return m.Module == "lemurs" }); !lemursOK { - t.Errorf("lemurs, which composes, is not answered beside the refusals: %+v", a) - } - // Issue 1126: a module the node holds today that the candidate holds too is held as today, no refusal: with every - // refused module held today the verdict is no refusal, and each is named under Held. - today := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), HeldToday: map[string]string{"api": "held", "spiegel": "held", "web": "held"}}, - map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "p-h1", 3) - if today.Verdict == delivery.CandidateRefused || len(today.Refusals) != 0 || len(today.Held) != 3 || today.Held[0].Why == "" { - t.Errorf("held as today: %+v", today) - } - // A refused module the node does not hold today stays a refusal: holding only api today leaves spiegel and web refused. - part := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), HeldToday: map[string]string{"api": "held"}}, - map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "p-h1", 3) - 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) - } - // 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: "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 != "mailu" || len(a.Held) != 1 { - t.Errorf("held as today spreads: %+v", a) - } - } - - // The fold: spiegel runs inside the node's own composition at a build of the old form. Its resources are carried as - // they run, each after the one it runs after: the node's own is the one it runs, and the node composes itself. - b = mk("lemurs", overlay.Name, "spiegel") - p := b.placements["n1"] - p.Decided.Endpoint = "n1.example:51820" - b.placements["n1"] = p - fps := map[string]string{"lemurs": fp("e")} - stub := Scope{Node: "n1"} - in, err := Input(ctx, b, Mesh{}, stub) - if err != nil { - t.Fatal(err) - } - in.Nodes[0].Kept = map[string]catalogue.Manifest{"spiegel": compose.Stub(in.Catalogue["spiegel"])} - base, _, err := compose.Compositions(in, "n1", fps, compose.Reading) - if err != nil { - t.Fatal(err) - } - // A refusal naming no module placed here is no module's: no compose is spent stubbing each in turn (issue 1126: ace's - // answer took 7m46s, one compose per module), though here a stub would compose. - if m, _ := culprit(in, "n1", fps, compose.Foreseeing, map[string]string{}, fmt.Errorf("nothing in this mesh provides \"artifact-store\"")); m != "" { - t.Errorf("a refusal naming no placed module was laid on %s", m) - } - ownKey, _ := delivery.ComposedNodeKey("n1") - own := base[ownKey] - carriedRes := []json.RawMessage{ - json.RawMessage(`{"id":"spiegel.state","type":"directory","path":"/var/lib/spiegel","mode":"0700"}`), - json.RawMessage(`{"id":"spiegel.server","type":"container","name":"spiegel","image":"registry.example/spiegel@sha256:` + strings.Repeat("c", 64) + `"}`)} - // They run after the node's first own resource. - own.Resources = append(append(append([]json.RawMessage{}, own.Resources[:1]...), carriedRes...), own.Resources[1:]...) - var running []delivery.Composed - for k, c := range base { - if k == ownKey { - c = own - } - running = append(running, c) - } - scope := Scope{Node: "n1", InOwn: []string{"spiegel"}, Running: running} - got, _, held, err := Compose(ctx, b, Mesh{}, scope, fps) - if err == nil && len(held) > 0 { - err = fmt.Errorf("modules held: %v", held) - } - if err != nil { - t.Fatalf("the node does not compose itself with spiegel folded at a build of the old form: %v", err) - } - gotOwn, _ := json.Marshal(got[ownKey].Resources) - wantOwn, _ := json.Marshal(own.Resources) - if string(gotOwn) != string(wantOwn) { - t.Errorf("the node's own composition is\n%s\nwant what runs\n%s", gotOwn, wantOwn) - } - a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, running, "p-h1", 3) - if a.Verdict != delivery.CandidateSame { - t.Errorf("the fold's answer: %+v, want the same", a) - } - // (A) The agent also says spiegel runs by module, from the declaration kept before the switch (agent.Everything): a - // module the composition folds is judged inside the node's own, not as removed (issue 1126, seed #531's answer). - keptToo := append(append([]delivery.Composed{}, running...), delivery.Composed{Node: "n1", Module: "spiegel", Resources: carriedRes}) - if a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, keptToo, "p-h1", 3); a.Verdict != delivery.CandidateSame || len(a.Removed) != 0 { - t.Errorf("a folded module running from the kept declaration is answered %+v, want the same", a) - } - // (B) The node's own composition carries its bus membership as it runs it (the controller's, from its store): composed - // the same, not 'in what runs, not composed' (seed #531, novox). - membership := json.RawMessage(`{"id":"bus-membership","type":"file","path":"` + catalogue.BusMembershipPath + `","sealed":"{\"box\":\"m\"}","mode":"0600"}`) - withMembership := make([]delivery.Composed, 0, len(running)) - for _, c := range running { - if c.Module == "" { - c.Resources = append(append([]json.RawMessage{}, c.Resources...), membership) - } - withMembership = append(withMembership, c) - } - if a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, withMembership, "p-h1", 3); a.Verdict != delivery.CandidateSame { - t.Errorf("the node's own with its bus membership is answered %+v, want the same", a) - } - // Without what runs, the fold composes spiegel from its manifest, as before, and the composition refuses it. - if _, _, held, err := Compose(ctx, b, Mesh{}, Scope{Node: "n1", InOwn: []string{"spiegel"}}, fps); err != nil || - !strings.Contains(held["spiegel"], "spiegel has not been built for this mesh") { - t.Errorf("composed spiegel without what runs: %v %v, want spiegel held alone", err, held) - } - - // (C) Issue 1126, C5: what the others read of a module carried as it runs is what runs, never its stub's: the filter - // opens the machine port its running container publishes (de-spiegel's 20005 on novox, not its software's 8080; a - // stub had no container, so snake's 80 on ace was opened beside the route proxy's). And C6: the operator export's file - // a module that keeps it runs is carried as it runs (mesh-vault.kept on novox), since the node holds no export. - listening := []byte(`{"module":"spiegel","version":"1","capabilities":["container-runtime"],` + - `"listens":[{"port":8080,"from":"mesh","why":"the site"}],"own-secrets":{"token":"${dir:state}/token"},` + - `"resources":[{"id":"state","type":"directory","path":"/var/lib/spiegel","mode":"0700"},` + - `{"id":"server","type":"container","name":"spiegel","artifact":"site","ports":["8080"]}],` + - `"build":{"artifacts":[{"name":"site","kind":"upstream","from":"` + image + `"}]}}`) - nft := []byte(`{"module":"nft","version":"1","capabilities":["systemd"],"filtering":{"into":"/etc/nftables.conf"}}`) - vault := []byte(`{"module":"vault","version":"1","capabilities":["systemd"],"keeps":"/var/lib/vault/root",` + - `"resources":[{"id":"service","type":"service","unit":"vault.service","state":"running","boot":"enabled"}]}`) - b = mk("lemurs", overlay.Name, "spiegel", "nft", "vault") - for m, raw := range map[string][]byte{"spiegel": listening, "nft": nft, "vault": vault} { - 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" - for i := range p.Modules { - if p.Modules[i].Module == "spiegel" { - p.Modules[i].Ports = map[string]int{"8080": 20005} - } - } - b.placements["n1"] = p - rec := b.records["n1"] - rec.OutwardLinks = []string{"eth0"} - b.records["n1"] = rec - fps = map[string]string{"lemurs": fp("e"), "nft": fp("f"), "vault": fp("9")} - if in, err = Input(ctx, b, Mesh{}, stub); err != nil { - t.Fatal(err) - } - in.Nodes[0].Kept = map[string]catalogue.Manifest{"spiegel": compose.Stub(in.Catalogue["spiegel"])} - if base, _, err = compose.Compositions(in, "n1", fps, compose.Reading); err != nil { - t.Fatal(err) - } - keptFile := json.RawMessage(`{"digest":"sha256:` + strings.Repeat("d", 64) + `","id":"vault.kept","mode":"0600","module":"vault","path":"/var/lib/vault/root/export.json","size":143604,"type":"file"}`) - running = nil - for _, c := range base { - switch c.Module { - case "": - c.Resources = append(append([]json.RawMessage{}, c.Resources...), - json.RawMessage(`{"id":"spiegel.state","type":"directory","path":"/var/lib/spiegel","mode":"0700"}`), - json.RawMessage(`{"id":"spiegel.server","type":"container","name":"spiegel","image":"registry.example/spiegel@sha256:`+strings.Repeat("c", 64)+`","ports":["10.77.0.1:20005:8080"]}`), - json.RawMessage(`{"id":"spiegel.server-env","type":"file","path":"/var/lib/spiegel/server.env","mode":"0600","content":"A=1\n"}`)) - case "vault": - c.Resources = append(append([]json.RawMessage{}, c.Resources...), keptFile) - } - running = append(running, c) - } - got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", InOwn: []string{"spiegel"}, Running: running}, fps) - if err == nil && len(held) > 0 { - err = fmt.Errorf("modules held: %v", held) - } - if err != nil { - t.Fatalf("the node does not compose itself with spiegel carried: %v", err) - } - var rules string - var keptComposed json.RawMessage - for _, c := range got { - for _, raw := range c.Resources { - var r struct { - ID string `json:"id"` - Content string `json:"content"` - } - _ = json.Unmarshal(raw, &r) - switch r.ID { - case "nft." + catalogue.FilteringID(): - rules = r.Content - case "vault." + catalogue.KeptID(): - keptComposed = raw - } - } - } - if !strings.Contains(rules, "# spiegel — the site") || !strings.Contains(rules, "dport 20005 accept") || strings.Contains(rules, "dport 8080") { - t.Errorf("the filter does not open the machine port spiegel runs at:\n%s", rules) - } - // The paths a carried module owns are its manifest's, as the controller composes them, never what runs of it (issue - // 1126, seed-8 #542: on ace, the folded f1-circuits, snake and txt-game, whose manifests leave their directories and - // files to ${dir:…}, owned their /var/lib paths on the node and none on the controller, shifting node-tools' - // MESH_OWNED_PATHS). f1-circuits' manifest at the indexer's commit (c0ea2c94) is captured; what runs of it is - // composed from that manifest by the controller's rule (the directory at /var/lib/f1-circuits, its file, its container). - for _, c := range running { - if c.Module == "" && !reflect.DeepEqual(compose.Carried(in.Catalogue["spiegel"], "spiegel", &c).OwnsPaths, []string{"/var/lib/spiegel"}) { - t.Errorf("spiegel carried owns %v, want its manifest's /var/lib/spiegel alone", compose.Carried(in.Catalogue["spiegel"], "spiegel", &c).OwnsPaths) - } - } - f1raw, err := os.ReadFile("testdata/f1-circuits-c0ea2c94.module.json") - if err != nil { - t.Fatal(err) - } - f1, err := catalogue.Decode(f1raw) - if err != nil { - t.Fatal(err) - } - f1runs := delivery.Composed{Node: "ace", Resources: []json.RawMessage{ - json.RawMessage(`{"id":"f1-circuits.state","module":"f1-circuits","type":"directory","path":"/var/lib/f1-circuits","mode":"0700"}`), - json.RawMessage(`{"id":"f1-circuits.cache","module":"f1-circuits","type":"directory","path":"/var/lib/f1-circuits/cache","mode":"0700"}`), - json.RawMessage(`{"id":"f1-circuits.site-env","module":"f1-circuits","type":"file","path":"/var/lib/f1-circuits/site.env","mode":"0644","content":"F1_CIRCUITS_BASE_URL=https://f1.example\n"}`), - json.RawMessage(`{"id":"f1-circuits.server","module":"f1-circuits","type":"container","name":"f1-circuits","image":"registry.example/f1@sha256:` + strings.Repeat("c", 64) + `"}`)}} - if got, want := catalogue.DeclaredPaths(compose.Carried(f1, "f1-circuits", &f1runs)), catalogue.DeclaredPaths(f1); !reflect.DeepEqual(got, want) { - t.Errorf("f1-circuits carried owns %v, want what the controller composes from its manifest, %v", got, want) - } - // Issue 1126: a held module hands no template its build kept for a file it no longer carries (novox's node: - // "postgres: its build kept a template for consumers"), but keeps its items and their templates, so what it - // contributes to other modules' collections still reaches them. - for _, row := range []struct { - name string - handed *catalogue.Handed - items []catalogue.Item - }{ - {"templates and items", &catalogue.Handed{Templates: map[string]string{"consumers": "files/consumers.json"}, - Items: map[string]string{"routes": "files/routes.tmpl"}}, []catalogue.Item{{Collection: "routes", Content: "x"}}}, - {"items alone", nil, []catalogue.Item{{Collection: "routes", Content: "x"}}}, - } { - kept := f1 - kept.Handed, kept.Items = row.handed, row.items - st := compose.Stub(kept) - if st.Handed != nil && st.Handed.Templates != nil { - t.Errorf("%s: a stub hands templates %v", row.name, st.Handed.Templates) - } - if !reflect.DeepEqual(st.Items, row.items) { - t.Errorf("%s: a stub collects %+v, want its items %+v", row.name, st.Items, row.items) - } - if row.handed != nil && (st.Handed == nil || !reflect.DeepEqual(st.Handed.Items, row.handed.Items) || row.handed.Templates == nil) { - t.Errorf("%s: a stub hands item templates %+v, want %v and its manifest untouched", row.name, st.Handed, row.handed.Items) - } - } - var k1, k2 any - _ = json.Unmarshal(keptComposed, &k1) - _ = json.Unmarshal(keptFile, &k2) - if !reflect.DeepEqual(k1, k2) { - t.Errorf("the operator export is composed as %s, want the file that runs %s", keptComposed, keptFile) - } - - // (D) Issue 1126, seed-8 (mesh-catalog #542): a module's own refusal refuses that module, never the node. novox runs - // 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; 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"}],` + - `"resources":[{"id":"service","type":"service","unit":"proxy.service","state":"running","boot":"enabled"}]}`) - 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"), "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 { - refused = append(refused, r.Module) - if r.Module == "de-spiegel" && (!strings.Contains(r.Why, "${config:praktijk-name}") || strings.Contains(r.Why, "${setting:")) { - 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"}) { - t.Errorf("a module's own refusal: %+v, want de-spiegel refused alone, the node, mailu and proxy not", 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 - // composed with no culprit, so de-spiegel's unset route names refused the whole node and every module it runs was - // marked not composed. Now de-spiegel is held, its refusal said in its config form's words, with proxy, which - // receives its routes; lemurs composes, and the node's own composition carries de-spiegel as it runs, so applying it - // removes nothing of it. - spiegelRuns := []json.RawMessage{ - json.RawMessage(`{"id":"de-spiegel.state","type":"directory","path":"/var/lib/de-spiegel","mode":"0755"}`), - json.RawMessage(`{"id":"de-spiegel.server-env","type":"file","path":"/var/lib/de-spiegel/server.env","mode":"0600","content":"A=1\n"}`), - json.RawMessage(`{"id":"de-spiegel.server","type":"container","name":"de-spiegel","image":"registry.example/de-spiegel@sha256:` + strings.Repeat("c", 64) + `"}`)} - if in, err = Input(ctx, b, Mesh{}, stub); err != nil { - t.Fatal(err) - } - in.Nodes[0].Kept = map[string]catalogue.Manifest{"de-spiegel": compose.Stub(in.Catalogue["de-spiegel"])} - in.Nodes[0].Kept["de-spiegel"] = func(m catalogue.Manifest) catalogue.Manifest { - m.Contributes, m.ContributesMany = nil, nil - return m - }(in.Nodes[0].Kept["de-spiegel"]) - if base, _, err = compose.Compositions(in, "n1", fps, compose.Reading); err != nil { - t.Fatal(err) - } - running = nil - for _, c := range base { - if c.Module == "" { - c.Resources = append(append([]json.RawMessage{}, c.Resources...), spiegelRuns...) - } - running = append(running, c) - } - got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", InOwn: []string{"de-spiegel"}, Running: running}, fps) - if err != nil { - t.Fatalf("de-spiegel's own refusal refuses the node's whole composition: %v", err) - } - 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) - } - 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() - ownRes, _ := json.Marshal(c.Resources) - for _, raw := range spiegelRuns { - var r struct{ ID string } - _ = json.Unmarshal(raw, &r) - if !strings.Contains(string(ownRes), `"id":"`+r.ID+`"`) { - t.Errorf("%s: the node's own composition drops %s, which de-spiegel runs: %s", what, r.ID, ownRes) - } - } - } - carries(got[ownKey], "folded") - // (F) Leaving the fold (review of the apply, round 1, finding 1): de-spiegel's want names a build of the new form, - // so it is no longer folded, while its names are still unset and the node's own still runs it. Held, it is carried - // in the node's own as it runs until a composition of its own is handed: else the node's own apply removes its - // container, files and units (issue 1945 by a new path). - fps["de-spiegel"] = fp("7") - got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", Running: running}, fps) - if err != nil || !strings.Contains(held["de-spiegel"], "${config:praktijk-name}") { - t.Fatalf("de-spiegel leaving the fold: %v, held %v", err, held) - } - spiegelKey, _ := delivery.ComposedKey("n1", "de-spiegel", fps["de-spiegel"]) - if _, ok := got[spiegelKey]; ok { - t.Errorf("held de-spiegel's own composition is handed") - } - carries(got[ownKey], "leaving the fold") - - // (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. 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"},` + - `{"id":"server","type":"container","name":"site","image":"registry.example/site@sha256:` + strings.Repeat("c", 64) + `","ports":["8080"]}]}`) - b = mk("lemurs", overlay.Name, "site", "nft") - for m, raw := range map[string][]byte{"site": site, "nft": nft} { - 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" - for i := range p.Modules { - if p.Modules[i].Module == "site" { - p.Modules[i].Ports = map[string]int{"8080": 20005} - } - } - b.placements["n1"] = p - rec = b.records["n1"] - rec.OutwardLinks = []string{"eth0"} - b.records["n1"] = rec - fps = map[string]string{"lemurs": fp("e"), "nft": fp("f"), "site": fp("8")} - siteRuns := delivery.Composed{Node: "n1", Module: "site", FP: fps["site"], Resources: []json.RawMessage{ - 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) - 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 { - if strings.Contains(string(raw), "dport 8080") || (c.Module == "nft" && !strings.Contains(string(raw), "dport 20005") && strings.Contains(string(raw), "# site")) { - t.Errorf("%s composes held site from its stub, not from what runs: %s", key, raw) - } - } - } - // (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 (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)} - for i := range p.Modules { - p.Modules[i].Ports = nil - } - 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 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 { - if strings.Contains(string(raw), "dport 9090") { - t.Errorf("%s opens 9090, which held site's new manifest names and nothing runs: %s", key, raw) - } - } - } -} - -// A module another node cannot run in this composition offers nothing in it: the refusal names it and why -// (issue 1126, probe #532: ace and g14 answered "nothing in this mesh provides artifact-store … assign distribution to a -// node" while novox runs it). And a refusal no module's stub changes is answered at once, not after a compose per module -// (ace took 7m46s). -func otherNodeNotResolvedIsNamed(t *testing.T) { - ctx := context.Background() - prov := []byte(`{"module":"prov","version":"1","capabilities":["container-runtime"],"provides":[{"name":"thing","scope":"mesh"}]}`) - cons := []byte(`{"module":"cons","version":"1","requires":["thing"]}`) - // n2 runs prov, which needs a container runtime n2's record does not say it has: n2's set does not resolve here. - b := &bus{placements: map[string]delivery.Placement{ - "n1": {Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1", Endpoint: "n1.example:51820"}, Modules: []delivery.PlacedModule{{Module: "cons"}, {Module: overlay.Name}}}, - "n2": {Node: "n2", Decided: delivery.NodeDecided{Address: "10.77.0.2"}, Modules: []delivery.PlacedModule{{Module: "prov"}, {Module: overlay.Name}}}}, - records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t)}, "n2": {Node: "n2", TunnelKey: publicKey(t)}}, - modules: map[string]delivery.Module{"prov": {Manifest: delivery.ManifestDigest(prov)}, "cons": {Manifest: delivery.ManifestDigest(cons)}}, - manifests: map[string][]byte{delivery.ManifestDigest(prov): prov, delivery.ManifestDigest(cons): cons}} - given := map[string]*delivery.Placement{} - for n, p := range b.placements { - p := p - given[n] = &p - } - a := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given}, map[string]string{"cons": "src2:" + strings.Repeat("1", 64)}, nil, "p-h1", 1) - if a.Verdict != delivery.CandidateRefused || !strings.Contains(a.Refused, "prov on n2 lacks container-runtime") { - t.Errorf("a refusal for want of n2's provider = %+v", a) - } -} - -// A node that reaches no artifact store says what its composition holds of one (issue 1126: ace and g14 answered -// "reaches no artifact store", and nothing said why). -func noStoreReachedSaysWhy(t *testing.T) { - in := compose.Input{Catalogue: map[string]catalogue.Manifest{ - "distribution": {Module: "distribution", Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5000}}}}, - Nodes: []compose.Node{{Name: "g14", Assigned: []string{"adwaita"}}, {Name: "novox", Assigned: []string{"distribution"}}}} - if got := storeSought(in); got != "the composition holds g14, novox; artifact-store is served by distribution on novox, none of it on the private network with a port" { - t.Errorf("said %q", got) - } - in.Nodes = in.Nodes[:1] - if got := storeSought(in); got != "the composition holds g14; no module placed there serves artifact-store" { - t.Errorf("said %q", got) - } -} diff --git a/go/compose/frombus/export.go b/go/compose/frombus/export.go deleted file mode 100644 index d28f95c..0000000 --- a/go/compose/frombus/export.go +++ /dev/null @@ -1,112 +0,0 @@ -package frombus - -import ( - "fmt" - "sort" - "strconv" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/compose" - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -// Export is one node's placement as the composition input the controller fills says it (novox/hq ADR 0555 §6, ADR 0567 -// §7): the inverse of Input for what a person decides, so the controller's one read-only verb `placement-export` -// answers, from its own store by its own code, exactly what the seat's adopt proposes as the seed. Its answer must stay -// stable for the same store, since adopt checks its digest again at the warrant. -// What the machine observes (its record) is not in it. -func Export(in compose.Input, node string) (delivery.Placement, error) { - var n *compose.Node - for i := range in.Nodes { - if in.Nodes[i].Name == node { - n = &in.Nodes[i] - } - } - if n == nil { - return delivery.Placement{}, fmt.Errorf("frombus: the input has no node %q", node) - } - // At the version a placement key is read at, with a list of modules even when none is placed: the export is the - // seed's value, and the reader refuses a value of version 0 or without its modules. - p := delivery.Placement{V: delivery.Version, Node: node, Modules: []delivery.PlacedModule{}, Decided: delivery.NodeDecided{ - Endpoint: n.Place.Endpoint, Site: n.Place.Site, Hub: n.Place.Hub, Address: n.Place.Address, - PublicDomain: n.PublicDomain, Account: n.Account, AccountHome: n.AccountHome, AgentAccount: n.AgentAccount, - AgentHome: n.AgentAccountHome, Adopted: n.Adopted, Taken: n.Taken}} - // The tunnel found at the node's adoption: the one its private network took over, or, on the hub, the mesh's adopted - // one whether or not the hub took it; and the names a person gave the hub's carried peers. - if t, ok := in.Tunnels[node]; ok { - rt := recordTunnelOf(t.Tunnel) - p.Decided.Tunnel = &rt - } else if in.Adopted != nil && in.Adopted.Hub == node { - rt := recordTunnelOf(in.Adopted.Tunnel) - p.Decided.Tunnel = &rt - } - if n.Place.Hub { - for _, c := range in.CarriedPeers { - if c.Named != "" { - if p.Decided.PeerNames == nil { - p.Decided.PeerNames = map[string]string{} - } - p.Decided.PeerNames[c.PublicKey] = c.Named - } - } - } - for _, module := range n.Assigned { - m := delivery.PlacedModule{Module: module} - for port, at := range n.Ports[module] { - if m.Ports == nil { - m.Ports = map[string]int{} - } - m.Ports[strconv.Itoa(port)] = at - } - for provision, to := range n.Bound[module] { - if m.Bindings == nil { - m.Bindings = map[string]delivery.Provider{} - } - m.Bindings[provision] = delivery.Provider{Node: to.Node, Module: to.Module} - } - // A module in the settings form keeps its config values in its layers, not in mesh-config: they are exported with - // the placement. A module in the config form has mesh-config's values laid into its layer, which are not. - settingsForm := !in.Catalogue[module].DeclaresConfig() - for _, layer := range n.Settings[module] { - if layer.From == catalogue.MeshWideLayer { - m.HasMesh, m.MeshWide = true, layer.Placement - if settingsForm && len(layer.Values) > 0 { - m.MeshValues = layer.Values - } - } else { - m.HasLayer, m.Layer = true, layer.Placement - if settingsForm && len(layer.Values) > 0 { - m.Values = layer.Values - } - } - } - p.Modules = append(p.Modules, m) - } - for provision, to := range n.Pins { - if p.Pins == nil { - p.Pins = map[string]delivery.Provider{} - } - p.Pins[provision] = delivery.Provider{Node: to.Node, Module: to.Module} - } - for _, h := range in.Holdings { - if h.Node == node { - p.Holds = append(p.Holds, delivery.Holding{Claim: h.Claim, Scope: h.Scope, Module: h.Module, Site: h.Site, Kind: h.Kind}) - } - } - sort.SliceStable(p.Holds, func(i, j int) bool { - if p.Holds[i].Claim != p.Holds[j].Claim { - return p.Holds[i].Claim < p.Holds[j].Claim - } - return p.Holds[i].Module < p.Holds[j].Module - }) - return p, nil -} - -func recordTunnelOf(t compose.Tunnel) delivery.RecordTunnel { - out := delivery.RecordTunnel{Interface: t.Interface, Unit: t.Unit, Config: t.Config, Port: t.Port, MTU: t.MTU, - Address: t.Address, Range: t.Range, PublicKey: t.PublicKey} - for _, peer := range t.Peers { - out.Peers = append(out.Peers, delivery.RecordTunnelPeer{PublicKey: peer.PublicKey, Address: peer.Address}) - } - return out -} diff --git a/go/compose/frombus/frombus.go b/go/compose/frombus/frombus.go deleted file mode 100644 index 2960221..0000000 --- a/go/compose/frombus/frombus.go +++ /dev/null @@ -1,1167 +0,0 @@ -// Package frombus fills mesh-sdk's composition input from what is on the bus, so a node agent, the composition check -// and the grants part compose with one composer over the same inputs (novox/hq ADR 0547 §2, ADR 0549 §3; issue 1126). -// -// **One composer, three callers.** compose.Compositions is the composer; Input is how each caller fills its -// compose.Input from the bus: the node agent for its own node (ADR 0547 §2), the seat mesh-placement's holder judging a -// proposal for every node (ADR 0567 §4), and the grants part (ADR 0547 §5). Each reads the bus with its own credential -// through a Reader, so this package holds no bus client, no store and no timer: what it is given is all it reads. -// -// **What it reads, and from whom.** -// - which modules run on each node, with their pins, holdings, host ports, bindings and settings placement: the -// seat mesh-placement's state `placement`, one key per node (ADR 0567 §1); -// - what a person decided of each node (its endpoint, site, hub, address on the private network, public domain and -// accounts): the same placement key, Placement.Decided; -// - what each node observes of itself (its tunnel key, capabilities, keys, certificate, links and networks): the -// node's own have key, `.node` (HaveNode.Record); the tunnel found at a node's adoption and the names of the hub's peers are decided, -// in its placement, and whether the hub took the tunnel over and who enrolled under a peer's key are read from both; -// - every module's manifest: the indexer's `modules.` and its manifests by digest; for each module placed on -// the composing node, its built manifest instead: the builder's done key at the trunk's FP2, or at the fingerprint -// the node's want names where it runs another build (Scope.KeptAt), resolved by the controller's own take-in rule -// (takein.Manifest), a moved module's templates archive read in the artifact store (issue 1126); -// - each module's config values on a node: mesh-config's resolved state, the composing node's slice alone unless the -// caller composes every node (ADR 0547 §2, the prepare step's L4); of another node, only the keys its modules' -// served values fill (catalogue.ServedSettings), which every consumer is told (issue 1126). -// -// **Own secrets and pair credentials** come from the vault sealed to the composing node's key (OwnSecrets, Credentials), -// for that node alone: as a consumer and as a provider, exactly the keys compose.CredentialsNeeded names. **Not yet -// read** (each a later subtask of issue 1126): licences (none is placed today) and core boxes. Without one -// the composition stands in for it, and Compose holds the module it stands in for (or refuses the node, for a stand-in -// of no module placed there; compose.ErrStoodIn): a node never applies a stand-in. No node is joining before ADR 0547's third milestone (ADR 0550 §6), so Joining is -// always empty. -package frombus - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "maps" - "net/netip" - "slices" - "sort" - "strings" - - "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" - "git.novox.be/novox/mesh-sdk/go/overlay" - "git.novox.be/novox/mesh-sdk/go/takein" -) - -// Reader is the bus as a caller reads it with its own credential. Each method reads one part's state; an error is a -// read that failed, never an absent key (an absent key is an empty answer). -type Reader interface { - // Placements is every node's placement, by node (delivery.PlacementBucket). - Placements(ctx context.Context) (map[string]delivery.Placement, error) - // Records is every node's record, by node: HaveNode.Record of each node's own have key, `.node` of - // delivery.HaveBucket (ADR 0555 §3). - Records(ctx context.Context) (map[string]delivery.NodeRecord, error) - // Modules is every module the indexer holds, by module (delivery.ModulesBucket). - Modules(ctx context.Context) (map[string]delivery.Module, error) - // Manifest is the raw manifest of a digest (delivery.ManifestsBucket). - Manifest(ctx context.Context, digest string) ([]byte, error) - // Values is one module's config values on one node as mesh-config resolved them (mesh-config's state `resolved`, - // keys `..`), decoded; nil when it has none. - Values(ctx context.Context, node, module string) (map[string]any, error) - // OwnSecrets is the own secrets of the composing node's modules, each sealed to that node's key, as the vault answers - // the node's engine (the vault's verb `own`, ADR 0550 §5); asked only for the node composing itself. - OwnSecrets(ctx context.Context, node string) ([]compose.OwnSecretRow, error) - // Credentials is, for the composing node, each pair credential it reads as a consumer, held and sealed to its key, - // and each it grants as a provider, with the copy sealed to it (the vault's `own` with the keys, ADR 0550 §5). A key - // the vault holds no value for is left out; the composition then stands in for it, and Compose refuses it. - Credentials(ctx context.Context, node string, asConsumer, asProvider []compose.CredentialKey) ([]compose.Credential, []compose.Issued, error) - // Build is a module's build at a fingerprint, the builder's key (delivery.BuilderBuildsBucket, BuilderBuildKey); nil - // when the builder holds none. The composing node composes each of its modules from its build (issue 1126). - Build(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error) - // Archive is the archive at url, a kept reference routed to the artifact store as the composing node reaches it - // (catalogue.Routed), checked against digest: a moved module's templates archive, read back for its texts as the - // controller's take-in reads it (takein.Built). - Archive(ctx context.Context, url, digest string) ([]byte, error) - // Served is, of one module's config values on another node than the composing one (mesh-config's state `resolved`, - // keys `..`), the keys named, decoded: the keys its served values and zone name fill - // (catalogue.ServedSettings), which every consumer is told and so every node may read (issue 1126). Nil when none - // is set. - Served(ctx context.Context, node, module string, keys []string) (map[string]any, error) -} - -// SignersReader is a Reader that also reads the vault's public authority from its state `signers` (delivery.SignersBucket, -// key delivery.SignersAuthorityKey; ADR 0550 §2). A Reader that has it composes the mesh's authority as a bundle, the -// controller's and the vault's; one that has not, or that answers none, composes the controller's alone, as before. -type SignersReader interface { - // VaultAuthority is the vault's authority certificate as PEM, checked (delivery.DecodeSignerAuthority); empty when the - // vault has published none. A read that fails is said by the reader and answered empty: a node that may not read the - // state yet composes as before rather than not at all. - 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. -type Mesh struct { - OverlayCIDR string - InternalSuffix string - Authority string - // Predefined is the list of predefined keys mesh-config published. No longer read: a reader decodes a manifest and - // takes the indexer's verdict on it, and judges nothing (novox/hq ADR 0578 §3); kept so a caller that sets it builds. - Predefined catalogue.PredefinedList -} - -// Scope says who composes. Node is the node composing; only its config values are read unless Every is set, as the -// composition check sets it to compose every node (of another node's modules, only the values their served values fill -// are read, Reader.Served). KeptAt is, by module, the fingerprint the composing node's want names where it differs from -// the indexer's trunk: that node composes the module from its build at that fingerprint, as it runs it. -// -// **Composed from its build** (issue 1126): each module placed on the composing node, and each in KeptAt, composes from -// its built manifest (takein.Manifest over the builder's done key at the trunk's FP2 or the kept fingerprint), as the -// controller composes from the manifest its take-in resolved: so a moved module is handed to the provisioner, a resource -// naming an artifact names what was built, and a templated file has its text. A placed module the builder holds no done -// build of at the trunk's FP2 composes from the indexer's manifest, as before. Every leaves the catalogue as the indexer -// read it: the check of every node judges placement, and reads no archive. -// -// Placements, when set, stands in for the placement of the nodes it names: a proposal's candidates -// (delivery.PlacementCandidate), so a node composes in shadow with a proposal laid over and compares the result with -// what it runs (ADR 0567 §3); a nil value removes the node's placement. The rest is read as always. -// -// InOwn is the modules the composing node runs inside its own composition (InOwn of what it runs), composed there again -// (compose.Node.InOwn) rather than cut out as modules no build is given for (issue 1126). -// -// Running is what the composing node runs; of it, its own composition (`.node`) is given to the composition -// (compose.Node.RunsOwn), so a module folded there that names a build of the old form is left to the node, carried -// as it runs (issue 1126). Nil composes every folded module from its manifest. -type Scope struct { - Node string - Every bool - KeptAt map[string]string - Placements map[string]*delivery.Placement - InOwn []string - Running []delivery.Composed - // HeldToday is, in an answer, the modules the node keeps today as they run ("node" for its own composition), each with - // how ("held", "failed", "runs a build of the old form"): a candidate that cannot compose one, or holds one only - // behind one of them, keeps it as today, which is said under CandidateAnswer.Held and is no refusal (issue 1126). - HeldToday map[string]string - // SameValue, in an answer, says whether two sealed values open to the same value (the node opens them with its own - // key), so a differing resource shows a value sealed again as such (delivery.CompareComposedWith). Nil shows them - // as differing. - SameValue func(a, b string) (same, known bool) -} - -// InOwn is, of the modules p places on its node, those whose resources the node's own composition it runs -// (`.node` in running) carries, by the module their ids name (declaration.ModuleGuess), sorted: the modules the -// controller's composed writer folded there because no fingerprint of the new form names them (mesh-wireguard, a module -// run at a build of the old form; issue 1126). A node composing itself passes it as Scope.InOwn, so its own -// composition keeps them as it runs them until a want names a build of the new form for one. -func InOwn(running []delivery.Composed, p delivery.Placement) []string { - placed := map[string]bool{} - for _, m := range p.Modules { - placed[m.Module] = true - } - in := map[string]bool{} - for _, c := range running { - if c.Module != "" { - continue - } - for _, raw := range c.Resources { - var head struct { - ID string `json:"id"` - Module string `json:"module"` - } - if json.Unmarshal(raw, &head) != nil || head.Module != "" { - continue - } - if m, ok := declaration.ModuleGuess(head.ID); ok && placed[m] { - in[m] = true - } - } - } - return sortedKeys(in) -} - -// Input is the composition input for s, read through r. -// -// **A refused manifest never ends the read** (novox/hq ADR 0578 §3): each manifest is decoded leniently -// (catalogue.Decode) and the indexer's verdict on it taken from its modules value (delivery.TakeManifestVerdict, a missing -// one accepted); a module whose manifest the indexer's judge refused, or whose bytes do not decode, stands in the -// catalogue as its stub (unusable), so what it provides and requires still shapes the rest, and is named with why in -// the input's Refused; a field set aside is named in its SetAside. Every other module of the read goes on. -func Input(ctx context.Context, r Reader, mesh Mesh, s Scope) (compose.Input, error) { - return input(ctx, r, mesh, s, nil, nil) -} - -// taken is a module's manifest as a reader takes it (novox/hq ADR 0578 §3): decoded leniently, with the indexer's verdict -// from its modules value. A refused one, or bytes that do not decode, stands as its stub (unusable) and why says so; a -// manifest not read past a field it set aside holds its name alone and is held the same. -func taken(name string, raw []byte, m delivery.Module) (man catalogue.Manifest, why string) { - if v := m.VerdictOf(name); v != nil { - if refused, _ := delivery.TakeManifestVerdict(v); refused { - return unusable(name, raw), v.Why() - } - } - man, err := catalogue.Decode(raw) - switch { - case err != nil: - return unusable(name, raw), fmt.Sprintf("the manifest of %s does not decode: %v", name, err) - case man.ReadNoFurther(): - return unusable(name, raw), fmt.Sprintf("the manifest of %s could not be read past a field this reader does not "+ - "know: %s", name, man.SetAside()) - } - return man, "" -} - -// 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 indexer refused, is also handed to refuse and composed as a stub (compose.Stub, kept for that node alone), -// so a node's answer names every module it cannot compose (issue 1126, issue 774); a module placed elsewhere whose -// manifest is refused is read for its offer alone (unusable). A manifest the bus does not answer for still ends the -// read: that is the bus's, not the module's. -func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module string, err error), wanted map[string]string) (compose.Input, error) { - b, err := readBase(ctx, r, mesh, s) - b.refuseOwn(refuse) - if err != nil { - return compose.Input{}, err - } - return b.finish(ctx, r, r.Build, mesh, s, b.in, refuse, wanted) -} - -// base is the part of a node's input that the fingerprints it composes its modules at leave alone (issue 1954): read once -// for its plans at the wanted builds and at the newest, whose builds alone differ (PlansHeldTwice). -type base struct { - in compose.Input - placements map[string]delivery.Placement - records map[string]delivery.NodeRecord - modules map[string]delivery.Module - // ownRefused is each module placed on the composing node whose manifest the indexer refused, with why, in the order - // read: every scope holds it. - ownRefused [][2]string -} - -// refuseOwn hands refuse each module placed on the composing node whose manifest is refused. -func (b base) refuseOwn(refuse func(module string, err error)) { - if refuse == nil { - return - } - for _, r := range b.ownRefused { - refuse(r[0], errors.New(r[1])) - } -} - -// cloned is the input with its own catalogue, nodes and kept manifests, what finishing it sets: two scopes finished over -// one base never see each other's builds. -func (b base) cloned() compose.Input { - in := b.in - in.Catalogue = maps.Clone(b.in.Catalogue) - in.Nodes = slices.Clone(b.in.Nodes) - for i := range in.Nodes { - in.Nodes[i].Kept = maps.Clone(in.Nodes[i].Kept) - } - in.KeptCoreBoxes = slices.Clone(b.in.KeptCoreBoxes) - in.OwnSecrets = slices.Clone(b.in.OwnSecrets) - return in -} - -// readBase is input up to the builds: placement, records, the indexer's modules and manifests, each node's values. -func readBase(ctx context.Context, r Reader, mesh Mesh, s Scope) (base, error) { - var b base - placements, err := r.Placements(ctx) - if err != nil { - return b, fmt.Errorf("frombus: placement: %w", err) - } - if len(s.Placements) > 0 { - given := make(map[string]delivery.Placement, len(placements)+len(s.Placements)) - for node, p := range placements { - given[node] = p - } - for node, p := range s.Placements { - if p == nil { - delete(given, node) - continue - } - if p.Node != node { - return b, fmt.Errorf("frombus: the placement given for %s names the node %q", node, p.Node) - } - given[node] = *p - } - placements = given - } - records, err := r.Records(ctx) - if err != nil { - return b, fmt.Errorf("frombus: node records: %w", err) - } - modules, err := r.Modules(ctx) - if err != nil { - return b, fmt.Errorf("frombus: the indexer's modules: %w", err) - } - if _, ok := placements[s.Node]; !ok && !s.Every { - return b, fmt.Errorf("frombus: %s has no placement in the catalogue", s.Node) - } - - in := compose.Input{OverlayCIDR: mesh.OverlayCIDR, InternalSuffix: mesh.InternalSuffix, Authority: mesh.Authority, - Catalogue: map[string]catalogue.Manifest{}} - if in.Authority == "" { - // The mesh's own certificate, public, as the composing node holds it; for the check of every node, as the first - // node holding one does. - if rec, ok := records[s.Node]; ok && rec.Authority != "" { - in.Authority = rec.Authority - } else { - for _, node := range sortedKeys(records) { - if a := records[node].Authority; a != "" { - in.Authority = a - break - } - } - } - } - // And the vault's authority beside it, once the vault publishes one (ADR 0550 §2): a certificate either signed checks - // against the bundle. The controller's stays while a certificate it issued is in a node's record. - if sr, ok := r.(SignersReader); ok { - if vault := sr.VaultAuthority(ctx); vault != "" { - in.Authority = delivery.AuthorityBundle(in.Authority, vault) - } - } - named := func(field *map[string]string, module, why string) { - if *field == nil { - *field = map[string]string{} - } - (*field)[module] = why - } - 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 - } - raw, err := r.Manifest(ctx, m.Manifest) - if err != nil { - // Not read is no refusal of the module: a bus that does not answer is waited out by the caller. - return b, fmt.Errorf("frombus: the manifest of %s (digest %s): %w", name, m.Manifest, err) - } - // One module's manifest the indexer refused refuses that module alone, by the rule a build that cannot be read - // follows (issue 774: power's bar item refused every node's composition, 2026-10-30; ADR 0578 §3: one refused - // manifest silenced disk-load for 45 hours). It stands in as its 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, why := taken(name, raw, m) - if why != "" { - named(&in.Refused, name, why) - if ownPlaced[name] { - b.ownRefused = append(b.ownRefused, [2]string{name, why}) - } - } else if aside := man.SetAside(); aside != "" { - named(&in.SetAside, name, aside) - } - in.Catalogue[name] = man - } - - // The module the mesh provides itself (the private network) has no repository the indexer reads: its manifest comes - // with mesh-sdk and always wins, as the controller's providedModules and the grants part have it. - for _, name := range delivery.ProvidedModules() { - raw, err := json.Marshal(overlay.Manifest()) - if err != nil { - return b, err - } - var private catalogue.Manifest - if err := json.Unmarshal(raw, &private); err != nil { - return b, fmt.Errorf("frombus: the manifest mesh-sdk provides for %s: %w", name, err) - } - in.Catalogue[name] = private - } - - var missing []string - for _, node := range nodesOf(placements, records) { - rec, hasRecord := records[node] - p := placements[node] - if !hasRecord { - if len(p.Modules) > 0 { - missing = append(missing, node) - } - continue - } - n := nodeOf(rec, p) - withValues := s.Every || node == s.Node - for _, m := range p.Modules { - var values map[string]any - if withValues { - if values, err = r.Values(ctx, node, m.Module); err != nil { - return b, fmt.Errorf("frombus: %s's config values on %s: %w", m.Module, node, err) - } - } else if keys := catalogue.ServedSettings(in.Catalogue[m.Module]); len(keys) > 0 { - // What a provider elsewhere serves from its settings is told to every consumer: the composing node reads - // those keys of it, and no other (issue 1126: mailu's smtp domain, a consumer's on shanks). - if values, err = r.Served(ctx, node, m.Module, keys); err != nil { - return b, fmt.Errorf("frombus: what %s serves on %s (%s): %w", m.Module, node, - strings.Join(keys, ", "), err) - } - } - if layers := m.Layers(node, values); layers != nil { - if n.Settings == nil { - n.Settings = map[string][]catalogue.Layer{} - } - n.Settings[m.Module] = layers - } - } - 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{} - } - n.InOwn[module] = true - } - for i := range s.Running { - // The operator export as the vault runs it: the controller composes it from its store, which the node - // does not read; until the export is on the bus, the node carries the file it runs (issue 1126, C6: - // mesh-vault.kept on novox, "in what runs, and not composed"). - if m := s.Running[i].Module; m != "" { - if kept := keptOf(s.Running[i]); kept != nil { - if in.KeptRuns == nil { - in.KeptRuns = map[string]map[string]map[string]any{} - } - if in.KeptRuns[s.Node] == nil { - in.KeptRuns[s.Node] = map[string]map[string]any{} - } - in.KeptRuns[s.Node][m] = kept - } - continue - } - if s.Running[i].Module == "" { - own := s.Running[i] - n.RunsOwn = &own - // Its bus membership as it runs it: the controller composes it from its store, which the node does - // not read; until the grants part hands memberships sealed to the node, the node's own composition - // carries the one it runs (issue 1126, seed #531 on novox: "bus-membership in what runs, not - // composed", which applied would have taken the node's bus credential away). - if sealed := membershipOf(own); sealed != "" { - if in.BusMemberships == nil { - in.BusMemberships = map[string]string{} - } - in.BusMemberships[s.Node] = sealed - } - } - } - } - in.Nodes = append(in.Nodes, n) - in.Holdings = append(in.Holdings, p.Held()...) - // A tunnel found at adoption is the node's private network's when the node's tunnel key is the tunnel's (the - // controller's inv.Tunnels); the hub's found tunnel is the mesh's adopted one whether or not the hub took it over - // (inv.AdoptedTunnel), and its peers are carried once it did (inv.CarriedPeers). - if t := p.Decided.Tunnel; t != nil { - if rec.TunnelKey == t.PublicKey { - if in.Tunnels == nil { - in.Tunnels = map[string]compose.FoundTunnel{} - } - in.Tunnels[node] = compose.FoundTunnel{Tunnel: tunnelOf(*t), NodeAdopted: p.Decided.Adopted} - } - if p.Decided.Hub { - in.Adopted = &compose.AdoptedTunnel{Tunnel: tunnelOf(*t), Hub: node, Adopted: rec.TunnelKey == t.PublicKey} - } - } - } - if in.Adopted != nil && in.Adopted.Adopted { - in.CarriedPeers = carriedPeers(in.Adopted.Tunnel, placements[in.Adopted.Hub].Decided.PeerNames, records) - } - if len(missing) > 0 { - return b, fmt.Errorf("frombus: modules are placed on %s, whose agent has written no record yet", - strings.Join(missing, ", ")) - } - b.in, b.placements, b.records, b.modules = in, placements, records, modules - return b, nil -} - -// finish is input from its base on: the composing node's modules from their builds (build reads one), its own secrets. -// in is the base's input, or a copy of it (base.cloned) where one base is finished more than once. -func (b base) finish(ctx context.Context, r Reader, build func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error), - mesh Mesh, s Scope, in compose.Input, refuse func(module string, err error), wanted map[string]string) (compose.Input, error) { - var err error - parse := parser(mesh) - read := func(module, digest string) (catalogue.Manifest, error) { - raw, err := r.Manifest(ctx, digest) - if err != nil { - return catalogue.Manifest{}, fmt.Errorf("frombus: the manifest of %s (digest %s): %w", module, digest, err) - } - man, err := parse(raw) - if err != nil { - return catalogue.Manifest{}, fmt.Errorf("frombus: the manifest of %s (digest %s) does not decode: %w", module, digest, err) - } - return man, nil - } - if !s.Every { - if _, ok := b.records[s.Node]; !ok { - return compose.Input{}, fmt.Errorf("frombus: %s has written no record of itself yet", s.Node) - } - if err := fromBuilds(ctx, r, build, &in, s, b.placements[s.Node], b.modules, read, parse, refuse, wanted); err != nil { - return compose.Input{}, err - } - // Its own modules' secrets, sealed to its own key by the vault: a composition carries them sealed (ADR 0550 §5). - // Asked only when a module placed on it declares one: a node none of whose modules has a secret composes - // whether or not the vault answers. - if declaresOwnSecrets(in, s.Node) { - 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. - for _, o := range in.OwnSecrets { - if json.Valid([]byte(o.Sealed)) { - in.KeptCoreBoxes = append(in.KeptCoreBoxes, compose.CoreBox{Node: o.Node, Module: o.Module, Name: o.Name, - SealedTo: o.SealedTo, Box: json.RawMessage(o.Sealed)}) - } - } - } - return in, nil -} - -// Compose is the node's compositions over the bus (ADR 0547 §2): input for s, then composed at the fingerprints fps -// gives each module in the Reading mode, as the controller composes them, by the one rule its answer to a candidate -// follows too (composeEach, issue 1126): a module refused alone (its build unread or not taken in, its composition -// refused, a stand-in for a value of its own the input does not carry, compose.ErrStoodIn) is held, named in held with -// why, and none of its own composition is in out, so the node keeps what it last applied of it and applies the rest; a -// node never applies a stand-in. Only a refusal that is no single module's (the node's own resources, a value served -// 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{} - // 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 - } - // The pair credentials its composition reads, asked of the vault sealed to it (ADR 0550 §5). - asConsumer, asProvider, err := compose.CredentialsNeeded(in, s.Node) - if err != nil { - return nil, nil, held, err - } - if len(asConsumer)+len(asProvider) > 0 { - if in.Credentials, in.Issued, err = r.Credentials(ctx, s.Node, asConsumer, asProvider); err != nil { - 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, 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) { - return heldOf(in, node, held, func(in compose.Input) (map[string]catalogue.Plan, error) { return plansIn(in, node, compose.Planning) }) -} - -// heldOf composes compose1 over in with every module in held held, and each module a refusal is of held too until it -// composes (culprit's rule: its stub first, then its bare stub); what compose1 answers of a held module is left out. -// Plans and data items are composed so (PlansHeld, DataItemsHeld): one module never ends the read (novox/hq ADR 0578 §3). -func heldOf[V any](in compose.Input, node string, held map[string]string, compose1 func(compose.Input) (map[string]V, error)) (map[string]V, error) { - for _, m := range sortedKeys(held) { - in = stubbed(in, node, m, "") - } - // 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: what %s composes does 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) { - // One composition for the three, so each node of the mesh is resolved once (issue 1954). - resolved, rendered, gens, err := compose.Composing(in, node, mode) - if err != nil { - return nil, fmt.Errorf("frombus: the plans of %s: %w", node, err) - } - 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 every reader does (novox/hq ADR 0578 §3): decoded leniently, judged by nothing here. -func parser(Mesh) func([]byte) (catalogue.Manifest, error) { - return catalogue.Decode -} - -// 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, build func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error), - in *compose.Input, s Scope, p delivery.Placement, modules map[string]delivery.Module, - read func(module, digest string) (catalogue.Manifest, error), parse func([]byte) (catalogue.Manifest, error), - refuse func(module string, err error), wanted map[string]string) error { - // stub keeps a refused module in the composition as a stub for this node alone (compose.Stub): what it provides - // and requires still shapes the rest. - stub := func(module string, err error) { - refuse(module, err) - for i := range in.Nodes { - if in.Nodes[i].Name != s.Node { - continue - } - man, ok := in.Nodes[i].Kept[module] - if !ok { - man, ok = in.Catalogue[module] - } - if !ok { - return - } - if in.Nodes[i].Kept == nil { - in.Nodes[i].Kept = map[string]catalogue.Manifest{} - } - in.Nodes[i].Kept[module] = compose.Stub(man) - } - } - address, addressErr := "", error(nil) - addressed := false - archive := func(ctx context.Context, reference, digest string) ([]byte, error) { - if !addressed { - address, addressErr = compose.ArtifactStoreAddress(*in, s.Node) - addressed = true - } - switch { - case addressErr != nil: - return nil, addressErr - case address == "": - return nil, fmt.Errorf("%s reaches no artifact store (%s)", s.Node, storeSought(*in)) - } - return r.Archive(ctx, catalogue.Routed(reference, address), digest) - } - built := func(module, fp string, b *delivery.BuilderBuild) (catalogue.Manifest, error) { - if b.Manifest == "" { - return catalogue.Manifest{}, fmt.Errorf("frombus: the build of %s at %s names no manifest", module, fp) - } - // A take-in kept by the reader is read back as below, never shared: each composition holds its own manifest. - keeper, keeps := r.(takenInKeeper) - if keeps && !addressed { - address, addressErr = compose.ArtifactStoreAddress(*in, s.Node) - addressed = true - } - // Kept by the store's address too: a node that reaches no store takes in as before, and fails where it did. - key, keyed := takenInKey(module, fp, address, *b) - keyed = keyed && addressErr == nil && address != "" - if keeps && keyed { - if raw, ok := keeper.takenIn(key); ok { - resolved, err := parse(raw) - if err != nil { - return catalogue.Manifest{}, fmt.Errorf("frombus: the built manifest of %s at %s: %w", module, fp, err) - } - return resolved, nil - } - } - src, err := read(module, b.Manifest) - if err != nil { - return catalogue.Manifest{}, err - } - resolved, err := takein.Manifest(ctx, src, *b, archive) - if err != nil { - return catalogue.Manifest{}, fmt.Errorf("frombus: %s at %s: %w", module, fp, err) - } - // Read back as the mesh holds a built manifest (the controller keeps the resolved manifest's bytes and reads them - // as any manifest): a template's text is then in the manifest's own words, its ${config:…} in the settings form. - raw, err := json.Marshal(resolved) - if err != nil { - return catalogue.Manifest{}, err - } - if resolved, err = parse(raw); err != nil { - return catalogue.Manifest{}, fmt.Errorf("frombus: the built manifest of %s at %s: %w", module, fp, err) - } - if keeps && keyed { - keeper.keepTakenIn(key, raw) - } - return resolved, nil - } - // A module wanted at another build than the trunk's is composed from the build its want names (issue 774), as one - // 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 == "" || 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 - } - if _, refused := in.Refused[pm.Module]; refused { - continue // held as its stub: no build of a refused manifest is taken in (ADR 0578 §3) - } - if man, ok := in.Catalogue[pm.Module]; !ok || (man.Build == nil && !man.Templated()) { - continue // nothing is built of it: its manifest is what it composes from - } - b, err := build(ctx, pm.Module, m.FP2) - if err != nil { - err = fmt.Errorf("frombus: the build of %s at %s: %w", pm.Module, m.FP2, err) - if refuse == nil { - return err - } - stub(pm.Module, err) - continue - } - if b == nil || b.State != delivery.BuildDone { - 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 { - if refuse == nil { - return err - } - stub(pm.Module, err) - continue - } - in.Catalogue[pm.Module] = resolved - } - for i := range in.Nodes { - if in.Nodes[i].Name != s.Node { - continue - } - for _, module := range sortedKeys(keptAt) { - fp := keptAt[module] - b, err := build(ctx, module, fp) - switch { - case err != nil: - err = fmt.Errorf("frombus: the build of %s at %s: %w", module, fp, err) - case b == nil || b.State != delivery.BuildDone: - err = fmt.Errorf("frombus: %s is wanted at %s, of which the builder holds no done build", module, fp) - } - var resolved catalogue.Manifest - if err == nil { - resolved, err = built(module, fp, b) - } - if err != nil { - if refuse == nil { - return err - } - stub(module, err) - continue - } - if in.Nodes[i].Kept == nil { - in.Nodes[i].Kept = map[string]catalogue.Manifest{} - } - in.Nodes[i].Kept[module] = resolved - } - } - return nil -} - -// declaresOwnSecrets says a module assigned to node declares an own secret, in the manifest the node composes it from. -func declaresOwnSecrets(in compose.Input, node string) bool { - for _, n := range in.Nodes { - if n.Name != node { - continue - } - for _, module := range n.Assigned { - m, ok := n.Kept[module] - if !ok { - m = in.Catalogue[module] - } - if len(m.OwnSecrets) > 0 { - return true - } - } - } - return false -} - -// nodeOf is the composition's node from its record (what the machine observes) and its placement (what a person -// decided of it, and where its modules run). -func nodeOf(rec delivery.NodeRecord, p delivery.Placement) compose.Node { - n := compose.Node{ - Name: rec.Node, - Place: compose.Place{Key: rec.TunnelKey}, - SealingKey: rec.SealingKey, - OutwardLinks: rec.OutwardLinks, Networks: rec.Networks, NetworksSaid: rec.NetworksSaid, - ServingKey: rec.ServingKey, Certificate: rec.Certificate, Contract: rec.Contract, Stated: rec.Contract > 0, - } - placeNode(&n, p) - if len(rec.Capabilities) > 0 { - n.Capabilities = make(map[string]bool, len(rec.Capabilities)) - for _, c := range rec.Capabilities { - n.Capabilities[c] = true - } - } - return n -} - -// placeNode sets what a person decided of n and where its modules run, from its placement; what the machine observes -// (its keys, links, networks, certificate) and its settings layers are left as they are. -func placeNode(n *compose.Node, p delivery.Placement) { - d := p.Decided - n.Place = compose.Place{Key: n.Place.Key, Endpoint: d.Endpoint, Site: d.Site, Hub: d.Hub, Address: d.Address} - n.Assigned, n.PublicDomain = p.Assigned(), d.PublicDomain - n.Account, n.AccountHome, n.AgentAccount, n.AgentAccountHome = d.Account, d.AccountHome, d.AgentAccount, d.AgentHome - n.Adopted, n.Taken = d.Adopted, d.Taken - n.Pins, n.Bound, n.Ports = p.ChosenPins(), p.Bound(), p.HostPorts() -} - -// Placed is in with the placements given standing in for those of the nodes they name (issue 1126): each such node's -// decided parts, modules, pins, bindings, host ports, settings placement and holdings are the given placement's, its -// config values those in already carries for it (the layer of the node's own name), and what the machine observes is -// kept. A nil placement leaves the node in the mesh with nothing placed on it. A node in does not hold is refused: it has -// no record to compose it from. The found tunnel and the hub's peers are read from in as they are, not from the given -// placement. It is how a check composes a change's own placement over an input already filled (composer.Case's -// Placements). -func Placed(in compose.Input, placements map[string]*delivery.Placement) (compose.Input, error) { - if len(placements) == 0 { - return in, nil - } - out := in - out.Nodes = append([]compose.Node(nil), in.Nodes...) - index := map[string]int{} - for i, n := range out.Nodes { - index[n.Name] = i - } - for _, node := range sortedKeys(placements) { - p := placements[node] - i, ok := index[node] - if !ok { - return compose.Input{}, fmt.Errorf("frombus: a placement is given for %s, which the input holds no node for", node) - } - if p == nil { - p = &delivery.Placement{Node: node} - } - if p.Node != node { - return compose.Input{}, fmt.Errorf("frombus: the placement given for %s names the node %q", node, p.Node) - } - n := out.Nodes[i] - placeNode(&n, *p) - settings := map[string][]catalogue.Layer{} - for _, m := range p.Modules { - var values map[string]any - for _, l := range n.Settings[m.Module] { - if l.From == node { - values = l.Values - } - } - if layers := m.Layers(node, values); layers != nil { - settings[m.Module] = layers - } - } - n.Settings = nil - if len(settings) > 0 { - n.Settings = settings - } - out.Nodes[i] = n - } - out.Holdings = nil - for _, h := range in.Holdings { - if _, given := placements[h.Node]; !given { - out.Holdings = append(out.Holdings, h) - } - } - for _, node := range sortedKeys(placements) { - if p := placements[node]; p != nil { - out.Holdings = append(out.Holdings, p.Held()...) - } - } - return out, nil -} - -func tunnelOf(t delivery.RecordTunnel) compose.Tunnel { - out := compose.Tunnel{Interface: t.Interface, Unit: t.Unit, Config: t.Config, Port: t.Port, MTU: t.MTU, - Address: t.Address, Range: t.Range, PublicKey: t.PublicKey} - for _, p := range t.Peers { - out.Peers = append(out.Peers, compose.TunnelPeer{PublicKey: p.PublicKey, Address: p.Address}) - } - return out -} - -// carriedPeers is every peer of the hub's adopted tunnel, by address, each with the node that enrolled under its key -// where one has (a node whose tunnel key is the peer's) and the name a person gave it (inv.CarriedPeers). -func carriedPeers(t compose.Tunnel, names map[string]string, records map[string]delivery.NodeRecord) []compose.CarriedPeer { - byKey := map[string]string{} - for node, rec := range records { - if rec.TunnelKey != "" { - byKey[rec.TunnelKey] = node - } - } - var out []compose.CarriedPeer - for _, p := range t.Peers { - address, _, _ := strings.Cut(p.Address, "/") - out = append(out, compose.CarriedPeer{PublicKey: p.PublicKey, Address: address, EnrolledAs: byKey[p.PublicKey], Named: names[p.PublicKey]}) - } - sort.SliceStable(out, func(i, j int) bool { - a, errA := netip.ParseAddr(out[i].Address) - b, errB := netip.ParseAddr(out[j].Address) - if errA != nil || errB != nil { - return out[i].Address < out[j].Address - } - return a.Less(b) - }) - return out -} - -// nodesOf is every node named by a placement or a record, by name: the order the controller reads them in. -func nodesOf(placements map[string]delivery.Placement, records map[string]delivery.NodeRecord) []string { - seen := map[string]bool{} - for n := range placements { - seen[n] = true - } - for n := range records { - seen[n] = true - } - return sortedKeys(seen) -} - -func sortedKeys[V any](m map[string]V) []string { - out := make([]string, 0, len(m)) - for k := range m { - out = append(out, k) - } - sort.Strings(out) - return out -} - -// storeSought says, for a node that reaches no artifact store, what the composition holds of one: the nodes it holds and -// where each module serving the artifact store is placed, so the refusal names why (issue 1126). -func storeSought(in compose.Input) string { - var nodes, where []string - for _, n := range in.Nodes { - nodes = append(nodes, n.Name) - for _, a := range n.Assigned { - if m, ok := in.Catalogue[a]; ok { - if _, serves := m.Serves[catalogue.ArtifactStoreProvision]; serves { - where = append(where, a+" on "+n.Name) - } - } - } - } - sort.Strings(nodes) - sort.Strings(where) - said := "the composition holds " + strings.Join(nodes, ", ") - if len(where) == 0 { - return said + "; no module placed there serves " + catalogue.ArtifactStoreProvision - } - return said + "; " + catalogue.ArtifactStoreProvision + " is served by " + strings.Join(where, ", ") + - ", none of it on the private network with a port" -} - -// keptOf is the operator export's file (catalogue.KeptID) of a module's composition as it runs, or nil. -func keptOf(c delivery.Composed) map[string]any { - for _, raw := range c.Resources { - var r map[string]any - if json.Unmarshal(raw, &r) == nil && r["id"] == c.Module+"."+catalogue.KeptID() { - return r - } - } - return nil -} - -// membershipOf is the sealed value of the bus membership resource (catalogue.BusMembershipID) of a node's own -// composition, or "". -func membershipOf(own delivery.Composed) string { - for _, raw := range own.Resources { - var r struct { - ID string `json:"id"` - Sealed string `json:"sealed"` - } - if json.Unmarshal(raw, &r) == nil && r.ID == catalogue.BusMembershipID() { - return r.Sealed - } - } - 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. A module whose manifest the indexer refused is held -// and its items left out; DataItemsHeld names it, and a reader that must say what it leaves out reads that. -func DataItems(ctx context.Context, r Reader, mesh Mesh, s Scope) (map[string][]catalogue.PlacedDataItem, []string, error) { - items, unplaced, _, err := DataItemsHeld(ctx, r, mesh, s) - return items, unplaced, err -} - -// DataItemsHeld is DataItems and, by module, why each module placed on scope's node is held: its manifest refused by -// the indexer's judge or not decoded (compose.Input.Refused). One refused manifest never ends the read (novox/hq ADR -// 0578 §3: disk-load measured nothing for 45 hours when one did). -func DataItemsHeld(ctx context.Context, r Reader, mesh Mesh, s Scope) (items map[string][]catalogue.PlacedDataItem, unplaced []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() }, nil) - if err != nil { - return nil, nil, held, err - } - // Held module by module, as PlansHeld holds them: a refused stub or one module's composition refusal (an item's - // template) holds that module and never ends the read. - items, err = heldOf(in, s.Node, held, func(in compose.Input) (map[string][]catalogue.PlacedDataItem, error) { - var err error - items, unplaced, err = compose.DataItems(withoutHeldData(in, s.Node), s.Node) - return items, err - }) - return items, unplaced, held, err -} - -// withoutHeldData is in with no data item of a module held on node: none of a held module's items is measured, so its -// data never refuses the rest (its stub keeps its data for the backups the plans compose). -func withoutHeldData(in compose.Input, node string) compose.Input { - out := in - out.Nodes = append([]compose.Node(nil), in.Nodes...) - for i := range out.Nodes { - n := &out.Nodes[i] - if n.Name != node || len(n.Held) == 0 { - continue - } - kept := maps.Clone(n.Kept) - for m := range n.Held { - if man, ok := kept[m]; ok { - man.Data = nil - kept[m] = man - } - } - n.Kept = kept - } - return out -} diff --git a/go/compose/frombus/frombus_test.go b/go/compose/frombus/frombus_test.go deleted file mode 100644 index 9ea9fdc..0000000 --- a/go/compose/frombus/frombus_test.go +++ /dev/null @@ -1,869 +0,0 @@ -package frombus - -import ( - "archive/tar" - "bytes" - "compress/gzip" - "context" - "crypto/ecdh" - "crypto/rand" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "os" - "reflect" - "slices" - "sort" - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/compose" - "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/overlay" -) - -// 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 - manifests map[string][]byte - values map[string]map[string]any // by node.module - valuesRead []string - own []compose.OwnSecretRow - ownErr error - // asked is every consumer credential asked; the fake refuses the whole ask, as the vault's own does, for a consuming - // module its placement does not put on the node. - asked []compose.CredentialKey - // builds are the builder's keys, by " "; archives the artifact store's blobs, by digest, and fetched - // every URL asked; served another node's values by ".", and servedRead every ask with its keys. - builds map[string]*delivery.BuilderBuild - archives map[string][]byte - fetched []string - 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) { - b.buildsRead = append(b.buildsRead, module) - return b.builds[module+" "+fp], nil -} - -func (b *bus) Archive(_ context.Context, url, digest string) ([]byte, error) { - b.fetched = append(b.fetched, url) - 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) { - b.servedRead = append(b.servedRead, node+"."+module+":"+strings.Join(keys, ",")) - out := map[string]any{} - for _, k := range keys { - if v, ok := b.served[node+"."+module][k]; ok { - out[k] = v - } - } - return out, nil -} - -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) { - raw, ok := b.manifests[digest] - if !ok { - return nil, errors.New("no such manifest") - } - return raw, nil -} -func (b *bus) OwnSecrets(context.Context, string) ([]compose.OwnSecretRow, error) { - return b.own, b.ownErr -} - -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 - for _, m := range b.placements[node].Modules { - placed = placed || m.Module == k.ConsumerModule - } - if !placed { - return nil, nil, fmt.Errorf("%s is not on %s: neither assigned there nor required by a module there", k.ConsumerModule, node) - } - } - 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) { - b.valuesRead = append(b.valuesRead, node+"."+module) - return b.values[node+"."+module], nil -} - -func publicKey(t *testing.T) string { - t.Helper() - k, err := ecdh.X25519().GenerateKey(rand.Reader) - if err != nil { - t.Fatal(err) - } - return base64.StdEncoding.EncodeToString(k.PublicKey().Bytes()) -} - -// Issue 1126 (S3, S4, S5): the bus's placement, node records, manifests and config values fill the same compose.Input the -// controller's store fills for compose's own two-machine mesh (compose's aMesh: anchor the hub, laptop), and that Input -// composes; a node reads only its own config values; what cannot be composed is refused, naming it. -func TestInput(t *testing.T) { - // Issue 1126, the seed's shadow: every refusal named, an old-form module left to the node, and no store reached - // 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("a refused manifest never ends the read", aRefusedManifestNeverEndsTheRead) - 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") - if err != nil { - t.Fatal(err) - } - digest := delivery.ManifestDigest(xterm) - // A module in the settings form (plex and the media modules on one node, 2026-10-26): its config values are in its - // layers, not mesh-config, so the placement carries them (MeshValues, Values) and mesh-config's are not read for it. - media := []byte(`{"module":"media","version":"1","settings":{"lines":{"kind":"preference","default":0,"why":"kept"},` + - `"name":{"kind":"preference","default":"x","why":"shown"}},"own-secrets":{"token":"/srv/media/token"},"resources":[{"id":"conf","type":"file",` + - `"path":"/srv/media/conf","mode":"0644","content":"uid=${setting:lines} name=${setting:name}\n"}]}`) - mediaDigest := delivery.ManifestDigest(media) - keys := map[string]string{"anchor": publicKey(t), "laptop": publicKey(t)} - caps := []string{"container-runtime", "systemd", "wireguard"} - tunnel := &delivery.RecordTunnel{Interface: "wg0", PublicKey: keys["anchor"], Address: "10.77.0.1/24", - Peers: []delivery.RecordTunnelPeer{{PublicKey: "peer", Address: "10.77.0.9/32"}, {PublicKey: keys["laptop"], Address: "10.77.0.2/32"}}} - mk := func() *bus { - return &bus{ - placements: map[string]delivery.Placement{ - "anchor": {Node: "anchor", Decided: delivery.NodeDecided{Endpoint: "anchor.example:51820", Site: "here", Hub: true, Address: "10.77.0.1", - Tunnel: tunnel, PeerNames: map[string]string{"peer": "phone"}}, - Modules: []delivery.PlacedModule{{Module: overlay.Name}}, - Holds: []delivery.Holding{{Claim: "mesh-private-network", Scope: "mesh", Module: overlay.Name}}}, - "laptop": {Node: "laptop", Decided: delivery.NodeDecided{Endpoint: "laptop.example:51820", Site: "here", Address: "10.77.0.2"}, - Modules: []delivery.PlacedModule{{Module: overlay.Name}, - {Module: "xterm", HasLayer: true, Ports: map[string]int{"80": 8080}}, - {Module: "media", HasMesh: true, MeshValues: map[string]any{"name": "mesh"}, HasLayer: true, - Layer: map[string]any{"places": map[string]any{"data": "/srv/data"}}, Values: map[string]any{"lines": float64(1000)}}}}, - }, - records: map[string]delivery.NodeRecord{ - "anchor": {Node: "anchor", TunnelKey: keys["anchor"], Capabilities: caps, Contract: 2}, - "laptop": {Node: "laptop", TunnelKey: keys["laptop"], Capabilities: caps, Authority: "the mesh's certificate"}, - }, - modules: map[string]delivery.Module{"xterm": {Manifest: digest}, "media": {Manifest: mediaDigest}, "unread": {}}, - manifests: map[string][]byte{digest: xterm, mediaDigest: media}, - values: map[string]map[string]any{"laptop.xterm": {"font": "mono"}, "anchor.xterm": {"font": "serif"}, - "laptop.media": {"lines": "not mesh-config's"}}, - own: []compose.OwnSecretRow{{Node: "laptop", Module: "media", Name: "token", Origin: "made", SealedTo: "sk", Sealed: "{}"}}, - } - } - - // What the controller's store gives for the same mesh, as compose's aMesh and SettingsFor build it. - var private catalogue.Manifest - raw, _ := json.Marshal(overlay.Manifest()) - if err := json.Unmarshal(raw, &private); err != nil { - t.Fatal(err) - } - parsed, err := catalogue.Decode(xterm) - if err != nil { - t.Fatal(err) - } - parsedMedia, err := catalogue.Decode(media) - if err != nil { - t.Fatal(err) - } - capsSet := map[string]bool{"container-runtime": true, "systemd": true, "wireguard": true} - tun := compose.Tunnel{Interface: "wg0", PublicKey: keys["anchor"], Address: "10.77.0.1/24", - Peers: []compose.TunnelPeer{{PublicKey: "peer", Address: "10.77.0.9/32"}, {PublicKey: keys["laptop"], Address: "10.77.0.2/32"}}} - want := compose.Input{ - Catalogue: map[string]catalogue.Manifest{overlay.Name: private, "xterm": parsed, "media": parsedMedia}, - Nodes: []compose.Node{ - {Name: "anchor", Place: compose.Place{Key: keys["anchor"], Endpoint: "anchor.example:51820", Site: "here", Hub: true, - Address: "10.77.0.1"}, Assigned: []string{overlay.Name}, Capabilities: capsSet, Contract: 2, Stated: true}, - {Name: "laptop", Place: compose.Place{Key: keys["laptop"], Endpoint: "laptop.example:51820", Site: "here", - Address: "10.77.0.2"}, Assigned: []string{overlay.Name, "xterm", "media"}, Capabilities: capsSet, - Ports: map[string]map[int]int{"xterm": {80: 8080}}, - Settings: map[string][]catalogue.Layer{"xterm": {{From: "laptop", Values: map[string]any{"font": "mono"}}}, - "media": {{From: catalogue.MeshWideLayer, Values: map[string]any{"name": "mesh"}}, - {From: "laptop", Values: map[string]any{"lines": float64(1000)}, Placement: map[string]any{"places": map[string]any{"data": "/srv/data"}}}}}}, - }, - Holdings: []catalogue.Held{{Claim: "mesh-private-network", Scope: "mesh", Node: "anchor", Module: overlay.Name}}, - OwnSecrets: []compose.OwnSecretRow{{Node: "laptop", Module: "media", Name: "token", Origin: "made", SealedTo: "sk", Sealed: "{}"}}, - // The same value as a core credential's box, for a core module (ADR 0349 §16). - KeptCoreBoxes: []compose.CoreBox{{Node: "laptop", Module: "media", Name: "token", SealedTo: "sk", Box: json.RawMessage("{}")}}, - Tunnels: map[string]compose.FoundTunnel{"anchor": {Tunnel: tun}}, - Adopted: &compose.AdoptedTunnel{Tunnel: tun, Hub: "anchor", Adopted: true}, - // The controller's order is by address; laptop enrolled under its peer's key, and a person named the other. - Authority: "the mesh's certificate", - CarriedPeers: []compose.CarriedPeer{{PublicKey: keys["laptop"], Address: "10.77.0.2", EnrolledAs: "laptop"}, - {PublicKey: "peer", Address: "10.77.0.9", Named: "phone"}}, - } - - b := mk() - got, err := Input(ctx, b, Mesh{}, Scope{Node: "laptop"}) - if err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(got, want) { - gj, _ := json.MarshalIndent(got, "", " ") - wj, _ := json.MarshalIndent(want, "", " ") - t.Fatalf("Input from the bus differs from the store's:\n got %s\nwant %s", gj, wj) - } - // L3 s8 (ADR 0550 §2): with the vault's authority published, the mesh's authority is the bundle of the controller's - // and the vault's; with none, the controller's alone, as above. - signing := mk() - signing.vaultAuthority = "the vault's certificate" - if bundled, err := Input(ctx, signing, Mesh{}, Scope{Node: "laptop"}); err != nil || - 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) - } - - // The same composer over both: the compositions from the bus are the store's, byte for byte. - fps := map[string]string{overlay.Name: "src2:" + strings.Repeat("a", 64), "xterm": "src2:" + strings.Repeat("b", 64), - "media": "src2:" + strings.Repeat("c", 64)} - fromBus, _, held, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop"}, fps) - if err == nil && len(held) > 0 { - err = fmt.Errorf("modules held: %v", held) - } - if err != nil { - t.Fatal(err) - } - fromStore, _, err := compose.Compositions(want, "laptop", fps, compose.Reading) - if err != nil { - t.Fatal(err) - } - if len(fromBus) == 0 || !reflect.DeepEqual(fromBus, fromStore) { - t.Fatalf("compositions differ or are empty: %d from the bus, %d from the store", len(fromBus), len(fromStore)) - } - - // Issue 1126: a candidate placement equal to the node's composes what it runs, and CompareComposed says so; one - // thing changed in the group's candidate of another node (the hub's endpoint) names exactly the module it moves - // here. Placed puts the same placement over the store's input unchanged. - running := make([]delivery.Composed, 0, len(fromBus)) - for _, c := range fromBus { - running = append(running, c) - } - laptop := mk().placements["laptop"] - asIs, _, held, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop", Placements: map[string]*delivery.Placement{"laptop": &laptop}}, fps) - if err == nil && len(held) > 0 { - err = fmt.Errorf("modules held: %v", held) - } - if err != nil { - t.Fatal(err) - } - if c := delivery.CompareComposed(asIs, running); len(c.Differs)+len(c.Added)+len(c.Removed) != 0 || len(c.Same) != len(running) { - t.Errorf("the candidate equal to the placement: %+v", c) - } - moved := mk().placements["anchor"] - moved.Decided.Endpoint = "anchor.example:51821" - changed, _, held, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop", Placements: map[string]*delivery.Placement{"laptop": &laptop, "anchor": &moved}}, fps) - if err == nil && len(held) > 0 { - err = fmt.Errorf("modules held: %v", held) - } - if err != nil { - t.Fatal(err) - } - if c := delivery.CompareComposed(changed, running); len(c.Differs) != 1 || c.Differs[0].Module != overlay.Name || c.Differs[0].Resource != overlay.Name+".overlay-config" { - t.Errorf("the hub's endpoint changed: %+v, want %s alone, naming its config", c, overlay.Name) - } - if c := delivery.CompareComposed(changed, running[:0]); len(c.Added) != len(changed) { - t.Errorf("nothing runs: %+v", c) - } - - // ADR 0559 §4, the node's answer: the same; one module differing; a module the group adds, whose own secret is not - // yet issued (no refusal); a value missing for a module the node runs (a refusal); a module the candidate removes. - keeper := []byte(`{"module":"keeper","version":"1","own-secrets":{"password":"/var/lib/mesh/keeper/password"},` + - `"resources":[{"id":"state","type":"directory","path":"/var/lib/mesh/keeper","mode":"0700"}]}`) - lemurs := []byte(`{"module":"lemurs","version":"1","capabilities":["systemd"],"resources":[{"id":"service","type":"service","unit":"lemurs.service","state":"running","boot":"enabled"}]}`) - // A provider and a consumer of a pair credential on the one node (compose's objects and files). - objects, _ := json.Marshal(catalogue.Manifest{Module: "objects", Version: "1", - Provides: []catalogue.Offer{{Name: "s3-bucket", Scope: catalogue.ScopeMesh, Identity: &catalogue.OfferIdentity{Max: 20, In: "an S3 access key"}}}, - Receives: map[string]string{"s3-bucket": "/var/lib/mesh/objects/mesh.json"}}) - files, _ := json.Marshal(catalogue.Manifest{Module: "files", Version: "1", Requires: []string{"s3-bucket"}, - Resources: []map[string]any{{"id": "config", "type": "file", "path": "/etc/files/config.json", "mode": "0600", "content": "{}", "merge": "json"}}}) - one := func(placed ...string) *bus { - p := delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}} - for _, m := range placed { - p.Modules = append(p.Modules, delivery.PlacedModule{Module: m}) - } - return &bus{placements: map[string]delivery.Placement{"n1": p}, - records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: keys["anchor"], SealingKey: "sk", Capabilities: []string{"systemd"}}}, - modules: map[string]delivery.Module{"keeper": {Manifest: delivery.ManifestDigest(keeper)}, "lemurs": {Manifest: delivery.ManifestDigest(lemurs)}, - "objects": {Manifest: delivery.ManifestDigest(objects)}, "files": {Manifest: delivery.ManifestDigest(files)}}, - manifests: map[string][]byte{delivery.ManifestDigest(keeper): keeper, delivery.ManifestDigest(lemurs): lemurs, - delivery.ManifestDigest(objects): objects, delivery.ManifestDigest(files): files}, - own: []compose.OwnSecretRow{{Node: "n1", Module: "keeper", Name: "password", Origin: "made", SealedTo: "sk", Sealed: `"box"`}}} - } - oneFps := map[string]string{"keeper": "src2:" + strings.Repeat("d", 64), "lemurs": "src2:" + strings.Repeat("e", 64), - "objects": "src2:" + strings.Repeat("f", 64), "files": "src2:" + strings.Repeat("0", 64)} - runs, _, held, err := Compose(ctx, one("keeper", "lemurs"), Mesh{}, Scope{Node: "n1"}, oneFps) - if err == nil && len(held) > 0 { - err = fmt.Errorf("modules held: %v", held) - } - if err != nil { - t.Fatal(err) - } - var runsNow []delivery.Composed - for _, c := range runs { - runsNow = append(runsNow, c) - } - objectsRun, _, held, err := Compose(ctx, one("objects"), Mesh{}, Scope{Node: "n1"}, oneFps) - if err == nil && len(held) > 0 { - err = fmt.Errorf("modules held: %v", held) - } - if err != nil { - t.Fatal(err) - } - var consumerRuns []delivery.Composed - for _, c := range objectsRun { - consumerRuns = append(consumerRuns, c) - } - withoutOwn := func(cs []delivery.Composed) []delivery.Composed { - var out []delivery.Composed - for _, c := range cs { - if c.Module != "" { - out = append(out, c) - } - } - return out - } - // What the controller runs on n1 for a module no want names (lemurs, at a build of the old form) and the private - // network's (built by nobody): its composed writer (mesh-controller compositionsOf) folds their resources into the - // node's own, each in its place, its module taken off; the rest split by module. - tunnelled := func() *bus { - b := one("keeper", "lemurs", overlay.Name) - p := b.placements["n1"] - p.Decided.Endpoint = "n1.example:51820" - b.placements["n1"] = p - return b - } - tunnelledAs := func() map[string]*delivery.Placement { - p := tunnelled().placements["n1"] - return map[string]*delivery.Placement{"n1": &p} - } - folded := func() []delivery.Composed { - b := tunnelled() - in, err := Input(ctx, b, Mesh{}, Scope{Node: "n1"}) - if err != nil { - t.Fatal(err) - } - reading := in - reading.Nodes = append([]compose.Node(nil), in.Nodes...) - reading.Nodes[0].ReadsModules = true - d, _, err := compose.ComposeIn(reading, "n1", compose.Reading) - if err != nil { - t.Fatal(err) - } - body, err := d.Body() - if err != nil { - t.Fatal(err) - } - var envelope struct { - Resources []map[string]json.RawMessage `json:"resources"` - } - if err := json.Unmarshal(body, &envelope); err != nil { - t.Fatal(err) - } - var resources []json.RawMessage - for _, r := range envelope.Resources { - var module string - _ = json.Unmarshal(r["module"], &module) - if module == "lemurs" || module == overlay.Name { - delete(r, "module") - } - raw, _ := json.Marshal(r) - resources = append(resources, raw) - } - split, err := delivery.SplitComposed("n1", resources, map[string]string{"keeper": oneFps["keeper"]}, nil, delivery.NodeOwn{}) - if err != nil { - t.Fatal(err) - } - var out []delivery.Composed - for _, c := range split { - out = append(out, c) - } - return out - }() - if got := InOwn(folded, tunnelled().placements["n1"]); !reflect.DeepEqual(got, []string{"lemurs", overlay.Name}) { - t.Errorf("InOwn of the controller's composition = %v, want lemurs and %s", got, overlay.Name) - } - placedAs := func(modules ...string) map[string]*delivery.Placement { - p := one(modules...).placements["n1"] - return map[string]*delivery.Placement{"n1": &p} - } - for name, c := range map[string]struct { - b *bus - given map[string]*delivery.Placement - running []delivery.Composed - verdict string - check func(delivery.CandidateAnswer) bool - }{ - "the same": {one("keeper", "lemurs"), placedAs("keeper", "lemurs"), runsNow, delivery.CandidateSame, - func(a delivery.CandidateAnswer) bool { - return len(a.Same) == 3 && a.Proposal == "p-h1" && a.Revision == 7 - }}, - "the node runs no own composition": {one("keeper", "lemurs"), placedAs("keeper", "lemurs"), withoutOwn(runsNow), delivery.CandidateSame, - func(a delivery.CandidateAnswer) bool { return len(a.Same) == 2 && len(a.Added) == 0 }}, - "one removed": {one("keeper", "lemurs"), placedAs("keeper"), runsNow, delivery.CandidateDiffers, - func(a delivery.CandidateAnswer) bool { return reflect.DeepEqual(a.Removed, []string{"lemurs"}) }}, - "one added, its secret not yet issued": {func() *bus { b := one("lemurs"); b.own = nil; return b }(), placedAs("keeper", "lemurs"), - nil, delivery.CandidateDiffers, func(a delivery.CandidateAnswer) bool { - for _, m := range a.Added { - if m.Module == "keeper" { - return reflect.DeepEqual(m.Unissued, []string{"keeper/password"}) && len(a.Refusals) == 0 - } - } - return false - }}, - "a value missing for a module that runs": {func() *bus { b := one("keeper", "lemurs"); b.own = nil; return b }(), placedAs("keeper", "lemurs"), - runsNow, delivery.CandidateRefused, func(a delivery.CandidateAnswer) bool { - return len(a.Refusals) == 1 && a.Refusals[0].Module == "keeper" && strings.Contains(a.Refusals[0].Why, "keeper/password") - }}, - // Review of L2b, round 1, finding 1: a consumer the candidate adds has no credential yet, and the vault refuses an - // ask for a module not on the node; it is not asked, and the module is added, its credential not yet issued. - "a consumer added, the vault not asked for it": {one("objects"), placedAs("objects", "files"), consumerRuns, delivery.CandidateDiffers, - func(a delivery.CandidateAnswer) bool { - return len(a.Added) == 1 && a.Added[0].Module == "files" && len(a.Added[0].Unissued) > 0 && len(a.Refusals) == 0 - }}, - // And with no trunk at all (the seed), a stand-in of a module the node runs is a refusal, not an addition. - "the seed, a value missing for a module that runs": {func() *bus { b := one(); b.own = nil; return b }(), placedAs("keeper", "lemurs"), - runsNow, delivery.CandidateRefused, func(a delivery.CandidateAnswer) bool { - return len(a.Refusals) == 1 && a.Refusals[0].Module == "keeper" && len(a.Added) == 0 - }}, - "cannot compose": {one("keeper"), map[string]*delivery.Placement{"n1": {Node: "n2"}}, runsNow, delivery.CandidateRefused, - func(a delivery.CandidateAnswer) bool { return a.Refused != "" }}, - // Issue 1126 (the seed's review): the private network's module and one at a build of the old form, which no want - // names and the controller folds into the node's own, are folded there again: the node's own is the same, and - // neither is added. - "mesh-wireguard and an old-form module, folded": {tunnelled(), tunnelledAs(), - folded, delivery.CandidateSame, func(a delivery.CandidateAnswer) bool { return len(a.Same) == 2 && len(a.Added) == 0 }}, - } { - fps := oneFps - if name == "mesh-wireguard and an old-form module, folded" { - fps = map[string]string{"keeper": oneFps["keeper"]} // the node's wants: no build of lemurs or the private network - } - a := Answer(ctx, c.b, Mesh{}, Scope{Node: "n1", Placements: c.given, InOwn: InOwn(c.running, *c.given["n1"])}, fps, c.running, "p-h1", 7) - if a.Verdict != c.verdict || !c.check(a) { - t.Errorf("%s: %+v", name, a) - } - if raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Candidates: map[string]delivery.CandidateAnswer{"g": a}}); err != nil { - t.Errorf("%s: the answer is not one a have holds: %v (%s)", name, err, raw) - } - } - if got, err := Placed(want, map[string]*delivery.Placement{"laptop": &laptop}); err != nil || !reflect.DeepEqual(got, want) { - t.Errorf("Placed with the node's own placement changes the input: %v", err) - } - if got, err := Placed(want, map[string]*delivery.Placement{"laptop": nil}); err != nil || len(got.Nodes[1].Assigned) != 0 || got.Nodes[1].Place.Key != keys["laptop"] { - t.Errorf("Placed with no placement: %+v, %v", got.Nodes[1], err) - } - if _, err := Placed(want, map[string]*delivery.Placement{"stranger": {Node: "stranger"}}); err == nil { - t.Error("Placed a node the input holds no node for") - } - if _, err := Input(ctx, mk(), Mesh{}, Scope{Node: "laptop", Placements: map[string]*delivery.Placement{"laptop": {Node: "anchor"}}}); err == nil { - t.Error("a given placement naming another node was taken") - } - - // ADR 0555 §6: Export is Input's inverse for what a person decides: the store's input exported, node by node, is the - // placement the bus holds, and read back it is the store's input again. - for node, placed := range mk().placements { - got, err := Export(want, node) - if err != nil { - t.Fatal(err) - } - if gj, _ := delivery.EncodePlacement(got); !delivery.SamePlacement(got, placed) { - pj, _ := delivery.EncodePlacement(placed) - t.Errorf("%s exported as %s, want %s", node, gj, pj) - } - } - if _, err := Export(want, "stranger"); err == nil { - t.Error("a node the input lacks was exported") - } - - b = mk() - if every, err := Input(ctx, b, Mesh{}, Scope{Every: true}); err != nil || every.OwnSecrets != nil { - t.Fatalf("composing every node: %v, own secrets %v (the vault answers a node about itself alone)", err, every.OwnSecrets) - } - if !reflect.DeepEqual(b.valuesRead, []string{"anchor." + overlay.Name, "laptop." + overlay.Name, "laptop.xterm", "laptop.media"}) { - // Every node's placed modules, and nothing placed nowhere: anchor places no xterm. - t.Errorf("the check read %v", b.valuesRead) - } - - // A node none of whose modules declares an own secret composes without asking the vault (the review of L2: a node - // with no secret composed nothing while the vault did not answer); one whose module does, refuses without it. - quiet := mk() - quiet.ownErr = errors.New("no responders") - if _, err := Input(ctx, quiet, Mesh{}, Scope{Node: "anchor"}); err != nil { - t.Errorf("anchor, with no own secret, needed the vault: %v", err) - } - if _, err := Input(ctx, quiet, Mesh{}, Scope{Node: "laptop"}); err == nil { - t.Error("laptop's media, which declares an own secret, composed without the vault") - } - - // A module the node's want keeps at another build than the trunk's composes from that build's manifest, resolved with - // what it made (issue 1126), on the composing node alone. - kept := mk() - oldXterm := []byte(`{"module":"xterm","version":"1","resources":[{"id":"tools","type":"process","name":"xterm-tools",` + - `"artifact":"tools","run":["./xterm-tools"]}],"build":{"artifacts":[{"name":"tools","kind":"bundle","language":"go","system":"arch","from":"cmd/xterm-tools","binary":"xterm-tools"}]}}`) - kept.manifests["old"] = oldXterm - oldFP := "src2:" + strings.Repeat("9", 64) - toolsRef := "artifact-store://xterm/tools/blobs/sha256:" + strings.Repeat("1", 64) - kept.builds = map[string]*delivery.BuilderBuild{"xterm " + oldFP: {State: delivery.BuildDone, Manifest: "old", - Artifacts: []delivery.BuiltArtifact{{Name: "tools", Kind: "bundle", Reference: toolsRef, Digest: "sha256:" + strings.Repeat("1", 64)}}}} - parsedOld, err := catalogue.Decode(oldXterm) - if err != nil { - t.Fatal(err) - } - keptBuilt, err := parsedOld.Resolve([]catalogue.Built{{Name: "tools", Kind: "bundle", Reference: toolsRef, Digest: "sha256:" + strings.Repeat("1", 64)}}) - if err != nil { - t.Fatal(err) - } - in, err := Input(ctx, kept, Mesh{}, Scope{Node: "laptop", KeptAt: map[string]string{"xterm": oldFP}}) - if err != nil || !reflect.DeepEqual(in.Nodes[1].Kept, map[string]catalogue.Manifest{"xterm": keptBuilt}) || in.Nodes[0].Kept != nil { - t.Errorf("the want's build is kept on the composing node alone: %v, %v", in.Nodes, err) - } - - // Issue 1126, the seed's refusals. restic and the provisioner's client as the catalogue holds them (mesh-catalog - // c05aa5e6), restic moved with an own secret the vault leaves out (it hands it to the client, mesh-vault ownnode.go - // handed): composed from the indexer's manifest restic is not handed and stands in for that secret, as novox's, - // ace's, g14's and shanks's answers refused; composed from its build, it is handed, its templates archive read - // where the node reaches the artifact store, and the composition is the one the controller's take-in gives. - resticRaw, err := os.ReadFile("testdata/catalogue-c05aa5e6-restic.module.json") - if err != nil { - t.Fatal(err) - } - clientRaw, err := os.ReadFile("testdata/catalogue-c05aa5e6-mesh-provisioner-client.module.json") - if err != nil { - t.Fatal(err) - } - store := []byte(`{"module":"store","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],` + - `"serves":{"artifact-store":{"port":5000}}}`) - texts := map[string]string{} - var packed bytes.Buffer - zw := gzip.NewWriter(&packed) - tw := tar.NewWriter(zw) - for _, f := range []string{"backups.conf", "data.conf", "retention.conf"} { - text, err := os.ReadFile("testdata/catalogue-c05aa5e6-restic.files/" + f) - if err != nil { - t.Fatal(err) - } - texts[f] = string(text) - if err := tw.WriteHeader(&tar.Header{Name: "./" + f, Mode: 0o644, Size: int64(len(text)), Typeflag: tar.TypeReg}); err != nil { - t.Fatal(err) - } - if _, err := tw.Write(text); err != nil { - t.Fatal(err) - } - } - if err := tw.Close(); err != nil { - t.Fatal(err) - } - if err := zw.Close(); err != nil { - t.Fatal(err) - } - archiveDigest := delivery.ManifestDigest(packed.Bytes()) - digestOf := func(c string) string { return "sha256:" + strings.Repeat(c, 64) } - movedFps := map[string]string{"restic": "src2:" + strings.Repeat("3", 64), "mesh-provisioner-client": "src2:" + strings.Repeat("4", 64), - "store": "src2:" + strings.Repeat("5", 64)} - resticArtifacts := []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/" + archiveDigest, Digest: archiveDigest}} - clientArtifacts := []delivery.BuiltArtifact{{Name: "client", Kind: "bundle", Reference: "artifact-store://mesh-provisioner-client/client/blobs/" + digestOf("7"), Digest: digestOf("7")}} - movedMesh := func(built bool) *bus { - b := one() - p := b.placements["n1"] - p.Modules = []delivery.PlacedModule{{Module: "store"}, {Module: "mesh-provisioner-client"}, {Module: "restic"}} - p.Decided.Account, p.Decided.AccountHome = "operator", "/home/operator" - b.placements["n1"] = p - rec := b.records["n1"] - rec.Contract, rec.Capabilities = compose.HandedContract, []string{"systemd", "package-manager"} - b.records["n1"] = rec - b.modules = map[string]delivery.Module{} - b.manifests = map[string][]byte{} - for module, raw := range map[string][]byte{"restic": resticRaw, "mesh-provisioner-client": clientRaw, "store": store} { - b.modules[module] = delivery.Module{FP2: movedFps[module], Manifest: delivery.ManifestDigest(raw)} - b.manifests[delivery.ManifestDigest(raw)] = raw - } - // The vault's own answer: the client's own secret; restic's is the client's to ask, so own leaves it out. - b.own = []compose.OwnSecretRow{{Node: "n1", Module: "mesh-provisioner-client", Name: "provisioner-broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}} - if built { - b.builds = map[string]*delivery.BuilderBuild{ - "restic " + movedFps["restic"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(resticRaw), Artifacts: resticArtifacts}, - "mesh-provisioner-client " + movedFps["mesh-provisioner-client"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(clientRaw), Artifacts: clientArtifacts}, - "store " + movedFps["store"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(store)}, - } - b.archives = map[string][]byte{archiveDigest: packed.Bytes()} - } - return b - } - if out, _, held, err := Compose(ctx, movedMesh(false), Mesh{}, Scope{Node: "n1"}, movedFps); err != nil || !strings.Contains(held["restic"], `restic needs a secret called "repository"`) { - t.Errorf("restic composed from the indexer's manifest: %v %v, want its own secret refusing restic alone", err, held) - } else { - for key, c := range out { - if c.Module == "restic" { - // Its stub's composition, applied, would remove what restic runs: a held module keeps its last one. - t.Errorf("held restic is composed as %s, want none of its own composition handed", key) - } - } - } - builtBus := movedMesh(true) - fromBuild, _, held, err := Compose(ctx, builtBus, Mesh{}, Scope{Node: "n1"}, movedFps) - if err == nil && len(held) > 0 { - err = fmt.Errorf("modules held: %v", held) - } - if err != nil { - t.Fatalf("restic composed from its build: %v", err) - } - if !reflect.DeepEqual(builtBus.fetched, []string{"http://127.0.0.1:5000/v2/restic/files/blobs/" + archiveDigest}) { - t.Errorf("the templates archive was read at %v", builtBus.fetched) - } - resticKey, _ := delivery.ComposedKey("n1", "restic", movedFps["restic"]) - if c := fromBuild[resticKey]; len(c.Filled) != 4 { // its three templated files and its own secret - t.Errorf("restic is not handed to the provisioner: its filled files are %v", c.Filled) - } - // A done key an artifact of which names no reference is waited for, in the controller's words (ADR 0498 §8). - unreferenced := movedMesh(true) - unreferenced.builds["restic "+movedFps["restic"]].Artifacts = []delivery.BuiltArtifact{{Name: "tools", Kind: "bundle", Digest: digestOf("6")}, resticArtifacts[1]} - if _, _, held, err := Compose(ctx, unreferenced, Mesh{}, Scope{Node: "n1"}, movedFps); err != nil || !strings.Contains(held["restic"], "the build of restic is done and waits for the reference of tools") { - t.Errorf("a done key with an unreferenced artifact: %v %v, want restic held alone", err, held) - } - // A module the node runs inside its own composition (the fold) composes from the indexer's manifest though a done - // build of it is at the trunk's FP2: its build is not read. - folding := movedMesh(true) - foldIn, err := Input(ctx, folding, Mesh{}, Scope{Node: "n1", InOwn: []string{"mesh-provisioner-client"}}) - if err != nil { - t.Fatal(err) - } - clientParsed, err := catalogue.Decode(clientRaw) - if err != nil { - t.Fatal(err) - } - if slices.Contains(folding.buildsRead, "mesh-provisioner-client") || !reflect.DeepEqual(foldIn.Catalogue["mesh-provisioner-client"], clientParsed) { - t.Errorf("a folded module was taken from its build: builds read %v", folding.buildsRead) - } - // The controller's: its take-in resolves each manifest with the key's artifacts and the archive's texts, and composes - // from those over the same input. - storeIn, err := Input(ctx, movedMesh(false), Mesh{}, Scope{Node: "n1"}) - if err != nil { - t.Fatal(err) - } - for module, built := range map[string][]catalogue.Built{ - "restic": {{Name: "tools", Kind: "bundle", Reference: resticArtifacts[0].Reference, Digest: digestOf("6")}, - {Name: "files", Kind: "archive", Reference: resticArtifacts[1].Reference, Digest: archiveDigest, Texts: texts}}, - "mesh-provisioner-client": {{Name: "client", Kind: "bundle", Reference: clientArtifacts[0].Reference, Digest: digestOf("7")}}, - } { - resolved, err := storeIn.Catalogue[module].Resolve(built) - if err != nil { - t.Fatal(err) - } - // Kept as its bytes and read again, as the controller's store holds a build's manifest. - raw, _ := json.Marshal(resolved) - if storeIn.Catalogue[module], err = catalogue.Decode(raw); err != nil { - t.Fatal(err) - } - } - storeIn.OwnSecrets = movedMesh(true).own - fromStoreMoved, _, err := compose.Compositions(storeIn, "n1", movedFps, compose.Reading) - if err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(fromBuild, fromStoreMoved) { - gj, _ := json.MarshalIndent(fromBuild, "", " ") - 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. - mailer := []byte(`{"module":"mailer","version":"1","config":{"domain":{"type":"string","description":"the mail domain","required":true}},` + - `"provides":[{"name":"smtp","scope":"mesh"}],"serves":{"smtp":{"port":587,"domain":"${config:domain}"}}}`) - withMailer := func(set bool) *bus { - b := mk() - p := b.placements["anchor"] - p.Modules = append(p.Modules, delivery.PlacedModule{Module: "mailer"}) - b.placements["anchor"] = p - b.modules["mailer"] = delivery.Module{Manifest: delivery.ManifestDigest(mailer)} - b.manifests[delivery.ManifestDigest(mailer)] = mailer - if set { - b.served = map[string]map[string]any{"anchor.mailer": {"domain": "example.org"}} - } - return b - } - if _, _, _, err := Compose(ctx, withMailer(false), Mesh{}, Scope{Node: "laptop"}, fps); err == nil || !strings.Contains(err.Error(), `nothing sets "domain"`) { - t.Errorf("a provider's served setting nobody sets: %v", err) - } - served := withMailer(true) - if _, _, held, err := Compose(ctx, served, Mesh{}, Scope{Node: "laptop"}, fps); err != nil || len(held) > 0 { - t.Errorf("laptop with what anchor's mailer serves: %v %v", err, held) - } - if !reflect.DeepEqual(served.servedRead, []string{"anchor.mailer:domain"}) || slices.Contains(served.valuesRead, "anchor.mailer") { - t.Errorf("laptop read %v of other nodes (and %v of config values): the served keys of anchor's mailer alone", served.servedRead, served.valuesRead) - } - - for name, c := range map[string]struct { - change func(*bus) - scope Scope - says string - }{ - "no record": {func(b *bus) { delete(b.records, "laptop") }, Scope{Node: "anchor"}, "laptop, whose agent has written no record"}, - "no placement": {func(b *bus) {}, Scope{Node: "stranger"}, "stranger has no placement"}, - "a manifest unread": {func(b *bus) { delete(b.manifests, digest) }, Scope{Node: "laptop"}, "the manifest of xterm"}, - "a kept one unbuilt": {func(b *bus) {}, Scope{Node: "laptop", KeptAt: map[string]string{"xterm": "src2:gone"}}, "holds no done build"}, - } { - b := mk() - c.change(b) - if _, err := Input(ctx, b, Mesh{}, c.scope); err == nil || !strings.Contains(err.Error(), c.says) { - t.Errorf("%s: %v, want it to say %q", name, err, c.says) - } - } -} - -// 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 -} diff --git a/go/compose/frombus/frombustest/frombustest.go b/go/compose/frombus/frombustest/frombustest.go deleted file mode 100644 index 3bfc971..0000000 --- a/go/compose/frombus/frombustest/frombustest.go +++ /dev/null @@ -1,183 +0,0 @@ -// 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 " " - 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)} -} diff --git a/go/compose/frombus/frombustest/frombustest_test.go b/go/compose/frombus/frombustest/frombustest_test.go deleted file mode 100644 index 97c71b4..0000000 --- a/go/compose/frombus/frombustest/frombustest_test.go +++ /dev/null @@ -1,272 +0,0 @@ -package frombustest - -import ( - "context" - "reflect" - "strings" - "testing" - - stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/compose/frombus" - "git.novox.be/novox/mesh-sdk/go/compose/fromruntime" - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -// 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 - // refused is the indexer's verdict on bad, a problem its judge found (novox/hq ADR 0578 §1): Plans then holds - // backup too, and never ends the read. - refused string - // dataHeld says the data items' read holds backup too: a refusal it meets (a refused manifest, an unusable build, - // an item composition refuses), not one only a plan meets. - dataHeld bool - // dataOnly checks the data items' read alone: the other reads blame restic, whose templates collect the item. - dataOnly bool - }{ - {name: "every module composes"}, - {name: "a built manifest that cannot be used", - bad: `{"module":"backup","version":"1","build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`, - built: `{"module":"backup","version":"1","requires":"amqp"}`, why: "backup", dataHeld: true}, - {name: "a plan that refuses", - bad: `{"module":"backup","version":"1","resources":[{"id":"conf","type":"file","path":"/etc/backup.conf",` + - `"mode":"0600","content":"key=${secret:nope}"}]}`, why: "${secret:nope}"}, - {name: "an indexer's manifest the mesh refuses", bad: unusableItem, why: `"shows" is none of them`, - refused: `"shows" is none of them`, dataHeld: true}, - // ADR 0578 §3: an accepted manifest whose data item its composition refuses is held by the culprit rule, in every read. - {name: "a data item its composition refuses", - bad: `{"module":"backup","version":"1","data":{"own":[{"id":"d","path":"${dir:nope}","class":"cache"}]}}`, - why: "nope", dataHeld: true, dataOnly: true}, - {name: "an indexer's manifest the mesh refuses, placed elsewhere", bad: unusableItem, elsewhere: true, - refused: `"shows" is none of them`}, - } { - 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.refused != "" { - b.Module["backup"] = delivery.Module{FP2: fp, Manifest: digest, Verdict: delivery.VerdictRefused, - Problems: []string{row.refused}} - } - 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 row.dataOnly { - items, _, iheld, err := frombus.DataItemsHeld(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node}) - if why, ok := iheld["backup"]; err != nil || !ok || !strings.Contains(why, row.why) || len(items["restic"]) == 0 { - t.Errorf("%s: data items %v held %v: %v", row.name, items, iheld, err) - } - continue - } - plans, err := frombus.Plans(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node}) - switch { - case row.refused != "" && (err != nil || !planned(plans, "restic") || planned(plans, "backup")): - t.Errorf("%s: Plans did not hold the refused manifest alone: %v %v", row.name, plans, err) - case row.refused == "" && 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) - } - // Issue 1954: the plans at the wanted builds and at the newest, over one read, are what PlansHeld answers for each, - // and where the time went is said by phase. - keptAt := FPs() - at, newest, took := frombus.PlansHeldTwice(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node, KeptAt: keptAt}) - wantAt, heldAt, errAt := frombus.PlansHeld(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node, KeptAt: keptAt}) - if !reflect.DeepEqual(at, frombus.HeldPlans{Plans: wantAt, Held: heldAt, Err: errAt}) || - !reflect.DeepEqual(newest, frombus.HeldPlans{Plans: plans, Held: held}) { - t.Errorf("%s: twice composed %+v and %+v, want %v %v %v and %v %v", row.name, at, newest, wantAt, heldAt, errAt, plans, held) - } - if said := frombus.PhasesSaid(took); !strings.HasPrefix(said, "read ") || !strings.Contains(said, "plans at newest ") { - t.Errorf("%s: the phases are said as %q", row.name, said) - } - if row.bad == "" || row.elsewhere { - if len(held) > 0 { - t.Errorf("%s: held %v", row.name, held) - } - 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) - } - // The data items are read by the same rule (ADR 0578 §3: disk-load's read): the bad module held, the read whole. - items, _, iheld, err := frombus.DataItemsHeld(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node}) - // A refusal only a plan meets (a file's secret) holds nothing here: data items render no such file. - mustHold := row.dataHeld - if why, ok := iheld["backup"]; err != nil || ok != mustHold || mustHold && !strings.Contains(why, row.why) || items["backup"] != nil { - t.Errorf("%s: data items %v held %v: %v", row.name, items, iheld, err) - } - } - - // ADR 0580 clause 1: the watched reader reads a state it composes from out of the copy, a build by key each time it is - // asked, and a manifest once per digest. - byKey := map[string]int{} - ws := stdio.WatchedStatesOver(func(state string, handle func(stdio.StateChange) error) error { - byKey[state+" watched"]++ - return handle(stdio.StateChange{Key: "placement." + Node, Op: stdio.OpPut, Value: []byte(`{}`), Current: true}) - }, func(state, key string) ([]byte, bool, error) { byKey[state]++; return []byte(key), true, nil }) - w := fromruntime.Watched{WatchedStates: ws} - for range 2 { - _, _, _ = w.Get(frombus.BuildsRead, "restic."+fp) - _, _, _ = w.Get(frombus.ManifestsRead, "sha256-d") - if _, found, _ := w.Get(frombus.PlacementRead, "placement."+Node); !found { - t.Error("the watched reader does not answer the placement from its copy") - } - } - if byKey[frombus.BuildsRead] != 2 || byKey[frombus.ManifestsRead] != 1 || byKey[frombus.PlacementRead] != 0 || - byKey[frombus.PlacementRead+" watched"] != 1 { - t.Errorf("the watched reader read %v, want a build by key each time, a manifest once, the placement watched once", byKey) - } - fetched := 0 - keep := fromruntime.KeptFetch(func(context.Context, string) ([]byte, error) { fetched++; return []byte("tar"), nil }) - for range 2 { - if body, err := keep(context.Background(), "http://store/restic/files/blobs/sha256:a"); err != nil || string(body) != "tar" { - t.Fatalf("the kept fetch answered %q, %v", body, err) - } - } - if fetched != 1 { - t.Errorf("a templates archive was fetched %d times, want once", fetched) - } - - // Issue 1954: the states reader takes a build in once per its content: composed again, its archive is not read again - // and the plans are the same. - b, err := Provisioned() - if err != nil { - t.Fatal(err) - } - reader, archives := frombus.StatesReader(statesOf(t, b), func(_ context.Context, url string) ([]byte, error) { - fetched++ - _, digest, _ := strings.Cut(url, "/blobs/") - return b.Archives[digest], nil - }), fetched - var first map[string]catalogue.Plan - for i := range 2 { - plans, held, err := frombus.PlansHeld(context.Background(), reader, frombus.Mesh{}, frombus.Scope{Node: Node}) - if err != nil || len(held) > 0 || (i == 1 && !reflect.DeepEqual(plans, first)) || !planned(plans, "restic") { - t.Fatalf("composed %v (held %v): %v", plans, held, err) - } - first = plans - } - if fetched-archives != 1 { - t.Errorf("restic's archive was read %d times over two compositions, want once", fetched-archives) - } -} - -// statesOf is the bus b as a module's read states hold it. -func statesOf(t *testing.T, b *Bus) frombus.States { - t.Helper() - s := states{} - put := func(state, key string, raw []byte, err error) { - if err != nil { - t.Fatal(err) - } - if s[state] == nil { - s[state] = map[string][]byte{} - } - s[state][key] = raw - } - for node, p := range b.Placement { - raw, err := delivery.EncodePlacement(p) - put(frombus.PlacementRead, "placement."+node, raw, err) - } - for node, rec := range b.Record { - raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Record: &rec}) - put(frombus.HaveRead, node+".node", raw, err) - } - for module, m := range b.Module { - key, _ := delivery.ModulesKey(module) - raw, err := delivery.EncodeModule(m) - put(frombus.ModulesRead, key, raw, err) - } - for digest, man := range b.Manifests { - key, _ := delivery.ManifestsKey(digest) - raw, err := delivery.EncodeManifest(delivery.Manifest{Module: "m", JSON: string(man)}) - put(frombus.ManifestsRead, key, raw, err) - } - for at, build := range b.Builds { - module, fp, _ := strings.Cut(at, " ") - key, _ := delivery.BuilderBuildKey(module, fp) - raw, err := delivery.EncodeBuilderBuild(*build) - put(frombus.BuildsRead, key, raw, err) - } - return s -} - -// states are read states in memory. -type states map[string]map[string][]byte - -func (s states) Get(state, key string) ([]byte, bool, error) { - v, ok := s[state][key] - return v, ok, nil -} - -func (s states) Keys(state string) ([]string, error) { - var keys []string - for k := range s[state] { - keys = append(keys, k) - } - return keys, nil -} - -func planned[P any](plans map[string]P, module string) bool { _, ok := plans[module]; return ok } - -// 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"}]}` diff --git a/go/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-mesh-provisioner-client.module.json b/go/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-mesh-provisioner-client.module.json deleted file mode 100644 index 095128a..0000000 --- a/go/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-mesh-provisioner-client.module.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "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" - } - ] - } -} diff --git a/go/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/backups.conf b/go/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/backups.conf deleted file mode 100644 index 2f23f90..0000000 --- a/go/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/backups.conf +++ /dev/null @@ -1,2 +0,0 @@ -# What the modules on this machine back up, composed by the mesh (novox/hq to-be 43). Do not edit. -${backup:lines} \ No newline at end of file diff --git a/go/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/data.conf b/go/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/data.conf deleted file mode 100644 index f19e849..0000000 --- a/go/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/data.conf +++ /dev/null @@ -1,2 +0,0 @@ -# The data the modules on this machine declare, composed by the mesh (novox/hq ADR 0233). Do not edit. -${backup:data} \ No newline at end of file diff --git a/go/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/retention.conf b/go/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/retention.conf deleted file mode 100644 index cd7954c..0000000 --- a/go/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/retention.conf +++ /dev/null @@ -1,6 +0,0 @@ -# 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} diff --git a/go/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.module.json b/go/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.module.json deleted file mode 100644 index ea5ec8b..0000000 --- a/go/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.module.json +++ /dev/null @@ -1,199 +0,0 @@ -{ - "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" - } - ] - } -} diff --git a/go/compose/frombus/held_rows_test.go b/go/compose/frombus/held_rows_test.go deleted file mode 100644 index acb0907..0000000 --- a/go/compose/frombus/held_rows_test.go +++ /dev/null @@ -1,298 +0,0 @@ -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) - refuse(b, "power", `"shows" is none of them`) - _, _, 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) - } -} - -// refuse writes the indexer's refusal of module's manifest into its modules value (novox/hq ADR 0578 §1). -func refuse(b *bus, module string, problems ...string) { - m := b.modules[module] - m.Verdict, m.Problems = delivery.VerdictRefused, problems - b.modules[module] = m -} - -// ADR 0578 §3: the bus reader never ends a whole read on a refused manifest. Input, Plans and DataItems compose the rest -// and hold the refused module, named with the indexer's words in Refused; a field the decoder does not know is set aside -// and named, and its module composed. -func aRefusedManifestNeverEndsTheRead(t *testing.T) { - ok := `{"module":"lamp","version":"1","resources":[{"id":"d","type":"directory","path":"/srv/lamp","mode":"0700"}],` + - `"data":{"own":[{"id":"d","path":"${dir:d}","class":"valuable"}]}}` - bad := `{"module":"power","version":"1","resources":[{"id":"d","type":"directory","path":"/srv/power","mode":"0700"}],` + - `"data":{"own":[{"id":"d","path":"${dir:d}","class":"valuable"}]}}` - later := `{"module":"later","version":"1","a-field-from-later":1}` - b, _ := aNode(t, ok, bad, later) - refuse(b, "power", "removes a stream it may not") - ctx := context.Background() - in, err := Input(ctx, b, Mesh{}, Scope{Node: "n1"}) - if err != nil { - t.Fatalf("one refused manifest ended the read: %v", err) - } - if why := in.Refused["power"]; !strings.Contains(why, "refused the manifest of power: removes a stream it may not") || - len(in.Refused) != 1 { - t.Fatalf("refused: %v", in.Refused) - } - if aside := in.SetAside["later"]; !strings.Contains(aside, "a-field-from-later") { - t.Fatalf("set aside: %v", in.SetAside) - } - if !in.Catalogue["power"].Stubbed || in.Catalogue["lamp"].Stubbed || in.Catalogue["later"].Module != "later" { - t.Fatalf("the catalogue: %+v", in.Catalogue) - } - if _, err := Plans(ctx, b, Mesh{}, Scope{Node: "n1"}); err != nil { - t.Fatalf("plans: %v", err) - } - items, _, held, err := DataItemsHeld(ctx, b, Mesh{}, Scope{Node: "n1"}) - if err != nil || len(items["lamp"]) != 1 || items["power"] != nil || !strings.Contains(held["power"], "removes a stream") { - t.Fatalf("data items %v held %v: %v", items, held, err) - } -} - -// 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) - } - } - } -} diff --git a/go/compose/frombus/states.go b/go/compose/frombus/states.go deleted file mode 100644 index b26c314..0000000 --- a/go/compose/frombus/states.go +++ /dev/null @@ -1,204 +0,0 @@ -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. -// It keeps what each build's take-in made, by the build's content (issue 1954), so a holder composing again and again -// takes in only a build it has not seen. -func StatesReader(s States, fetch Fetch) Reader { - return statesReader{s: s, fetch: fetch, kept: &keptTakeIns{}} -} - -type statesReader struct { - s States - fetch Fetch - kept *keptTakeIns -} - -func (r statesReader) takenIn(key string) ([]byte, bool) { return r.kept.takenIn(key) } - -func (r statesReader) keepTakenIn(key string, raw []byte) { r.kept.keepTakenIn(key, raw) } - -func (r statesReader) each(read string, visit func(key string, value []byte)) error { - keys, err := r.s.Keys(read) - if err != nil { - 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 -} diff --git a/go/compose/frombus/testdata/catalogue-c05aa5e6-mesh-provisioner-client.module.json b/go/compose/frombus/testdata/catalogue-c05aa5e6-mesh-provisioner-client.module.json deleted file mode 100644 index 095128a..0000000 --- a/go/compose/frombus/testdata/catalogue-c05aa5e6-mesh-provisioner-client.module.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "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" - } - ] - } -} diff --git a/go/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/backups.conf b/go/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/backups.conf deleted file mode 100644 index 2f23f90..0000000 --- a/go/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/backups.conf +++ /dev/null @@ -1,2 +0,0 @@ -# What the modules on this machine back up, composed by the mesh (novox/hq to-be 43). Do not edit. -${backup:lines} \ No newline at end of file diff --git a/go/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/data.conf b/go/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/data.conf deleted file mode 100644 index f19e849..0000000 --- a/go/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/data.conf +++ /dev/null @@ -1,2 +0,0 @@ -# The data the modules on this machine declare, composed by the mesh (novox/hq ADR 0233). Do not edit. -${backup:data} \ No newline at end of file diff --git a/go/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/retention.conf b/go/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/retention.conf deleted file mode 100644 index cd7954c..0000000 --- a/go/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/retention.conf +++ /dev/null @@ -1,6 +0,0 @@ -# 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} diff --git a/go/compose/frombus/testdata/catalogue-c05aa5e6-restic.module.json b/go/compose/frombus/testdata/catalogue-c05aa5e6-restic.module.json deleted file mode 100644 index ea5ec8b..0000000 --- a/go/compose/frombus/testdata/catalogue-c05aa5e6-restic.module.json +++ /dev/null @@ -1,199 +0,0 @@ -{ - "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" - } - ] - } -} diff --git a/go/compose/frombus/testdata/de-spiegel-e25e1beb.module.json b/go/compose/frombus/testdata/de-spiegel-e25e1beb.module.json deleted file mode 100644 index b4c4bd2..0000000 --- a/go/compose/frombus/testdata/de-spiegel-e25e1beb.module.json +++ /dev/null @@ -1,129 +0,0 @@ -{ - "module": "de-spiegel", - "version": "1", - "slug": "spiegel", - "capabilities": [ - "container-runtime" - ], - "requires": [ - "route", - "smtp" - ], - "contributes": { - "route": { - "site": { - "label": "de-spiegel", - "port": 35621 - }, - "praktijk": { - "name": "${config:praktijk-name}", - "port": 35621 - }, - "praktijk-www": { - "name": "${config:praktijk-www-name}", - "redirect": "https://${config:praktijk-name}" - }, - "psychologie": { - "name": "${config:psychologie-name}", - "port": 35621 - }, - "psychologie-www": { - "name": "${config:psychologie-www-name}", - "redirect": "https://${config:psychologie-name}" - } - }, - "smtp": { - "account": "de-spiegel" - } - }, - "binds": { - "route": "/var/lib/de-spiegel/route.json", - "smtp": "/var/lib/de-spiegel/smtp.json" - }, - "config": { - "praktijk-name": { - "type": "string", - "description": "The public name the practice's site is served on; the operator's, with no default (novox/hq ADR 0112).", - "trusted": true, - "required": true - }, - "praktijk-www-name": { - "type": "string", - "description": "The public name that redirects to the practice's site (its www form); the operator's, with no default (novox/hq ADR 0112).", - "trusted": true, - "required": true - }, - "psychologie-name": { - "type": "string", - "description": "The public name the psychology practice's site is served on; the operator's, with no default (novox/hq ADR 0112).", - "trusted": true, - "required": true - }, - "psychologie-www-name": { - "type": "string", - "description": "The public name that redirects to the psychology practice's site (its www form); the operator's, with no default (novox/hq ADR 0112).", - "trusted": true, - "required": true - } - }, - "listens": [ - { - "port": 35621, - "protocol": "tcp", - "from": "mesh", - "why": "the de-spiegel site and its /contact endpoint over http; the practice's three public names are route grants, and the proxy reaches every one of them here" - } - ], - "resources": [ - { - "id": "state", - "type": "directory", - "path": "/var/lib/de-spiegel", - "mode": "0700" - }, - { - "id": "server-env", - "type": "file", - "path": "/var/lib/de-spiegel/server.env", - "mode": "0600", - "content": "SMTP_HOST=${bound:smtp:at}\nSMTP_PORT=${bound:smtp:port}\nSMTP_AUTH_USER=de-spiegel@${bound:smtp:domain}\nSMTP_AUTH_PASS=${secret:smtp}\n" - }, - { - "id": "net", - "type": "network", - "name": "de-spiegel" - }, - { - "id": "server", - "type": "container", - "name": "de-spiegel", - "artifact": "site", - "network": "de-spiegel", - "env-file": [ - "/var/lib/de-spiegel/server.env" - ], - "ports": [ - "35621" - ], - "secrets-in-environment": "the application's own code reads SMTP_AUTH_USER/PASS from the environment (server/index.js); converting is this repository's change" - } - ], - "build": { - "on": [ - { - "arg": "NODE_BASE", - "image": "node@sha256:48e4b67d85f87bd551df43704e24d252f56cc5f8e9718841aace50f19948f0f9" - } - ], - "artifacts": [ - { - "name": "site", - "kind": "image", - "from": "Dockerfile" - } - ] - }, - "secrets": { - "smtp": "/var/lib/de-spiegel/smtp.secret" - } -} diff --git a/go/compose/frombus/testdata/f1-circuits-c0ea2c94.module.json b/go/compose/frombus/testdata/f1-circuits-c0ea2c94.module.json deleted file mode 100644 index 8f2b612..0000000 --- a/go/compose/frombus/testdata/f1-circuits-c0ea2c94.module.json +++ /dev/null @@ -1,82 +0,0 @@ -{ - "module": "f1-circuits", - "slug": "f1", - "version": "1", - "capabilities": [ - "container-runtime" - ], - "requires": [ - "route" - ], - "contributes": { - "route": { - "label": "f1-circuits", - "endpoint": "web" - } - }, - "binds": { - "route": "${dir:state}/route.json" - }, - "listens": [ - { - "name": "web", - "port": 8000, - "protocol": "tcp", - "from": "mesh", - "why": "the track-layout CDN, the race posters and the Kometa metadata over http; its public name is a route grant and the proxy reaches it here. Kometa fetches the metadata and every poster by that public name" - } - ], - "resources": [ - { - "id": "state", - "type": "directory", - "mode": "0700", - "place": "." - }, - { - "id": "cache", - "type": "directory", - "mode": "0700" - }, - { - "id": "site-env", - "type": "file", - "path": "${dir:state}/site.env", - "mode": "0644", - "content": "F1_CIRCUITS_BASE_URL=https://${bound:route:name}\n" - }, - { - "id": "server", - "type": "container", - "name": "f1-circuits", - "artifact": "cdn", - "ports": [ - "8000" - ], - "env-file": [ - "${dir:state}/site.env" - ], - "volumes": [ - "${dir:cache}:/app/cache" - ], - "restart-on": [ - "site-env" - ] - } - ], - "build": { - "on": [ - { - "arg": "PYTHON_BASE", - "image": "python@sha256:6d43704baacd1bfbe7c295d7f13079d5d8104ed33568873133f8fc69980419df" - } - ], - "artifacts": [ - { - "name": "cdn", - "kind": "image", - "from": "service/Dockerfile" - } - ] - } -} diff --git a/go/compose/frombus/twice.go b/go/compose/frombus/twice.go deleted file mode 100644 index cb133df..0000000 --- a/go/compose/frombus/twice.go +++ /dev/null @@ -1,147 +0,0 @@ -package frombus - -// **A node's plans at two sets of builds, over one read** (issue 1954; novox/hq ADR 0570 clause 2, ADR 0580 clause 2): -// the seat mesh-provisioner's holder composes each node at the builds its wants name and at the newest build of each -// placed module. Both are composed from one read of the bus (placement, records, the indexer's modules and manifests, -// values: base); only the builds the node's modules are composed from differ, each build read once for both, and a -// build taken in once per content by a reader that keeps it (StatesReader). Where the time went is said by phase. - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "sync" - "time" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -// HeldPlans is one scope's plans, as PlansHeld answers them: err a refusal that is no single module's. -type HeldPlans struct { - Plans map[string]catalogue.Plan - Held map[string]string - Err error -} - -// Phase is one step of a composition and how long it took. -type Phase struct { - Name string `json:"phase"` - Took time.Duration `json:"took"` -} - -// PlansHeldTwice is PlansHeld at s and at s with no kept builds (each placed module at its newest build), over one -// read of the bus: what each answers is what PlansHeld answers for that scope. A read that fails is both scopes' err. -// took names each phase in order: the read, then each scope's builds and its plans. -func PlansHeldTwice(ctx context.Context, r Reader, mesh Mesh, s Scope) (at, newest HeldPlans, took []Phase) { - start := time.Now() - mark := func(name string) { - now := time.Now() - took = append(took, Phase{Name: name, Took: now.Sub(start)}) - start = now - } - b, err := readBase(ctx, r, mesh, s) - mark("read") - builds := onceBuilds(r) - newestScope := s - newestScope.KeptAt = nil - for _, sc := range []struct { - scope Scope - into *HeldPlans - name string - }{{s, &at, "wanted"}, {newestScope, &newest, "newest"}} { - held := map[string]string{} - sc.into.Held = held - refuse := func(module string, err error) { held[module] = err.Error() } - b.refuseOwn(refuse) - if err != nil { - sc.into.Err = err - continue - } - in, err := b.finish(ctx, r, builds, mesh, sc.scope, b.cloned(), refuse, nil) - mark("builds at " + sc.name) - if err != nil { - sc.into.Err = err - continue - } - sc.into.Plans, sc.into.Err = plansHeldOf(in, s.Node, held) - mark("plans at " + sc.name) - } - return at, newest, took -} - -// onceBuilds is r's Build, each module's build at a fingerprint read once: both scopes of one composition read the -// builds of the same moment. -func onceBuilds(r Reader) func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error) { - type answer struct { - b *delivery.BuilderBuild - err error - } - read := map[string]answer{} - return func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error) { - key := module + " " + fp - if a, ok := read[key]; ok { - return a.b, a.err - } - b, err := r.Build(ctx, module, fp) - read[key] = answer{b, err} - return b, err - } -} - -// takenInKeeper is a Reader that keeps what a build's take-in made (StatesReader does): the resolved manifest's bytes, -// by the content it was made from, so a composition made again on every change takes in only a build it has not seen. -type takenInKeeper interface { - takenIn(key string) ([]byte, bool) - keepTakenIn(key string, raw []byte) -} - -// takenInKey is what a take-in is made from: the artifact store's address and the build's record (its manifest's digest, its artifacts by digest), read -// whole; an archive is checked against its digest on every read, so the same record takes in to the same bytes. -func takenInKey(module, fp, address string, b delivery.BuilderBuild) (string, bool) { - raw, err := json.Marshal(b) - if err != nil { - return "", false - } - sum := sha256.Sum256(append([]byte(module+" "+fp+" "+address+" "), raw...)) - return hex.EncodeToString(sum[:]), true -} - -// takenInAtMost bounds the take-ins a reader keeps; past it they are emptied, which costs only a take-in again. -const takenInAtMost = 512 - -// keptTakeIns is a reader's kept take-ins. -type keptTakeIns struct { - mu sync.Mutex - kept map[string][]byte -} - -func (k *keptTakeIns) takenIn(key string) ([]byte, bool) { - k.mu.Lock() - defer k.mu.Unlock() - raw, ok := k.kept[key] - return raw, ok -} - -func (k *keptTakeIns) keepTakenIn(key string, raw []byte) { - k.mu.Lock() - defer k.mu.Unlock() - if k.kept == nil || len(k.kept) >= takenInAtMost { - k.kept = map[string][]byte{} - } - k.kept[key] = raw -} - -// PhasesSaid says the phases in one line, each in milliseconds. -func PhasesSaid(took []Phase) string { - out := "" - for i, p := range took { - if i > 0 { - out += ", " - } - out += fmt.Sprintf("%s %d ms", p.Name, p.Took.Milliseconds()) - } - return out -} diff --git a/go/compose/fromruntime/fromruntime.go b/go/compose/fromruntime/fromruntime.go deleted file mode 100644 index 997fa5a..0000000 --- a/go/compose/fromruntime/fromruntime.go +++ /dev/null @@ -1,101 +0,0 @@ -// 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" - "sync" - - 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 key by key, fetching archives over HTTP. A holder that -// composes again and again reads through Watched instead (novox/hq ADR 0580). -func Reader() frombus.Reader { return frombus.StatesReader(States{}, HTTPFetch) } - -// Watched is frombus's States over a watched copy (novox/hq ADR 0580 clause 1): the states a composition reads whole -// (placement, haves, modules, resolved values) from the copy, a build by key when asked, a manifest once per digest. -type Watched struct{ *stdio.WatchedStates } - -// Get is a key's value: a build read now, a manifest kept once read, any other state's from the copy. -func (w Watched) Get(state, key string) ([]byte, bool, error) { - switch state { - case frombus.BuildsRead: - return w.Direct(state, key) - case frombus.ManifestsRead: - return w.Once(state, key) - } - return w.WatchedStates.Get(state, key) -} - -// WatchedReader is frombus's Reader over w, fetching each templates archive over HTTP once (KeptFetch): a composition -// made again on every change reads it again otherwise. -func WatchedReader(w *stdio.WatchedStates) frombus.Reader { - return frombus.StatesReader(Watched{w}, KeptFetch(HTTPFetch)) -} - -// archivesAtMost bounds the archives KeptFetch keeps; past it they are emptied, which costs only a fetch again. -const archivesAtMost = 64 - -// KeptFetch is fetch kept once per URL: an archive's URL names its blob by digest, so its body never changes under it -// (frombus checks the digest on every read). A failed fetch is not kept. -func KeptFetch(fetch frombus.Fetch) frombus.Fetch { - var mu sync.Mutex - kept := map[string][]byte{} - return func(ctx context.Context, url string) ([]byte, error) { - mu.Lock() - body, ok := kept[url] - mu.Unlock() - if ok { - return body, nil - } - body, err := fetch(ctx, url) - if err != nil { - return nil, err - } - mu.Lock() - if len(kept) >= archivesAtMost { - clear(kept) - } - kept[url] = body - mu.Unlock() - return body, nil - } -} - -// HTTPFetch reads an archive over HTTP, at most 256 MiB. -func HTTPFetch(ctx context.Context, url string) ([]byte, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - 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)) -} diff --git a/go/compose/input.go b/go/compose/input.go deleted file mode 100644 index 72aecf7..0000000 --- a/go/compose/input.go +++ /dev/null @@ -1,365 +0,0 @@ -// Package compose is the controller's composition of a machine, as a function over a plain input (novox/hq ADR 0453 -// clause 3, ADR 0446 clause 4): what the rest of the mesh offers and holds, the private network's generator, one -// node's resolution and, in the issues after, its rendering and its declaration. -// -// **It never allocates, makes, writes, logs or raises.** The controller composes with a store behind it; this -// package is its Reading and Foreseeing modes over data. Everything the controller reads from its store is a field of -// Input, and everything a send would make or record (a port, a credential, a seat passed on, a node left out) is -// returned for the caller to act on, never done. Input is never changed by a call. No field holds a secret value -// the composition needs: sealed values are optional inputs, and without them the composition uses stand-ins named -// foreseen. The logic is the controller's, ported from mesh-controller cmd/mesh-controller at commit -// 6c2a8d62100cb7d491f8eb9a623e1eeea5ec0de1 (plan.go, network.go, holdings.go, seat_pass.go), in the same order, with the -// same refusals in the same words, so that a comparison node for node is a fair one. -// -// It imports the standard library and mesh-sdk's own packages only. -// It reads no environment variable: the range the mesh allocates from and the suffix of its internal names are fields of Input. -package compose - -import ( - "encoding/json" - "errors" - "fmt" - "sort" - "strings" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -// Input is everything the composition reads. Each field is what one read of the controller's inventory answers, named -// beside it; the controller's adapter fills it from its stores and the assigner from the mesh's facts and buckets -// (ADR 0446 clause 2). -type Input struct { - // Catalogue is the shelf: every module's manifest by name (inv.Catalogue), as it is to be composed. A recorded - // module kept at the build a machine runs (keepRecorded) is that node's Kept, applied to its own resolution only (never to the rest of the mesh). - Catalogue map[string]catalogue.Manifest - // Nodes is every node the mesh has (inv.Nodes and inv.Overlays together, one row per node). - Nodes []Node - // Holdings is every seat whose holder is on record (inv.Holdings). - Holdings []catalogue.Held - // Licences is every licence the mesh holds, with the machines that use it (open.Licences); none for a mesh with no - // licence. - Licences []Licence - - // OverlayCIDR is the range the mesh allocates from when no tunnel was adopted, as genesis was told it (the - // controller's OverlayCIDRVar); empty means DefaultOverlayCIDR. The package reads no environment. - OverlayCIDR string - // InternalSuffix is what the mesh's internal names end in (the controller's MESH_INTERNAL_SUFFIX), with or without a leading - // dot; empty means overlay.DefaultSuffix. A fact of the whole mesh, so it is in the Input and never read from the environment. - InternalSuffix string - // Tunnels is, by node name, the found tunnel whose key is the node's overlay key (inv.Tunnels). - Tunnels map[string]FoundTunnel - // Adopted is the hub's found tunnel and the hub's name, whether or not the hub took it (inv.AdoptedTunnel); nil - // when no hub has one. - Adopted *AdoptedTunnel - // CarriedPeers is every peer of the adopted tunnel (inv.CarriedPeers). - CarriedPeers []CarriedPeer - // Joining is the nodes that hold a live enrolment token (inv.NodesWithALiveToken). - Joining []string - - // Credentials is how each pair credential a node takes from elsewhere stands, never its value (what inv.SecretFor, - // SharedSecretFor and GivenPairSecret would answer): held, to be made by the next send, only a person's to give, or - // refused. A need with no entry here is one the next send would make. - Credentials []Credential - - // Built is every repository the mesh has built (the keys of inv.Held): what tells a reference recorded with an address - // from an image a module runs straight from a public registry. - Built []string - // OperatorExport is every operator-sealed secret in the mesh, for a module that keeps them (inv.OperatorExport): sealed - // values the composition carries without opening. Nil when the mesh has no operator key. - OperatorExport *catalogue.KeptExport - // KeptRuns is, by node and module, the file of the export as that node runs it (its resource of a module's running - // composition): what a node composing its own carries where it holds no OperatorExport (catalogue.Rendering.KeptRuns, - // issue 1126, C6). Used only while OperatorExport is nil. - KeptRuns map[string]map[string]map[string]any - - // Issued is the credentials made for the consumers of each provider (inv.SecretsFrom): one row for each pair credential - // that exists, by the provider node it is for. The grants a provider must create are read from these, so a credential not - // made yet has no grant until the send that makes it. An assigner fills it from the facts snapshot's Grants of each - // machine (go/facts, from format 2), which carry who each credential is for and never the credential. - Issued []Issued - // OwnSecrets is the own secrets held on each machine (the module_secret rows): who made each, and the key it is sealed to. - // Their sealed values are optional. - OwnSecrets []OwnSecretRow - // KeptCoreBoxes is the vault's box last kept for each core credential on each machine (inv.KeptCoreBoxes). - KeptCoreBoxes []CoreBox - // BusUsers is the bus's user list as the controller composes it from the bus records and the hashes it holds (composeBusUsers), - // for the machine that runs the bus: names and hashes, never passwords. Empty when the Input does not carry it, and then - // the composition uses a stand-in, named foreseen. BusMissing are the users left out of it for want of a credential. - BusUsers string - BusMissing []string - // BusMinted are the bus users the mesh has minted a password hash for (inv.BusUserHash): `.` for a module and - // `provisioner.` for a machine's provisioner client. - BusMinted []string - // BusMemberships is, by node, the membership sealed to it for the bus the mesh is moving to (inv.BusMemberships). - BusMemberships map[string]string - // Authority is the mesh's own certificate (identity.EstablishAuthority), public. Optional. - Authority string - - // CurrentBuilds is, by module, the commit of the build the mesh holds for it (inv.CurrentBuilds, the commit it was built from); - // BuildIDs the build made from a commit of a module (inv.BuildOf), by module then commit. Read only to name the builds a - // declaration carries to a node-engine that reads them. - CurrentBuilds map[string]string - BuildIDs map[string]map[string]string - - // Refused is each module whose manifest the indexer's judge refused, or whose bytes do not decode, by module, with why - // (novox/hq ADR 0578 §3, §5): its verdict, carried in the plain input. Such a module stands in Catalogue as its stub - // (compose.Stub), so it is held: nothing of its own is composed, while what it provides and requires still shapes the - // rest. A module not named is accepted, a missing verdict included. - Refused map[string]string `json:",omitempty"` - // SetAside is each module whose manifest has a field the reader's decoder does not know, by module, with what was set - // aside (catalogue.Manifest.SetAside): the rest of it is composed, and the reader names it (ADR 0578 §6). - SetAside map[string]string `json:",omitempty"` -} - -// Issued is one pair credential made for a consumer of a provider (inv.Secret): ForProvider is its copy sealed to the -// provider, optional. -type Issued struct { - Provision string - Consumer string - ConsumerModule string - Local string - Provider string - ForProvider string -} - -// OwnSecretRow is one own secret held on a machine: Origin is `made` (the mesh generated it) or `accepted` (a person gave it), -// SealedTo the sealing key it is sealed to, and Sealed its value sealed to that key, optional. -type OwnSecretRow struct { - Node string - Module string - Name string - Origin string - SealedTo string - Sealed string -} - -// CoreBox is the vault's box for one core credential of one machine, as last kept (inv.CoreBox). -type CoreBox struct { - Node string - Module string - Name string - Generation int - SealedTo string - Box json.RawMessage -} - -// Node is one node of the mesh and what the composition reads about it. -type Node struct { - Name string - // Place is where it is on the private network (inv.Overlays). - 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). - PublicDomain string - // Account, AccountHome, AgentAccount and AgentAccountHome are its operator and agent logins (inv.NodeByName). - Account, AccountHome, AgentAccount, AgentAccountHome string - // Adopted is whether it is adopted rather than converged. - Adopted bool - // Pins is the provider each provision was pinned to for it (inv.PinsFor). - Pins map[string]catalogue.Chosen - // Bound is where each of its consumers of a provision that keeps data was last sent (inv.BindingsFor), by consumer - // module and provision. An assigner fills it from the facts snapshot's Bindings of the machine (go/facts, from format 2). - Bound map[string]map[string]catalogue.Chosen - // Settings is its settings layers by module (inv.SettingsFor); a module with none is absent. - Settings map[string][]catalogue.Layer - // Ports is the machine port each module's software port was given on it (inv.PortsFor), by module then port. - Ports map[string]map[int]int - // SealingKey is the public key its secrets are sealed to (inv.SealingKeyOf); empty when it has none. A public key, so - // nothing secret. - SealingKey string - // Taken is the modules taken on it, for an adopted node (inv.Taken). - Taken []string - // OutwardLinks are the links it reported as facing outside it (inv.OutwardLinksOf). - OutwardLinks []string - // Networks are the networks it reported being attached to, as link and network (inv.MachineOf); NetworksSaid is whether - // its node-engine said any at all, which is not the same as saying none. - Networks []catalogue.AttachedNetwork - NetworksSaid bool - // ServingKey is the key the machine serves with, which a certificate is issued for (identity.ServingKeyOf); empty when it has - // never reported one. Certificate is the certificate the mesh issued for its internal name, public and optional. - ServingKey string - Certificate string - // Sequence, Epoch and Generation are what the declaration is sent under (inv.Sequence, SentEpoch, SentGeneration): the order - // against every other send to the node, the lease epoch and the assignment generation it was last sent. Zero is not sent. - Sequence int64 - Epoch uint64 - Generation int64 - // ReadsModules says its node-engine reads a module per resource and a build per module (inv.ReadsModules, issue 1145); only - // then are they sent. SentBuilds is the build of each module it was last sent, by module to commit, and SentKnown whether - // that is known at all (inv.SentBuilds); Runs is what each of its assignments runs (inv.AssignmentRuns), by module. - ReadsModules bool - SentBuilds map[string]string - SentKnown bool - Runs map[string]Run - // Kept is, by module, the manifest of a recorded or core module kept at the build this machine runs (plan.go keepRecorded: - // the caller reads it with recordedKept and ManifestAt), and KeptBuilds the commit of that build. They apply to this node's - // own resolution only, never to another node's, and a declaration carries the kept commit for the module. - Kept map[string]catalogue.Manifest - KeptBuilds map[string]string - // Contract is the contract of its node-engine's newest statement (inv.HealthOf); Stated is whether it ever stated one. - Contract int - Stated bool - // InOwn is the modules it runs inside its own composition (`.node`), not by a composition of their own, since - // no fingerprint of the new form names them where it runs them (novox/hq issue 1126): Compositions folds their - // resources into the node's own, as the controller's composed writer does, unless fps gives one of the new form. - // The private network's module (overlay.Name), which nobody builds, is folded whether or not it is named here. - InOwn map[string]bool - // RunsOwn is the node's own composition (`.node`) as it runs, when the node composes itself: a module folded - // there (InOwn) whose manifest names a build (NamesBuild) runs a build of the old form only the controller holds, - // so it is left to the node: Compositions composes it as a stub (Stub) and carries its resources as they run, each - // after the resource it runs after (issue 1126). Nil composes every folded module from its manifest, as before. - RunsOwn *delivery.Composed - // Held is the modules refused alone on this node, each composed as what runs of it (Carried, in Kept; its Stub when - // nothing of it runs) so the rest is composed (novox/hq issue 1126): none of a held module's own composition is in what - // Compositions answers, so the node keeps what it last applied of it; one whose resources the node's own runs - // (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). -type Place struct { - Key string - Endpoint string - Site string - Hub bool - Address string -} - -// Reachable says whether the node can be dialled. -func (p Place) Reachable() bool { return strings.TrimSpace(p.Endpoint) != "" } - -// Tunnel is a tunnel found on a node (inv.Tunnel). -type Tunnel struct { - Interface string - Unit string - Config string - Port int - MTU int - // Address is the interface's own address with its prefix length, Range the network that prefix names. - Address string - Range string - PublicKey string - Peers []TunnelPeer -} - -// TunnelPeer is one peer of a found tunnel. -type TunnelPeer struct { - PublicKey string - Address string -} - -// FoundTunnel is a node's tunnel and whether the node is adopted (inv.FoundTunnel). -type FoundTunnel struct { - Tunnel - NodeAdopted bool -} - -// AdoptedTunnel is the hub's tunnel, the hub's name, and whether the hub's overlay key is the tunnel's, which is -// whether the private network took the tunnel over. -type AdoptedTunnel struct { - Tunnel Tunnel - Hub string - Adopted bool -} - -// CarriedPeer is a peer of the adopted tunnel (inv.CarriedPeer). -type CarriedPeer struct { - PublicKey string - Address string - EnrolledAs string - Named string -} - -// Licence is one licence the mesh holds: its name, what a consumer may know of it, and the machines and modules -// that use it. -type Licence struct { - Name string - Serves map[string]any - Holders []Holder - // Keys is, by holder, the licence's key sealed to that holder's machine (held.KeyFor), empty where the mesh holds none: - // the plaintext was discarded when it was supplied. Optional: nil means not given, and the composition uses a stand-in named - // foreseen; given (even empty), a holder with no entry is one the mesh holds no key for. - Keys map[Holder]string - // Manager is the holder of a refreshable-grant licence that manages it (held.ManagerOf); the zero Holder when none. - Manager Holder -} - -// Holder is a module on a node that uses a licence. -type Holder struct { - Node string - Module string -} - -// ErrNoSuchNode is what the controller's inventory answers for a node it does not have. -var ErrNoSuchNode = errors.New("no node of that name") - -// node is the node of that name, or the refusal the controller's inventory gives for one it does not have. -func (in Input) node(name string) (Node, error) { - for _, n := range in.Nodes { - if n.Name == name { - return n, nil - } - } - return Node{}, fmt.Errorf("%w: %s", ErrNoSuchNode, name) -} - -// sorted is the nodes by name, the order the controller reads them in. -func (in Input) sorted() []Node { - out := append([]Node(nil), in.Nodes...) - sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) - return out -} - -// CredentialState is how one pair credential stands. -type CredentialState string - -const ( - // CredentialHeld is a credential held for both ends as they stand now. Sealed, when given, is the consumer's copy; a held - // credential with no value is composed with a stand-in, named foreseen. - CredentialHeld CredentialState = "held" - // CredentialWouldMake is a credential none is held for: the next send makes it, and the composition uses a stand-in, - // named foreseen. It is also what a need with no entry is. - CredentialWouldMake CredentialState = "would-make" - // CredentialNotGiven is a credential only a person gives (novox/hq ADR 0337 clause 4) that nobody has given, or that was given - // under a key one end no longer has: the consumer still resolves, without it. Why is the controller's words for it. - CredentialNotGiven CredentialState = "not-given" - // CredentialRefused is a credential the send would refuse to make, for the reason Why gives in the controller's words: a - // value a person gave sealed to a key the machine no longer has, a machine with no sealing key. - CredentialRefused CredentialState = "refused" -) - -// Credential is the state of one pair credential, by the ends it joins: the provision, the consumer's node and module, the -// provider's node, and the local name where a module keeps several (inv.Secret's key). -type Credential struct { - Provision string - Consumer string - ConsumerModule string - Provider string - Local string - State CredentialState - // Sealed is the consumer's copy as it is sealed to the consumer's key; optional, and only the controller's adapter passes it. - Sealed string - // Why is the words of a CredentialNotGiven or CredentialRefused. - Why string -} - -// Run is the build an assignment names: the commit it is of and the build made from it (chain.Ref). -type Run struct { - Commit string - Build string -} diff --git a/go/compose/judge/judge.go b/go/compose/judge/judge.go deleted file mode 100644 index 8d68a39..0000000 --- a/go/compose/judge/judge.go +++ /dev/null @@ -1,372 +0,0 @@ -// Package judge is how the seat mesh-placement's holder judges a proposal (novox/hq ADR 0567 §3, §4): which nodes must -// answer it and what it changes on each (Intended), and the verdict of their answers (JudgeAnswers). Moved here from -// the assigner, where it judged a catalogue pull request's placement (ADR 0559 §5), with a proposal in place of a pull -// request's change. It holds no bus client and no timer: the holder gives it what it read. -package judge - -import ( - "encoding/json" - "fmt" - "reflect" - "sort" - "strconv" - "strings" - "time" - - "git.novox.be/novox/mesh-sdk/go/compose" - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -// AnswerBound is how long the holder waits for a node's answer after its candidate was written (ADR 0567 §4): the time to -// live of the node's pulse key (delivery.PulseAnswerKey), whose expiry names it silent, never passed. A variable so a test -// on the bus can shorten it. -var AnswerBound = 10 * time.Minute - -// Intended is, by node, the modules whose composition the proposal changes, and the nodes that must answer it (§3). -// current is the composition input with the current placement and laidInput the same with the proposal's placements -// (laid, by node; nil removes the node's key) laid over it; nodes is every node to look at (every node with a record). -// A node's set holds the modules the proposal adds, removes or whose placed entry differs on that node, and those whose -// composition differs between the two inputs (delivery.CompareComposed), the node's own named "node". Must is every node -// whose key the proposal changes and every other whose set is not empty, sorted. A node that has no key now gets an -// empty set (§7: the seed is answered as the same). A node whose compositions cannot be made on either side keeps the -// modules its entries name, said in notes; such a node is in Must only when its key changes. -func Intended(current, laidInput *compose.Input, currentPlaced map[string]delivery.Placement, laid map[string]*delivery.Placement, - nodes []string, fps map[string]string) (intended map[string]map[string]bool, must []string, notes []string) { - intended = map[string]map[string]bool{} - inMust := map[string]bool{} - all := map[string]bool{} - for _, n := range nodes { - all[n] = true - } - for n := range laid { - all[n] = true - inMust[n] = true - } - names := make([]string, 0, len(all)) - for n := range all { - names = append(names, n) - } - sort.Strings(names) - for _, node := range names { - before, placed := currentPlaced[node] - set := map[string]bool{} - after, changesKey := laid[node] - if changesKey && !placed { - intended[node] = set // a node new to placement: every answer must be the same - continue - } - if changesKey { - var afterModules []delivery.PlacedModule - if after != nil { - afterModules = after.Modules - } - entries := map[string]delivery.PlacedModule{} - for _, m := range before.Modules { - entries[m.Module] = m - } - seen := map[string]bool{} - for _, m := range afterModules { - seen[m.Module] = true - if e, ok := entries[m.Module]; !ok || !reflect.DeepEqual(e, m) { - set[m.Module] = true - } - } - for name := range entries { - if !seen[name] { - set[name] = true - } - } - } - if placed && current != nil && laidInput != nil { - was, _, _, errB := compose.CompositionsShadow(*current, node, fps) - now, _, _, errA := compose.CompositionsShadow(*laidInput, node, fps) - if errA == nil && errB == nil { - running := make([]delivery.Composed, 0, len(was)) - for _, c := range was { - running = append(running, c) - } - cmp := delivery.CompareComposed(now, running) - for _, d := range cmp.Differs { - set[d.Module] = true - } - for _, d := range cmp.Added { - set[d.Module] = true - } - for _, m := range cmp.Removed { - set[m] = true - } - } else if changesKey { - notes = append(notes, fmt.Sprintf("what the proposal changes on %s is read from its placement alone: its "+ - "compositions cannot be made (now: %v; proposed: %v)", node, errB, errA)) - } - } - if len(set) > 0 { - inMust[node] = true - } - if inMust[node] { - intended[node] = set - } - } - for n := range inMust { - must = append(must, n) - } - sort.Strings(must) - return intended, must, notes -} - -// JudgeAnswers is the verdict of a proposal's answers (§4). answers is by node its HaveNode.Candidates entry for the -// proposal (nil when it has none) and revisions by node the revision of its candidate key: an answer counts when it -// names the proposal and that revision. It fails on a refusal by a node that must answer, and on every node of Must with -// no answer that counts once its pulse key has ended (pulsing false: the bound's expiry, heard on the bus); while it -// stands it waits for them. -// A difference fails nothing: one Intended computed is listed under Intended, one it did not under Unintended, both -// shown in the ask's brief. The seed alone fails on any difference. -func JudgeAnswers(p delivery.PlacementProposal, intended map[string]map[string]bool, answers map[string]*delivery.CandidateAnswer, - revisions map[string]uint64, online map[string]bool, pulsing map[string]bool, now time.Time) delivery.PlacementVerdict { - v := delivery.PlacementVerdict{At: now.UTC(), Intended: map[string][]string{}, Unintended: map[string][]string{}, Carried: map[string][]string{}, - Answers: map[string]delivery.CandidateAnswer{}} - for node, set := range intended { - for m := range set { - v.Intended[node] = append(v.Intended[node], m) - } - sort.Strings(v.Intended[node]) - } - must := p.Must - if len(must) == 0 { - must = p.Nodes() - } - for _, node := range must { - a := answers[node] - if a == nil || a.Proposal != p.ID || a.Revision == 0 || a.Revision != revisions[node] { - v.Silent = append(v.Silent, node) - continue - } - v.Answers[node] = *a - if a.Refused != "" { - v.Fails = append(v.Fails, fmt.Sprintf("%s cannot compose the proposal: %s", node, a.Refused)) - continue - } - for _, r := range a.Refusals { - v.Fails = append(v.Fails, fmt.Sprintf("%s refuses the proposal for %s: %s", node, r.Module, r.Why)) - } - want := intended[node] - differs := func(module, words string) { - if p.Seed { - // The seed carries what its nodes found, each named in the brief for the operator who approves it. - v.Carried[node] = append(v.Carried[node], words) - return - } - if !want[module] { - v.Unintended[node] = append(v.Unintended[node], module) - } - } - for _, d := range a.Differs { - differs(d.Module, differenceWords(d)) - } - for _, d := range a.Added { - differs(d.Module, "would run "+d.Module) - } - for _, m := range a.Removed { - differs(m, "would stop running "+m) - } - sort.Strings(v.Unintended[node]) - } - if len(v.Intended) == 0 { - v.Intended = nil - } - if len(v.Unintended) == 0 { - v.Unintended = nil - } - if len(v.Carried) == 0 { - v.Carried = nil - } - if len(v.Answers) == 0 { - v.Answers = nil - } - var waiting, gone []string - for _, node := range v.Silent { - named := node - if online != nil && !online[node] { - named += " (offline)" - } - if pulsing[node] { - waiting = append(waiting, named) - } else { - gone = append(gone, named) - } - } - if len(waiting) > 0 { - v.Notes = append(v.Notes, fmt.Sprintf("waits for the answers of %s, each for %s from its candidate's write", - strings.Join(waiting, ", "), AnswerBound)) - } - if len(gone) > 0 { - v.Fails = append(v.Fails, fmt.Sprintf("%s did not answer within %s of its candidate's write; judged again when it "+ - "answers", strings.Join(gone, ", "), AnswerBound)) - } - switch { - case len(v.Fails) > 0: - v.State = delivery.VerdictFail - case len(waiting) > 0: - v.State = delivery.VerdictWaits - default: - v.State = delivery.VerdictPass - } - return v -} - -// Brief is the verdict as the ask's brief shows it, by node (§5): what the proposal changes and what each node answered -// beside it, marked, and what stays held as the node holds it today, with the proposal's why. -func Brief(p delivery.PlacementProposal, v delivery.PlacementVerdict) string { - var b strings.Builder - fmt.Fprintf(&b, "%s (proposed by %s)", p.Why, p.By) - nodes := map[string]bool{} - for n := range v.Intended { - nodes[n] = true - } - for n := range v.Unintended { - nodes[n] = true - } - for _, n := range p.Nodes() { - nodes[n] = true - } - for n, a := range v.Answers { - if len(a.Held) > 0 { - nodes[n] = true - } - } - for n := range v.Carried { - nodes[n] = true - } - names := make([]string, 0, len(nodes)) - for n := range nodes { - names = append(names, n) - } - sort.Strings(names) - for _, n := range names { - fmt.Fprintf(&b, "; %s:", n) - if m := v.Intended[n]; len(m) > 0 { - fmt.Fprintf(&b, " changes %s", strings.Join(m, ", ")) - } else { - b.WriteString(" composes the same") - } - if m := v.Unintended[n]; len(m) > 0 { - fmt.Fprintf(&b, "; also differs, not computed: %s", strings.Join(m, ", ")) - } - if m := v.Carried[n]; len(m) > 0 { - fmt.Fprintf(&b, "; the seed carries: %s", strings.Join(m, "; ")) - } - if held := v.Answers[n].Held; len(held) > 0 { - var names []string - for _, h := range held { - today := h.Today - if today == "" { - today = "held" - } - names = append(names, h.Module+" ("+today+")") - } - fmt.Fprintf(&b, "; stays as today: %s", strings.Join(names, ", ")) - } - } - return b.String() -} - -// differenceWords names one module's difference: the module, the first field that differs in it with its value as it -// runs and as the seed composes it, each cut to about 60 characters ("(value cut)" where a body shown is cut before the -// field), and how many fields more; or the resource and why when no body is shown. -func differenceWords(d delivery.ModuleDiffers) string { - for _, b := range d.Bodies { - if len(b.Paths) == 0 { - continue - } - path := b.Paths[0] - words := fmt.Sprintf("%s: %s %s → %s", d.Module, fieldName(b.Running, b.Composed, path), - valueAt(b.Running, path), valueAt(b.Composed, path)) - if n := len(b.Paths) - 1; n > 0 { - words += fmt.Sprintf(" (and %d more fields)", n) - } - return words - } - if d.Resource != "" { - return fmt.Sprintf("%s: %s (%s)", d.Module, d.Resource, d.Why) - } - return fmt.Sprintf("%s: %s", d.Module, d.Why) -} - -// shownValue bounds a value named in a brief. -const shownValue = 60 - -// walk follows a differing path (delivery.DifferingPaths: keys joined by dots, a key may hold dots, and a JSON value -// written in a string is read as JSON) through a body, answering the keys it passed and the value at its end. -func walk(body, path string) (keys []string, value any, ok bool) { - var v any - if json.Unmarshal([]byte(body), &v) != nil { - return nil, nil, false - } - rest := path - for rest != "" { - if s, isString := v.(string); isString { - var inner any - if json.Unmarshal([]byte(s), &inner) != nil { - return nil, nil, false - } - v = inner - } - switch t := v.(type) { - case map[string]any: - best := "" - for k := range t { - if (rest == k || strings.HasPrefix(rest, k+".")) && len(k) > len(best) { - best = k - } - } - if best == "" { - return nil, nil, false - } - keys, v, rest = append(keys, best), t[best], strings.TrimPrefix(strings.TrimPrefix(rest, best), ".") - case []any: - seg, after, _ := strings.Cut(rest, ".") - n, err := strconv.Atoi(seg) - if err != nil || n < 0 || n >= len(t) { - return nil, nil, false - } - keys, v, rest = append(keys, seg), t[n], after - default: - return nil, nil, false - } - } - return keys, v, true -} - -// valueAt is the value at path in a shown body, cut to shownValue, or why it is not shown. -func valueAt(body, path string) string { - if body == "" { - return "(none)" - } - _, v, ok := walk(body, path) - if !ok { - return "(value cut)" - } - s, isString := v.(string) - if !isString { - raw, _ := json.Marshal(v) - s = string(raw) - } - if r := []rune(s); len(r) > shownValue { - s = string(r[:shownValue-1]) + "…" - } - return s -} - -// fieldName is the differing field as a person reads it: its parent's name and its own, `PARENT[key]`, from whichever -// body reaches it; the path's end when neither does. -func fieldName(running, composed, path string) string { - for _, body := range []string{running, composed} { - if keys, _, ok := walk(body, path); ok && len(keys) > 0 { - if len(keys) == 1 { - return keys[0] - } - return keys[len(keys)-2] + "[" + keys[len(keys)-1] + "]" - } - } - return path -} diff --git a/go/compose/judge/judge_test.go b/go/compose/judge/judge_test.go deleted file mode 100644 index 701f197..0000000 --- a/go/compose/judge/judge_test.go +++ /dev/null @@ -1,89 +0,0 @@ -package judge - -import ( - "slices" - "strings" - "testing" - "time" - - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -// ADR 0567 §3, §4 (rows moved from the assigner's check of a pull request's placement, ADR 0559 §5): what a proposal -// changes by node, and the verdict of the answers: differences shown and failing nothing, a refusal and a silent node -// failing, a later answer judging again, the seed failing on any difference. -func TestJudge(t *testing.T) { - current := map[string]delivery.Placement{"anchor": {Node: "anchor", Modules: []delivery.PlacedModule{{Module: "mail"}}}, "server": {Node: "server"}} - laid := map[string]*delivery.Placement{ - "anchor": {Node: "anchor", Modules: []delivery.PlacedModule{{Module: "mail", Ports: map[string]int{"25": 2525}}, {Module: "web"}}}, - "laptop": {Node: "laptop", Modules: []delivery.PlacedModule{{Module: "web"}}}} - got, must, _ := Intended(nil, nil, current, laid, []string{"anchor", "laptop", "server"}, nil) - if !got["anchor"]["mail"] || !got["anchor"]["web"] || len(got["laptop"]) != 0 || !slices.Equal(must, []string{"anchor", "laptop"}) { - t.Errorf("intended = %v, must %v: want anchor's mail and web, nothing on the laptop that has no key, server untouched", got, must) - } - - at := time.Date(2026, 10, 27, 2, 0, 0, 0, time.UTC) - p := delivery.PlacementProposal{ID: "p-one", By: "op", Why: "move web", Written: at, Base: map[string]uint64{"anchor": 3, "laptop": 0}, - Must: []string{"anchor", "laptop"}} - revisions := map[string]uint64{"anchor": 7, "laptop": 8} - intended := map[string]map[string]bool{"anchor": {"web": true}} - same := func(rev uint64) *delivery.CandidateAnswer { - return &delivery.CandidateAnswer{Proposal: "p-one", Revision: rev, Verdict: delivery.CandidateSame, Same: []string{"mail"}} - } - differs := func(rev uint64, module string) *delivery.CandidateAnswer { - return &delivery.CandidateAnswer{Proposal: "p-one", Revision: rev, Verdict: delivery.CandidateDiffers, - Differs: []delivery.ModuleDiffers{{Module: module, Why: "the port differs"}}} - } - for _, row := range []struct { - name string - seed bool - answers map[string]*delivery.CandidateAnswer - pulsing map[string]bool - state string - words string - marked string - }{ - {"all the same", false, map[string]*delivery.CandidateAnswer{"anchor": same(7), "laptop": same(8)}, nil, delivery.VerdictPass, "", ""}, - {"a module added where intended", false, map[string]*delivery.CandidateAnswer{"anchor": {Proposal: "p-one", Revision: 7, Verdict: delivery.CandidateDiffers, - Added: []delivery.ModuleAdded{{Module: "web", Unissued: []string{"its own secret"}}}}, "laptop": same(8)}, nil, delivery.VerdictPass, "", ""}, - {"a difference not computed passes, marked", false, map[string]*delivery.CandidateAnswer{"anchor": differs(7, "mail"), "laptop": same(8)}, nil, delivery.VerdictPass, "", "mail"}, - {"the seed carries a difference, named", true, map[string]*delivery.CandidateAnswer{"anchor": same(7), "laptop": {Proposal: "p-one", Revision: 8, - Verdict: delivery.CandidateDiffers, Differs: []delivery.ModuleDiffers{{Module: "node-tools", Resource: "node-tools.runtime", Why: "the resource differs", - Bodies: []delivery.ResourceBodies{{ID: "node-tools.runtime", Paths: []string{"env.MESH_OWNED_PATHS./etc/resolv.conf", "env.MESH_TOOL_MODULES"}, - Running: `{"env":{"MESH_OWNED_PATHS":"{\"/etc/hosts\":\"hostname\",\"/etc/resolv.conf\":\"networkmanager (node-uplink)\"}"}}`, - Composed: `{"env":{"MESH_OWNED_PATHS":"{\"/etc/hosts\":\"hostname\",\"/etc/resolv.conf\":\"systemd-resolved (node-resolver)\"}","MESH_TOOL_MODULES":"… (cut at 8192 bytes)`}}}}}}, nil, delivery.VerdictPass, "", ""}, - {"a refusal", false, map[string]*delivery.CandidateAnswer{"anchor": same(7), "laptop": {Proposal: "p-one", Revision: 8, Verdict: delivery.CandidateRefused, - Refusals: []delivery.ModuleRefused{{Module: "web", Why: "no own secret"}}}}, nil, delivery.VerdictFail, "laptop refuses", ""}, - {"a node not answering, within its bound", false, map[string]*delivery.CandidateAnswer{"anchor": same(7)}, map[string]bool{"laptop": true}, delivery.VerdictWaits, "laptop (offline)", ""}, - {"an answer of an earlier revision, past its bound", false, map[string]*delivery.CandidateAnswer{"anchor": same(6), "laptop": same(8)}, nil, delivery.VerdictFail, "anchor did not answer", ""}, - {"a module held as today passes, shown in the brief", false, map[string]*delivery.CandidateAnswer{"anchor": same(7), "laptop": {Proposal: "p-one", Revision: 8, - Verdict: delivery.CandidateSame, Held: []delivery.ModuleRefused{{Module: "spiegel", Why: "a required value is unset"}}}}, nil, delivery.VerdictPass, "", ""}, - {"its later answer", false, map[string]*delivery.CandidateAnswer{"anchor": same(7), "laptop": same(8)}, nil, delivery.VerdictPass, "", ""}, - } { - q := p - q.Seed = row.seed - v := JudgeAnswers(q, intended, row.answers, revisions, map[string]bool{"anchor": true}, row.pulsing, at) - all := strings.Join(append(append([]string{}, v.Fails...), v.Notes...), "; ") - if v.State != row.state || !strings.Contains(all, row.words) { - t.Errorf("%s: %s, %q", row.name, v.State, all) - } - if row.seed && row.state == delivery.VerdictPass && !strings.Contains(Brief(q, v), "the seed carries: node-tools: MESH_OWNED_PATHS[/etc/resolv.conf] networkmanager (node-uplink) → (value cut) (and 1 more fields)") { - t.Errorf("%s: the brief does not name it: %s", row.name, Brief(q, v)) - } - if row.seed && row.state == delivery.VerdictPass { - whole := row.answers["laptop"].Differs[0] - whole.Bodies = []delivery.ResourceBodies{{ID: "r", Paths: []string{"env.MESH_OWNED_PATHS./etc/resolv.conf"}, - Running: `{"env":{"MESH_OWNED_PATHS":"{\"/etc/resolv.conf\":\"networkmanager (node-uplink)\"}"}}`, - Composed: `{"env":{"MESH_OWNED_PATHS":"{\"/etc/resolv.conf\":\"systemd-resolved (node-resolver)\"}"}}`}} - if got := differenceWords(whole); got != "node-tools: MESH_OWNED_PATHS[/etc/resolv.conf] networkmanager (node-uplink) → systemd-resolved (node-resolver)" { - t.Errorf("the difference is named %q", got) - } - } - if strings.HasPrefix(row.name, "a module held as today") && !strings.Contains(Brief(q, v), "stays as today: spiegel (held)") { - t.Errorf("%s: the brief does not show it: %s", row.name, Brief(q, v)) - } - if row.marked != "" && (!slices.Contains(v.Unintended["anchor"], row.marked) || !strings.Contains(Brief(q, v), "not computed: "+row.marked)) { - t.Errorf("%s: the difference is not marked: %+v, %s", row.name, v.Unintended, Brief(q, v)) - } - } -} diff --git a/go/compose/network.go b/go/compose/network.go deleted file mode 100644 index 6998bb8..0000000 --- a/go/compose/network.go +++ /dev/null @@ -1,307 +0,0 @@ -package compose - -import ( - "errors" - "fmt" - "slices" - "sort" - "strconv" - "strings" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/overlay" -) - -// The private network: who is on it, where, and what they are called. Ported from mesh-controller -// cmd/mesh-controller/network.go at 6c2a8d62. - -// DefaultOverlayCIDR is the range the mesh allocates from when nothing says another. -const DefaultOverlayCIDR = "10.42.0.0/16" - -// composer is one composition over an Input: it reads Input, changes nothing in it, and keeps what the controller -// said on its standard error as lines to return. -type composer struct { - in Input - said []string - seen map[string]bool - // unhostable is, from the last look at the rest of the mesh, each module assigned to another node that this - // composition finds that node cannot run: what it would have offered is missing here (issue 1126, probe #532). - unhostable []string - - // What one composition looks at again and again, each worked out once over its Input, which it never changes (issue - // 1954: a node's plans resolved every machine of the mesh some two hundred times, 11.7 s of CPU for novox): who is - // on the private network, and the rest of the mesh as each node sees it. - onNetwork []Node - onNetworkSet bool - rest map[string]restOf - plans map[string]planOf -} - -// restOf is theRestOfTheMesh's answer for one excluded node, kept: what it found unhostable beside it. -type restOf struct { - world catalogue.World - passes Passes - left []LeftOut - unhostable []string - err error -} - -func newComposer(in Input) *composer { return &composer{in: in, seen: map[string]bool{}} } - -// say keeps a line the controller would have written to its log, once. -func (c *composer) say(format string, args ...any) { - line := fmt.Sprintf(format, args...) - if c.seen[line] { - return - } - c.seen[line] = true - c.said = append(c.said, line) -} - -// suffix is what the mesh's internal names end in: the Input's, and overlay.DefaultSuffix when it gives none. The package never calls -// overlay.Suffix or overlay.InternalName, which read the environment, so the assigner and the controller compose the same names -// from the same Input. -func (c *composer) suffix() string { - if v := strings.TrimPrefix(strings.TrimSpace(c.in.InternalSuffix), "."); v != "" { - return v - } - return overlay.DefaultSuffix -} - -// internalName is a node's name inside the mesh. -func (c *composer) internalName(node string) string { return node + "." + c.suffix() } - -// saidBeside is the lines already said and then every line the composer kept, once each, in the order they were found. -func (c *composer) saidBeside(lines []string) []string { - for _, l := range c.said { - if !slices.Contains(lines, l) { - lines = append(lines, l) - } - } - return lines -} - -// overlayRange is the range the mesh allocates node addresses from: the adopted tunnel's first (novox/hq ADR 0105), -// then the range genesis was told, then the default. -func (c *composer) overlayRange() string { - if a := c.in.Adopted; a != nil && a.Adopted { - return a.Tunnel.Range - } - if v := strings.TrimSpace(c.in.OverlayCIDR); v != "" { - return v - } - return DefaultOverlayCIDR -} - -// network builds the private network over the machines that resolved the module for it. A machine is on it because it -// was given the module, or because it is joining with a key and an address (novox/hq ADR 0169), or because its own set -// could not be resolved and it holds a place (a key and an address): that place is its last good one on the network, and -// it does not change because its modules failed, so every other machine keeps its path to it -- the hub above all -- and -// only its own declaration is refused (issue 1833). A machine refused with no place is left off. -func (c *composer) network(on map[string]bool, refused map[string]string) (*overlay.Generator, error) { - tunnels := c.in.Tunnels - carried := c.in.CarriedPeers - isJoining := map[string]bool{} - for _, name := range c.in.Joining { - isJoining[name] = true - } - var nodes []overlay.Node - for _, node := range c.in.sorted() { - p := node.Place - placed := p.Key != "" && p.Address != "" - _, wasRefused := refused[node.Name] - if !on[node.Name] && !((isJoining[node.Name] || wasRefused) && placed) { - continue - } - if !on[node.Name] && wasRefused && !isJoining[node.Name] { - c.say("%s could not be resolved and keeps its place on the private network at %s, so the others keep their path to it", - node.Name, p.Address) - } - n := overlay.Node{ - Name: node.Name, Key: p.Key, Endpoint: p.Endpoint, - Site: p.Site, Hub: p.Hub, Address: p.Address, - } - if t, takes := tunnels[node.Name]; takes && t.NodeAdopted { - // Only an adopted node is told to take the found unit over. A hub whose placement disagrees with the tunnel is - // refused, not composed. - if wrong := disagrees(p, t.Tunnel); wrong != "" { - return nil, fmt.Errorf("%s takes over the tunnel on %s and its placement disagrees with it: %s. "+ - "Re-place it — `overlay place %s --hub --endpoint :%d …` — and push again; "+ - "nothing was composed", node.Name, t.Interface, wrong, node.Name, t.Port) - } - n.TakesOver = &overlay.TakeOver{Interface: t.Interface, Unit: t.Unit, Config: t.Config, Port: t.Port, MTU: t.MTU} - } - if p.Hub { - for _, cp := range carried { - n.Carried = append(n.Carried, overlay.Carried{Key: cp.PublicKey, Address: cp.Address}) - } - } - nodes = append(nodes, n) - } - if len(nodes) == 0 { - // Nobody was given it: an empty network is a legitimate mesh, not a broken one. - return overlay.Empty(), nil - } - g, err := overlay.From(nodes, c.overlayRange(), "") - if err != nil && len(refused) > 0 { - // The network is missing something, and some machines could not be resolved at all: almost always the same fact. - var who []string - for name, why := range refused { - who = append(who, fmt.Sprintf(" %s: %s", name, why)) - } - sort.Strings(who) - return nil, fmt.Errorf("%w\n\nand %d node(s) could not be resolved at all, which is "+ - "probably why:\n%s", err, len(refused), strings.Join(who, "\n")) - } - return g, err -} - -// Graph is the whole mesh's network, for showing it: the nodes on it and each one's peers. -func Graph(in Input) ([]overlay.Node, overlay.Graph, error) { - c := newComposer(in) - on, refused, err := c.whoResolves(overlay.Requirement) - if err != nil { - return nil, nil, err - } - g, err := c.network(on, refused) - if err != nil { - return nil, nil, err - } - return g.Nodes(), g.Graph(), nil -} - -// whoResolves is the machines whose resolution answers a requirement, and why the others did not: by what a module -// provides, not by its name, resolved rather than read from the assignments. -func (c *composer) whoResolves(requirement string) (map[string]bool, map[string]string, error) { - on := map[string]bool{} - // Why a node could not be resolved, kept rather than raised: one broken node must not stop the rest being described. - refused := map[string]string{} - for _, n := range c.in.sorted() { - resolved, err := c.planFor(n.Name) - switch { - case unresolvable(err): - refused[n.Name] = err.Error() - continue - case err != nil: - // Not a node that does not resolve: a question that went unanswered. - return nil, nil, fmt.Errorf("whether %s answers %q cannot be read: %w", n.Name, requirement, err) - } - for _, m := range resolved.Plan.Modules { - for _, offered := range m.Offers() { - if offered == requirement { - on[n.Name] = true - } - } - } - } - return on, refused, nil -} - -// Generators is what every node's declaration is composed with, over the whole mesh: the private network's generator -// (one generator now; the names and a resolver's zone file are facts a module asks for in its manifest). -func Generators(in Input) (map[string]catalogue.Generator, error) { - c := newComposer(in) - return c.generators() -} - -func (c *composer) generators() (map[string]catalogue.Generator, error) { - on, refused, err := c.whoResolves(overlay.Addressing) - if err != nil { - return nil, err - } - net, err := c.network(on, refused) - if err != nil { - return nil, err - } - return map[string]catalogue.Generator{overlay.Name: net}, nil -} - -// disagrees says how a node's placement differs from the tunnel it takes over — its address not the tunnel's, its -// endpoint not on the tunnel's port — or nothing when both agree. -func disagrees(p Place, t Tunnel) string { - var wrong []string - want := t.Address - if i := strings.Index(want, "/"); i >= 0 { - want = want[:i] - } - if p.Address != want { - wrong = append(wrong, fmt.Sprintf("its address is %s and the tunnel's is %s", orNothing(p.Address), want)) - } - if p.Reachable() && portOfEndpoint(p.Endpoint) != strconv.Itoa(t.Port) { - wrong = append(wrong, fmt.Sprintf("its endpoint %s is not on the tunnel's port %d", p.Endpoint, t.Port)) - } - return strings.Join(wrong, "; ") -} - -func orNothing(s string) string { - if s == "" { - return "unset" - } - return s -} - -// portOfEndpoint is the port in host:port, or empty. -func portOfEndpoint(endpoint string) string { - if i := strings.LastIndex(endpoint, ":"); i >= 0 { - return endpoint[i+1:] - } - return "" -} - -// whereEveryoneIs is each machine's name on the private network, for the ones on it. Resolved without consulting the -// rest of the mesh (it is called from that path), and refused without the catalogue. -func (c *composer) whereEveryoneIs() (map[string]string, error) { - if c.in.Catalogue == nil { - return nil, errors.New("asked where everyone is without the catalogue, which cannot be answered") - } - out := map[string]string{} - for _, p := range c.onTheNetwork() { - out[p.Name] = c.internalName(p.Name) - } - return out, nil -} - -// onTheNetwork is every placed machine that resolves the private network: has an address AND runs what puts it there -// (novox/hq issue 079). A machine that does not resolve keeps its place when it holds one (a key and an address), by the -// same rule as network(), so its name and its address stay where the tunnel to it does (issue 1833); one without is not -// counted, and said. -func (c *composer) onTheNetwork() []Node { - if !c.onNetworkSet { - c.onNetwork, c.onNetworkSet = c.onTheNetworkNow(), true - } - return slices.Clone(c.onNetwork) -} - -// onTheNetworkNow is onTheNetwork worked out: every placed machine's set resolved. -func (c *composer) onTheNetworkNow() []Node { - var out []Node - for _, n := range c.in.sorted() { - if n.Place.Address == "" { - continue - } - if len(n.Assigned) == 0 { - continue - } - got, err := catalogue.Resolve(c.in.Catalogue, n.Assigned, - catalogue.Node{Name: n.Name, Site: n.Place.Site, Capabilities: n.Capabilities}, - catalogue.World{Unchecked: true, Holdings: c.in.Holdings}) - if err != nil { - if n.Place.Key != "" { - c.say("%s could not be resolved and keeps its place on the private network at %s, so the others keep their path to it", - n.Name, n.Place.Address) - out = append(out, n) - continue - } - c.say("%s is not counted as on the network: it does not resolve: %v", n.Name, err) - continue - } - for _, m := range got.Modules { - for _, offered := range m.Offers() { - if offered == overlay.Requirement { - out = append(out, n) - } - } - } - } - return out -} diff --git a/go/compose/network_test.go b/go/compose/network_test.go deleted file mode 100644 index ff73e08..0000000 --- a/go/compose/network_test.go +++ /dev/null @@ -1,306 +0,0 @@ -package compose - -import ( - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/overlay" -) - -// onTheTunnel makes the hub of an Input what mesh-controller's network_test.go makes of anchor in -// TestTheOverlaysRangeIsTheAdoptedTunnels: adopted, enrolled with the found tunnel's key, and presenting the tunnel -// (Tunnel{wg0, wg-quick@wg0, port 51900, 192.0.2.1/24, range 192.0.2.0/24, one peer PEER-TWO}). Placed on it when -// replaced is true: its address the tunnel's, its endpoint on the tunnel's port, as `overlay place anchor --endpoint -// 198.51.100.10:51900 --site hosting --hub` leaves it. -func onTheTunnel(t *testing.T, in *Input, replaced bool) { - t.Helper() - const key = "THE-TUNNELS-KEY=========================" - tunnel := Tunnel{Interface: "wg0", Unit: "wg-quick@wg0", Config: "/etc/wireguard/wg0.conf", Port: 51900, - Address: "192.0.2.1/24", Range: "192.0.2.0/24", PublicKey: key, - Peers: []TunnelPeer{{PublicKey: "PEER-TWO", Address: "192.0.2.2"}}} - hub := nodeOf(t, in, "anchor") - hub.Adopted = true - hub.Place.Key = key - if replaced { - hub.Place.Address, hub.Place.Endpoint, hub.Place.Site = "192.0.2.1", "198.51.100.10:51900", "hosting" - } - in.Tunnels = map[string]FoundTunnel{"anchor": {Tunnel: tunnel, NodeAdopted: true}} - in.Adopted = &AdoptedTunnel{Tunnel: tunnel, Hub: "anchor", Adopted: true} - in.CarriedPeers = []CarriedPeer{{PublicKey: "PEER-TWO", Address: "192.0.2.2"}} -} - -// novox/hq ADR 0105 (mesh-controller network_test.go TestTheOverlaysRangeIsTheAdoptedTunnels): the range every address is -// composed from is the adopted tunnel's, read from the tunnel the hub holds, never stored anywhere else. The environment -// value of the controller is Input.OverlayCIDR here. -func TestTheOverlaysRangeIsTheAdoptedTunnels(t *testing.T) { - in := aMesh(t) - in.OverlayCIDR = "10.99.0.0/16" - if got := newComposer(in).overlayRange(); got != "10.99.0.0/16" { - t.Fatalf("without an adopted tunnel the range is not what genesis said: %q", got) - } - in.OverlayCIDR = "" - if got := newComposer(in).overlayRange(); got != DefaultOverlayCIDR { - t.Fatalf("with nothing said the range is %q, not the default", got) - } - in.OverlayCIDR = "10.99.0.0/16" - onTheTunnel(t, &in, true) - if got := newComposer(in).overlayRange(); got != "192.0.2.0/24" { - t.Fatalf("with an adopted tunnel the range is %q, not the tunnel's", got) - } - // A tunnel the hub found and did not take (its key is not the tunnel's) does not give its range. - in.Adopted.Adopted = false - if got := newComposer(in).overlayRange(); got != "10.99.0.0/16" { - t.Fatalf("a tunnel the hub did not take gave its range: %q", got) - } - in.Adopted.Adopted = true - - // And the hub's declaration carries the peer and the takeover. - nodes, computed, err := Graph(in) - if err != nil { - t.Fatal(err) - } - var hubNode overlay.Node - for _, n := range nodes { - if n.Name == "anchor" { - hubNode = n - } - } - if hubNode.TakesOver == nil || hubNode.TakesOver.Unit != "wg-quick@wg0" { - t.Errorf("the hub is not told to take over the found tunnel: %+v", hubNode) - } - carried := false - for _, p := range computed["anchor"] { - if p.Key == "PEER-TWO" && p.Allowed == "192.0.2.2/32" { - carried = true - } - } - if !carried { - t.Errorf("the hub's peer list does not carry the tunnel's peer: %+v", computed["anchor"]) - } -} - -// A takeover is composed only for a hub whose placement agrees with the tunnel (mesh-controller network_test.go -// TestATakeoverIsNotComposedForAHubPlacedOffItsTunnel): an address or an endpoint port that differs would have the host stop -// the found interface and raise the mesh's where no peer is listening. -func TestATakeoverIsNotComposedForAHubPlacedOffItsTunnel(t *testing.T) { - in := aMesh(t) - onTheTunnel(t, &in, false) - // aMesh placed anchor at 10.77.0.1 on :51820, the record of a hub placed before it took the tunnel over. - _, _, err := Graph(in) - if err == nil { - t.Fatal("a takeover was composed for a hub whose address and port are not the tunnel's") - } - for _, want := range []string{"10.77.0.1", "192.0.2.1", "51820", "51900", "overlay place anchor"} { - if !strings.Contains(err.Error(), want) { - t.Errorf("the refusal does not say %q: %v", want, err) - } - } - // Re-placed on the tunnel, it composes. - onTheTunnel(t, &in, true) - if _, _, err := Graph(in); err != nil { - t.Fatalf("re-placed on the tunnel, the graph still refuses: %v", err) - } -} - -// 1360/AC2: the generator gives the addresses, peers and names of aMesh: anchor the hub at 10.77.0.1, laptop at 10.77.0.2 -// peering through it, both written for their machines. -func TestTheGeneratorGivesTheMeshItsAddressesAndPeers(t *testing.T) { - in := aMesh(t) - gens, err := Generators(in) - if err != nil { - t.Fatal(err) - } - g, ok := gens[overlay.Name].(*overlay.Generator) - if !ok { - t.Fatalf("the generator is %T", gens[overlay.Name]) - } - addr := map[string]string{} - for _, n := range g.Nodes() { - addr[n.Name] = n.Address - } - if addr["anchor"] != "10.77.0.1" || addr["laptop"] != "10.77.0.2" || len(addr) != 2 { - t.Fatalf("the addresses are %v", addr) - } - peers := g.Graph() - if len(peers["laptop"]) != 1 || peers["laptop"][0].Name != "anchor" || peers["laptop"][0].Endpoint != "anchor.example:51820" { - t.Errorf("laptop's peers are %+v, want the hub", peers["laptop"]) - } - if len(peers["anchor"]) != 1 || peers["anchor"][0].Name != "laptop" || peers["anchor"][0].Allowed != "10.77.0.2/32" { - t.Errorf("anchor's peers are %+v, want laptop", peers["anchor"]) - } - for _, node := range []string{"anchor", "laptop"} { - res, on, err := g.Resources(node) - if err != nil || !on || len(res) == 0 { - t.Errorf("%s is given no network: %v %v %d", node, on, err, len(res)) - } - } -} - -// 1360/AC3: a node not on the private network (it runs nothing that puts it there) gets no address and no network, and the -// one that stayed keeps its own. -func TestANodeNotOnThePrivateNetworkGetsNoAddressAndNoNetwork(t *testing.T) { - in := aMesh(t) - nodeOf(t, &in, "laptop").Assigned = nil // it keeps its place and its address, and stops running the network - gens, err := Generators(in) - if err != nil { - t.Fatal(err) - } - g := gens[overlay.Name].(*overlay.Generator) - if len(g.Nodes()) != 1 || g.Nodes()[0].Name != "anchor" { - t.Fatalf("the network is over %+v, want anchor alone", g.Nodes()) - } - if res, on, err := g.Resources("laptop"); on || err != nil || len(res) != 0 { - t.Errorf("a machine that left the network is given one: %v %v %v", res, on, err) - } - if _, in := g.Graph()["laptop"]; in { - t.Error("a machine that left the network has peers") - } -} - -// 1360/AC3: a node whose resolution is refused is said with today's words: the network missing its hub names the -// machine that could not be resolved, which is almost always why. The hub here holds no complete place (no key): a refused -// machine with one keeps it on the network (issue 1833), so only one without is missing from it. -func TestARefusedNodeIsSaidWhenTheNetworkCannotBeComposed(t *testing.T) { - in := aMesh(t) - nodeOf(t, &in, "anchor").Place.Key = "" - one := catalogue.Manifest{Module: "rival-one", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}} - two := catalogue.Manifest{Module: "rival-two", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}} - in.Catalogue["rival-one"], in.Catalogue["rival-two"] = one, two - hub := nodeOf(t, &in, "anchor") - hub.Assigned = append(hub.Assigned, "rival-one", "rival-two") // its own set is incoherent: the hub does not resolve - _, err := Generators(in) - if err == nil { - t.Fatal("a network without its hub was composed") - } - for _, want := range []string{overlay.ErrNoHub.Error(), "and 1 node(s) could not be resolved at all, which is probably why:", " anchor: "} { - if !strings.Contains(err.Error(), want) { - t.Errorf("the refusal does not say %q:\n%v", want, err) - } - } -} - -// 1360/AC4: Generators changes nothing in its Input. -func TestGeneratorsChangesNothingInItsInput(t *testing.T) { - unchanged(t, func() Input { - in := aMesh(t) - onTheTunnel(t, &in, true) - in.Joining = []string{"laptop"} - return in - }, func(in Input) { - if _, err := Generators(in); err != nil { - t.Fatal(err) - } - }) -} - -// Each field of Input that the network reads reaches the generator: change it and the generator changes. -func TestEachFieldOfTheInputReachesTheGenerator(t *testing.T) { - addressesOf := func(in Input) map[string]string { - t.Helper() - gens, err := Generators(in) - if err != nil { - t.Fatalf("%v", err) - } - out := map[string]string{} - for _, n := range gens[overlay.Name].(*overlay.Generator).Nodes() { - out[n.Name] = n.Address - } - return out - } - base := addressesOf(aMesh(t)) - if len(base) != 2 { - t.Fatalf("the base mesh has %v", base) - } - t.Run("Catalogue: without the module offering it nobody is on the network", func(t *testing.T) { - in := aMesh(t) - // The module stays known, so every machine still resolves: a machine that does not resolve keeps its place (issue 1833). - in.Catalogue = map[string]catalogue.Manifest{overlay.Name: {Module: overlay.Name, Version: "1"}} - if got := addressesOf(in); len(got) != 0 { - t.Errorf("got %v", got) - } - }) - t.Run("Nodes.Place: an address", func(t *testing.T) { - in := aMesh(t) - nodeOf(t, &in, "laptop").Place.Address = "10.77.0.9" - if got := addressesOf(in); got["laptop"] != "10.77.0.9" { - t.Errorf("got %v", got) - } - }) - t.Run("Nodes.Assigned", func(t *testing.T) { - in := aMesh(t) - nodeOf(t, &in, "laptop").Assigned = nil - if got := addressesOf(in); len(got) != 1 { - t.Errorf("got %v", got) - } - }) - t.Run("Nodes.Capabilities: a capability the module needs", func(t *testing.T) { - in := aMesh(t) - m := in.Catalogue[overlay.Name] - m.Capabilities = []string{"wireguard"} - in.Catalogue[overlay.Name] = m - nodeOf(t, &in, "laptop").Capabilities = map[string]bool{} - got, err := Generators(in) - if err != nil { - t.Fatal(err) - } - if n := got[overlay.Name].(*overlay.Generator).Nodes(); len(n) != 1 || n[0].Name != "anchor" { - t.Errorf("a machine without the capability is on the network: %+v", n) - } - }) - t.Run("OverlayCIDR", func(t *testing.T) { - in := aMesh(t) - in.OverlayCIDR = "10.99.0.0/16" - _, graph, err := Graph(in) - if err != nil { - t.Fatal(err) - } - found := false - for _, p := range graph["laptop"] { - found = found || p.Allowed == "10.99.0.0/16" - } - if !found { - t.Errorf("the hub's route is not the range given: %+v", graph["laptop"]) - } - }) - t.Run("Tunnels and Adopted and CarriedPeers", func(t *testing.T) { - in := aMesh(t) - onTheTunnel(t, &in, true) - nodes, graph, err := Graph(in) - if err != nil || len(nodes) != 2 { - t.Fatalf("%v %v", nodes, err) - } - if len(graph["anchor"]) < 2 { - t.Errorf("the hub's peers do not include the carried one: %+v", graph["anchor"]) - } - }) - t.Run("Joining: a machine with a key and an address is on the network before it is anything else", func(t *testing.T) { - in := aMesh(t) - in.Nodes = append(in.Nodes, Node{Name: "newcomer", Place: Place{Key: keyOf(t, "newcomer"), Address: "10.77.0.3"}}) - if got := addressesOf(in); got["newcomer"] != "" { - t.Fatalf("a machine that runs nothing is on the network: %v", got) - } - in.Joining = []string{"newcomer"} - if got := addressesOf(in); got["newcomer"] != "10.77.0.3" { - t.Errorf("a joining machine is not on the network: %v", got) - } - }) - t.Run("Holdings: a holder on record settles a seat two assignments could hold", func(t *testing.T) { - withSeat(t) - in := aMesh(t) - for _, name := range []string{"holder-one", "holder-two"} { - in.Catalogue[name] = aBrokerClaiming(name) - } - laptop := nodeOf(t, &in, "laptop") - laptop.Assigned = append(laptop.Assigned, "holder-one", "holder-two") - // Without a key: a machine that does not resolve and holds a complete place keeps it on the network (issue 1833). - laptop.Place.Key = "" - if got := addressesOf(in); got["laptop"] != "" { - t.Fatalf("a machine whose set claims one seat twice is on the network: %v", got) - } - in.Holdings = []catalogue.Held{{Claim: "mesh-broker", Scope: catalogue.ScopeMesh, Node: "laptop", Module: "holder-one"}} - if got := addressesOf(in); got["laptop"] != "10.77.0.2" { - t.Errorf("a holder on record did not settle the seat: %v", got) - } - }) -} diff --git a/go/compose/node.go b/go/compose/node.go deleted file mode 100644 index 81e8557..0000000 --- a/go/compose/node.go +++ /dev/null @@ -1,351 +0,0 @@ -package compose - -import ( - "encoding/json" - "fmt" - "sort" - "strings" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/validate" -) - -// Declared is a declaration as a machine is sent it (the controller's sendable, sendable.go): its resources and, for an adopted -// node, its mode and which modules were taken on it (novox/hq ADR 0100), with what the composition says beside it and never puts on -// the wire. -// -// **Body is the only place the envelope is marshalled**, so the digest the mesh compares and the body validated are one. -type Declared struct { - Resources []map[string]any - // Sequence, Epoch and Generation are what the Input gave the node: zero is not sent at all. - Sequence int64 - Epoch uint64 - Generation int64 - // ReadsModules says the machine's node-engine reads a module per resource and a build per module (novox/hq issue 1145): only - // then are they sent. ModuleOf is the composer's owner of each resource id and BuildIDs the build each module carries. - ReadsModules bool - ModuleOf map[string]string - BuildIDs map[string]string - // Adoption is nil for a converged node, and then the body is byte for byte what it was before adoption existed. - Adoption *Adoption - // Filled is the path of every file this declaration hands to the provisioner, and Provisioner the account its client runs as - // (novox/hq ADR 0349): on the wire as `filled_files` and `provisioner_account`, both absent when nothing is handed. - Filled []string - Provisioner string - // LeftOut is every module of the machine's set left out because a stored setting cannot compose with its definition (novox/hq - // ADR 0163, rule 6), sorted; LeftOutWhy is why each was. - LeftOut []string - LeftOutWhy map[string]string - - // Not on the wire: - Received map[string]map[string][]catalogue.Contribution - Mesh []string - BusUsers string - // Modules are the modules this declaration names, sorted. - Modules []string - // LocalNetwork is every port admitted from the local network and what it resolved to (novox/hq ADR 0340). - LocalNetwork []catalogue.LocalNetworkPort - Withheld []catalogue.Overflow - Unbound []catalogue.Unbound - Unplaced []string - HandedOnly []string - // RequiredBy is, for each handed module only pulled in by another, the module that requires it (issue 765). - RequiredBy map[string]string - // Foreseen is every own secret composed with a stand-in, as `module/name`; StoodIn is everything the composition stood in for - // (an own secret, a grant's sealed copy, the certificate, the bus's user list); ForeseenPorts the ports the mesh may assign that - // the Input holds none for. A declaration that has any is never sent: what a send would make, named, never invented. - Foreseen []string - StoodIn []string - ForeseenPorts []string - // CoreBoxesWanted are the core credentials whose box the send must ask the vault for and keep. - CoreBoxesWanted []CoreBoxWanted - // Passes are the seats the mesh would pass on, for the caller to record, and Said the lines found while composing. - Passes Passes - Said []string -} - -// Adoption is what an adopted node is told about its mode. Taken is every module taken on it that it runs; Untaken is, for every -// module it runs that is not taken, the ids of every one of that module's resources: what the host keeps as found until the module -// is taken (ADR 0103). -type Adoption struct { - Taken []string `json:"taken"` - Untaken map[string][]string `json:"untaken,omitempty"` -} - -// Body is the declaration's bytes, as sent and as digested. -func (d Declared) Body() ([]byte, error) { - envelope := map[string]any{"declaration": 1, "resources": d.Resources} - if d.ReadsModules { - // The module of each resource the composer owns, and the build of each module (novox/hq issue 1145): to a machine that - // said it reads them, and no other. - named := make([]map[string]any, len(d.Resources)) - for n, r := range d.Resources { - module, owned := d.ModuleOf[fmt.Sprint(r["id"])] - if !owned { - named[n] = r - continue - } - with := make(map[string]any, len(r)+1) - for k, v := range r { - with[k] = v - } - with["module"] = module - named[n] = with - } - envelope["resources"] = named - if len(d.BuildIDs) > 0 { - envelope["builds"] = d.BuildIDs - } - } - if d.Adoption != nil { - envelope["adoption"] = d.Adoption - } - if d.Sequence > 0 { - envelope["sequence"] = d.Sequence - } - if d.Epoch > 0 { - envelope["epoch"] = d.Epoch - } - if len(d.LeftOut) > 0 { - envelope["left_out"] = d.LeftOut - } - if d.Generation > 0 { - envelope["generation"] = d.Generation - } - if len(d.Filled) > 0 { - envelope["filled_files"] = d.Filled - envelope["provisioner_account"] = d.Provisioner - } - // An empty declaration is deliberate: the node owns nothing the mesh put there (novox/hq issue 127), and the host refuses an - // empty body unless it is told the emptiness is meant. - if len(d.Resources) == 0 { - envelope["owns_nothing"] = true - } - return json.Marshal(envelope) -} - -// Refusals are the findings a check refuses a change for, beside the validator's problems: a consumer left out of the grants -// because its identity overflows the provision's bound, and a credential bound to another consumer (novox/hq ADR 0446 clause 1). -// Each names the consumer and the provision. -func (d Declared) Refusals() []string { - var out []string - for _, o := range d.Withheld { - out = append(out, o.String()) - } - for _, u := range d.Unbound { - out = append(out, u.String()) - } - sort.Strings(out) - return out -} - -// Compose composes one node's declaration (the issue calls it Node; the Input already has a type of that name) over an Input -- as a push would, without the sending (the controller's -// composedAndValidated in the Foreseeing mode) -- and runs the node-engine's own validator over its body. An error is that the -// node did not compose (a set that does not compose satisfies IsUnresolvable); problems are what the validator refuses, in the -// node-engine's words. It allocates, makes, keeps and writes nothing, and names what a send would do as foreseen. -func Compose(in Input, node string) (Declared, []string, error) { - return ComposeIn(in, node, Foreseeing) -} - -// ComposeIn is Compose in a chosen mode. -func ComposeIn(in Input, node string, mode Mode) (Declared, []string, error) { - c := newComposer(in) - d, err := c.declaration(node, mode) - if err != nil { - return Declared{}, nil, err - } - body, err := d.Body() - if err != nil { - return Declared{}, nil, err - } - return d, validate.Declaration(body), nil -} - -// declaration is the controller's declarationWith over the plain Input. -func (c *composer) declaration(nodeName string, mode Mode) (Declared, error) { - resolved, err := c.planFor(nodeName) - if err != nil { - return Declared{}, err - } - gens, err := c.generators() - if err != nil { - return Declared{}, err - } - rendered, err := c.render(nodeName, mode) - if err != nil { - return Declared{}, err - } - with := rendered.Rendering - with.Generators = gens - plan := resolved.Plan - composed, err := plan.Compose(with) - if err != nil { - return Declared{}, err - } - // Which ports the filter admits from the local network, and from which networks (novox/hq ADR 0340 clause 8): the same rules the - // composition rendered, never sent. - rules, err := plan.Rules(with) - if err != nil { - return Declared{}, err - } - node, err := c.in.node(nodeName) - if err != nil { - return Declared{}, err - } - out := Declared{Resources: composed.Resources, Adoption: adoptionFor(plan, node, composed), - Received: composed.Received, Mesh: with.Mesh, BusUsers: with.BusUsers, - LeftOut: sortedKeysOf(composed.LeftOut), LeftOutWhy: composed.LeftOut, - Withheld: rendered.Withheld, Unbound: rendered.Unbound, - Foreseen: composed.Foreseen, StoodIn: rendered.Foreseen, ForeseenPorts: rendered.ForeseenPorts, - Unplaced: composed.Unplaced, HandedOnly: composed.HandedOnly, Modules: namedModules(plan, composed.LeftOut), - Filled: composed.Filled, LocalNetwork: catalogue.LocalNetworkPorts(rules, with.LocalNetworks), - CoreBoxesWanted: rendered.CoreBoxesWanted, Said: rendered.Said, - Sequence: node.Sequence, Epoch: node.Epoch, Generation: node.Generation} - if len(composed.Filled) > 0 { - out.Provisioner = with.Provisioner - } - // A handed module only pulled in by another keeps what requires it, for its value of its own in the assignments mirror (issue 765). - for module := range composed.Plans { - if by := plan.RequiredBy(module); by != "" { - if out.RequiredBy == nil { - out.RequiredBy = map[string]string{} - } - out.RequiredBy[module] = by - } - } - // And the module of each resource and the build of each module, to a machine whose node-engine said it reads them (novox/hq issue - // 1145) and to no other. - if node.ReadsModules { - carried := c.carriedOn(node, plan, composed.LeftOut) - out.ReadsModules, out.ModuleOf, out.BuildIDs = true, composed.Owner, c.wireBuilds(carried, node.Runs) - } - _, passes, left, err := c.theRestOfTheMesh("") - if err != nil { - return Declared{}, err - } - out.Passes = passes - for _, l := range left { - out.Said = append(out.Said, l.String()) - } - out.Said = c.saidBeside(out.Said) - return out, nil -} - -// carriedOn is the build of each module a declaration for the node carries (plan.go carriedOn, in a question that sends nothing): the -// module's current build for each module in it, and for a module left out of it the build it was last sent, since the machine keeps -// that one -- or nothing, when that is not known. -func (c *composer) carriedOn(node Node, plan catalogue.Resolution, leftOut map[string]string) map[string]string { - before := node.SentBuilds - if !node.SentKnown { - before = nil - } - out := map[string]string{} - for _, m := range plan.Modules { - if _, left := leftOut[m.Module]; left { - if was, kept := before[m.Module]; kept { - out[m.Module] = was - } - continue - } - out[m.Module] = c.in.CurrentBuilds[m.Module] - // A module kept at the build the machine runs is carried at that build (plan.go carriedOn). - if kept, ok := node.KeptBuilds[m.Module]; ok && kept != "" { - out[m.Module] = kept - } - } - return out -} - -// wireBuilds is the build of each carried module that goes on the wire (novox/hq issue 1145): the build its assignment names when -// that assignment is of the commit carried, else the build made from that commit; a module with neither is not named. -func (c *composer) wireBuilds(carried map[string]string, runs map[string]Run) map[string]string { - out := map[string]string{} - for module, commit := range carried { - if commit == "" { - continue - } - if ref, ok := runs[module]; ok && ref.Commit == commit && ref.Build != "" { - out[module] = ref.Build - continue - } - if build := c.in.BuildIDs[module][commit]; build != "" { - out[module] = build - } - } - return out -} - -// adoptionFor is the envelope computed from what was taken and who owns each resource: nil for a converged node. Every module the -// node runs that is not taken is untaken -- including one pulled in by another rather than assigned. -func adoptionFor(plan catalogue.Resolution, node Node, composed catalogue.Composed) *Adoption { - if !node.Adopted { - return nil - } - isTaken := map[string]bool{} - for _, m := range node.Taken { - isTaken[m] = true - } - out := &Adoption{Taken: []string{}} - runs := map[string]bool{} - for _, m := range plan.Modules { - runs[m.Module] = true - if isTaken[m.Module] { - out.Taken = append(out.Taken, m.Module) - } - } - sort.Strings(out.Taken) - for _, r := range composed.Resources { - id, _ := r["id"].(string) - module, owned := composed.Owner[id] - // Every kind, not only files and containers (novox/hq ADR 0103). What the mesh declares of its own is never held. - if !owned || !runs[module] || isTaken[module] || strings.HasPrefix(id, catalogue.AdoptionPrefix) { - continue - } - if out.Untaken == nil { - out.Untaken = map[string][]string{} - } - out.Untaken[module] = append(out.Untaken[module], id) - } - return out -} - -// sortedKeysOf is a map's keys, sorted: so what a declaration says it left out does not move for a reordering nobody made. -func sortedKeysOf(m map[string]string) []string { - if len(m) == 0 { - return nil - } - out := make([]string, 0, len(m)) - for k := range m { - out = append(out, k) - } - sort.Strings(out) - return out -} - -// namedModules are the modules a declaration names: its machine's set, less what was left out of it. -func namedModules(plan catalogue.Resolution, leftOut map[string]string) []string { - out := make([]string, 0, len(plan.Modules)) - for _, m := range plan.Modules { - if _, left := leftOut[m.Module]; !left { - out = append(out, m.Module) - } - } - 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) -} diff --git a/go/compose/node_test.go b/go/compose/node_test.go deleted file mode 100644 index c2485dc..0000000 --- a/go/compose/node_test.go +++ /dev/null @@ -1,424 +0,0 @@ -package compose - -import ( - "encoding/json" - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/overlay" -) - -func resourceIDs(d Declared) []string { - var ids []string - for _, r := range d.Resources { - ids = append(ids, r["type"].(string)+":"+r["id"].(string)) - } - return ids -} - -func has(ids []string, want string) bool { - for _, id := range ids { - if id == want { - return true - } - } - return false -} - -// aSmallMesh is three nodes, a provider and its consumer, a seat and the private network: aMesh with `ace` running a database and -// a resolver holding the mesh's resolver seat on record (the providers and consumer of mesh-controller's facts_test.go -// aMeshWithSecrets, the seat of bindings_test.go storeManifests). -func aSmallMesh(t *testing.T) Input { - t.Helper() - in := threeNodes(t) - for _, m := range storeManifests() { - if m.Module == "resolver" { - in.Catalogue[m.Module] = m - } - } - ace := nodeOf(t, &in, "ace") - ace.Assigned = append(ace.Assigned, "resolver") - in.Holdings = []catalogue.Held{{Claim: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Node: "ace", Module: "resolver", Site: "there"}} - return in -} - -// 1298/AC1: each node of a small mesh composes, its body is accepted by the node-engine's validator, and it names the resources the -// controller composes for the same case: the private network's files on the nodes that run it, the provider's received file, the -// consumer's file. -func TestEachNodeOfASmallMeshComposesAndItsBodyIsAcceptedByTheValidator(t *testing.T) { - in := aSmallMesh(t) - want := map[string][]string{ - "anchor": {"package:mesh-wireguard.overlay-tools", "file:mesh-wireguard.overlay-config", "service:mesh-wireguard.overlay-up", "file:objects.received-s3-bucket"}, - "laptop": {"package:mesh-wireguard.overlay-tools", "file:mesh-wireguard.overlay-config", "service:mesh-wireguard.overlay-up", "file:files.config"}, - "ace": nil, - } - for node, ids := range want { - d, problems, err := Compose(in, node) - if err != nil || len(problems) != 0 { - t.Fatalf("%s: %v %v", node, err, problems) - } - for _, id := range ids { - if !has(resourceIDs(d), id) { - t.Errorf("%s does not name %s: %v", node, id, resourceIDs(d)) - } - } - body, err := d.Body() - if err != nil { - t.Fatal(err) - } - var parsed struct { - Declaration int `json:"declaration"` - Resources []map[string]any `json:"resources"` - } - if err := json.Unmarshal(body, &parsed); err != nil || parsed.Declaration != 1 || len(parsed.Resources) != len(d.Resources) { - t.Errorf("%s: body %s %v", node, body, err) - } - } - // ace runs a resolver and a database and nothing the mesh declares of its own: an empty declaration says it owns nothing. - d, _, _ := Compose(in, "ace") - if body, _ := d.Body(); !strings.Contains(string(body), `"owns_nothing":true`) { - t.Errorf("an empty declaration is not said to be deliberate: %s", body) - } -} - -// 1298/AC2 (mesh-controller merge_gate_integration_test.go TestIssue236AManifestTheNodeEngineRefusesFailsThePullRequest, the manifest): -// a service that omits its state is accepted by the catalogue's check and refused by the node-engine; Compose returns the validator's -// problems in the node-engine's words and no error. -func TestAManifestTheNodeEngineRefusesComesBackAsProblemsAndNoError(t *testing.T) { - in := aMesh(t) - var lemurs catalogue.Manifest - if err := json.Unmarshal([]byte(`{"module":"lemurs","version":"1","capabilities":["systemd"], - "resources":[{"id":"service","type":"service","unit":"lemurs.service","boot":"enabled"}]}`), &lemurs); err != nil { - t.Fatal(err) - } - in.Catalogue["lemurs"] = lemurs - laptop := nodeOf(t, &in, "laptop") - laptop.Assigned = append(laptop.Assigned, "lemurs") - d, problems, err := Compose(in, "laptop") - if err != nil { - t.Fatalf("a refusal of the validator is an error: %v", err) - } - if len(problems) == 0 || !strings.Contains(strings.Join(problems, "\n"), "lemurs.service") { - t.Fatalf("problems are %v", problems) - } - if !has(resourceIDs(d), "service:lemurs.service") { - t.Errorf("the declaration does not carry the resource: %v", resourceIDs(d)) - } - // And the same, as the validator says it of the body. - body, _ := d.Body() - if again := strings.Join(problems, "\n"); again == "" || !strings.Contains(string(body), "lemurs.service") { - t.Errorf("the body does not name the service: %s", body) - } -} - -// 1298/AC3: a node whose own set does not compose returns an error and no declaration, told apart from an unreadable Input. -func TestANodeWhoseSetDoesNotComposeReturnsAnErrorAndNoDeclaration(t *testing.T) { - in := aMesh(t) - in.Catalogue["rival-one"] = catalogue.Manifest{Module: "rival-one", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}} - in.Catalogue["rival-two"] = catalogue.Manifest{Module: "rival-two", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}} - laptop := nodeOf(t, &in, "laptop") - laptop.Assigned = append(laptop.Assigned, "rival-one", "rival-two") - d, problems, err := Compose(in, "laptop") - if err == nil || !IsUnresolvable(err) || d.Resources != nil || problems != nil { - t.Fatalf("a set that does not compose: %v %v %v", d.Resources, problems, err) - } - if _, _, err := Compose(in, "nowhere"); err == nil || IsUnresolvable(err) { - t.Fatalf("a node the Input does not have: %v", err) - } -} - -// 1298/AC4: composing the same Input twice gives byte-identical bodies, and the Input is unchanged. -func TestComposingTwiceGivesTheSameBytesAndChangesNothing(t *testing.T) { - mk := func() Input { - in := aSmallMesh(t) - nodeOf(t, &in, "anchor").Account = "jochens" - nodeOf(t, &in, "laptop").Sequence = 7 - return in - } - in := mk() - for _, node := range []string{"anchor", "laptop", "ace"} { - a, _, err := Compose(in, node) - if err != nil { - t.Fatal(err) - } - b, _, err := Compose(in, node) - if err != nil { - t.Fatal(err) - } - ab, _ := a.Body() - bb, _ := b.Body() - if string(ab) != string(bb) { - t.Errorf("%s: two compositions differ:\n%s\n%s", node, ab, bb) - } - } - unchanged(t, mk, func(in Input) { - for _, node := range []string{"anchor", "laptop", "ace"} { - if _, _, err := Compose(in, node); err != nil { - t.Fatal(err) - } - } - }) -} - -// 1298/AC5: what a send would make is named in the result as foreseen and never invented: an own secret, a port, a core box, a grant's -// sealed copy. -func TestWhatASendWouldMakeIsNamedAsForeseenAndNeverInvented(t *testing.T) { - in := aGrantMesh(t) - in.Issued[0].ForProvider = "" - in.Catalogue["assistant"] = anAssistant() - in.Catalogue["web"] = aWebModule() - in.Catalogue["nats"] = catalogue.Manifest{Module: "nats", Version: "1", - OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh/nats/broker", Taken: catalogue.TakenAtStart}}} - anchor := nodeOf(t, &in, "anchor") - anchor.Assigned = append(anchor.Assigned, "assistant", "web", "nats") - anchor.SealingKey = keyOf(t, "anchor") - anchor.Stated, anchor.Contract = true, VaultBoxContract - in.BusMinted = []string{"anchor.nats"} - d, problems, err := Compose(in, "anchor") - if err != nil || len(problems) != 0 { - t.Fatalf("%v %v", err, problems) - } - if !has(d.Foreseen, "assistant/password") || !has(d.Foreseen, "nats/broker") { - t.Errorf("own secrets foreseen: %v", d.Foreseen) - } - if len(d.ForeseenPorts) != 1 || d.ForeseenPorts[0] != "web:8080" { - t.Errorf("foreseen ports: %v", d.ForeseenPorts) - } - if len(d.CoreBoxesWanted) != 1 || d.CoreBoxesWanted[0].Module != "nats" { - t.Errorf("core boxes wanted: %v", d.CoreBoxesWanted) - } - if !has(d.StoodIn, "the grant of s3-bucket for files on laptop") { - t.Errorf("stood in for: %v", d.StoodIn) - } - body, _ := d.Body() - if strings.Contains(string(body), secretValue) { - t.Error("a secret value is in the body") - } -} - -// 1298/AC6: the body carries the sequence, epoch and generation the Input gives, and the module of each resource and the carried builds -// only to a node whose Input says it reads modules (novox/hq issue 1145). -func TestTheBodyCarriesTheOrderTheInputGivesAndTheBuildsOnlyToAnEngineThatReadsThem(t *testing.T) { - in := aSmallMesh(t) - laptop := nodeOf(t, &in, "laptop") - laptop.Sequence, laptop.Epoch, laptop.Generation = 42, 57, 9 - in.CurrentBuilds = map[string]string{"files": "commit-files", "mesh-wireguard": "commit-wg"} - in.BuildIDs = map[string]map[string]string{"files": {"commit-files": "build-7"}} - // An assignment of another commit than the one carried does not name the build; the build made from the commit carried does. - laptop.Runs = map[string]Run{"mesh-wireguard": {Commit: "commit-wg", Build: "build-9"}, "files": {Commit: "an-older-commit", Build: "build-6"}} - d, _, err := Compose(in, "laptop") - if err != nil { - t.Fatal(err) - } - body, _ := d.Body() - var got map[string]any - if err := json.Unmarshal(body, &got); err != nil { - t.Fatal(err) - } - if got["sequence"] != float64(42) || got["epoch"] != float64(57) || got["generation"] != float64(9) { - t.Errorf("the order is %v %v %v", got["sequence"], got["epoch"], got["generation"]) - } - if _, there := got["builds"]; there { - t.Errorf("builds are sent to an engine that does not read them: %s", body) - } - for _, r := range got["resources"].([]any) { - if _, there := r.(map[string]any)["module"]; there { - t.Errorf("a module is named on a resource of an engine that does not read them: %v", r) - } - } - laptop.ReadsModules = true - d, _, err = Compose(in, "laptop") - if err != nil { - t.Fatal(err) - } - body, _ = d.Body() - got = nil - if err := json.Unmarshal(body, &got); err != nil { - t.Fatal(err) - } - builds, _ := got["builds"].(map[string]any) - if builds["files"] != "build-7" || builds["mesh-wireguard"] != "build-9" { - t.Errorf("builds are %v", builds) - } - named := false - for _, r := range got["resources"].([]any) { - if r.(map[string]any)["id"] == "files.config" && r.(map[string]any)["module"] == "files" { - named = true - } - } - if !named { - t.Errorf("a resource is not named for its module: %s", body) - } - // Zero is not sent at all. - laptop.Sequence, laptop.Epoch, laptop.Generation = 0, 0, 0 - d, _, _ = Compose(in, "laptop") - body, _ = d.Body() - for _, key := range []string{`"sequence"`, `"epoch"`, `"generation"`} { - if strings.Contains(string(body), key) { - t.Errorf("%s is sent as zero: %s", key, body) - } - } -} - -// A module left out of a build set keeps the build it was last sent (novox/hq issue 259): the machine keeps that one. -func TestAModuleLeftOutKeepsTheBuildItWasLastSent(t *testing.T) { - c := newComposer(Input{CurrentBuilds: map[string]string{"a": "new-a", "b": "new-b"}}) - plan := catalogue.Resolution{Modules: []catalogue.Manifest{{Module: "a"}, {Module: "b"}, {Module: "c"}}} - node := Node{SentBuilds: map[string]string{"b": "old-b", "c": "old-c"}, SentKnown: true} - got := c.carriedOn(node, plan, map[string]string{"b": "its settings cannot compose"}) - if got["a"] != "new-a" || got["b"] != "old-b" || got["c"] != "" { - t.Errorf("carried builds are %v", got) - } - node.SentKnown = false - if got := c.carriedOn(node, plan, map[string]string{"b": "x"}); len(got) != 2 || got["b"] != "" { - t.Errorf("when what was sent is not known: %v", got) - } -} - -// An adopted node is told which modules were taken and, for every module it runs that is not, the ids of its resources -// (novox/hq ADR 0100 and 0103); a converged node is told nothing, and its body is as it was before adoption. -func TestAnAdoptedNodeIsToldWhatWasTakenAndWhatIsKept(t *testing.T) { - in := aSmallMesh(t) - d, _, err := Compose(in, "laptop") - if err != nil || d.Adoption != nil { - t.Fatalf("a converged node: %v %v", d.Adoption, err) - } - laptop := nodeOf(t, &in, "laptop") - laptop.Adopted, laptop.Taken = true, []string{"mesh-wireguard"} - d, problems, err := Compose(in, "laptop") - if err != nil || len(problems) != 0 { - t.Fatalf("%v %v", err, problems) - } - if d.Adoption == nil || len(d.Adoption.Taken) != 1 || d.Adoption.Taken[0] != "mesh-wireguard" || - len(d.Adoption.Untaken["files"]) == 0 || d.Adoption.Untaken["mesh-wireguard"] != nil { - t.Fatalf("adoption is %+v", d.Adoption) - } - if body, _ := d.Body(); !strings.Contains(string(body), `"adoption"`) { - t.Errorf("the adoption is not on the wire: %s", body) - } -} - -// Issue 263 (merge_gate_integration_test.go TestIssue263AnIdentityARealMachineNameOverflows...): a change makes the network manager -// require the object store's provision; on a machine whose name is six characters its identity is 26 against a bound of 20. The -// composition of the provider returns the refusal, naming the module and the provision. -func TestIssue263AnIdentityARealMachineNameOverflowsIsARefusal(t *testing.T) { - in := aGrantMesh(t) - in.Catalogue["resolver"] = storeManifests()[1] - in.Catalogue["networkmanager"] = catalogue.Manifest{Module: "networkmanager", Version: "1", Requires: []string{"wildcard-resolution", "s3-bucket"}} - laptop, anchor := nodeOf(t, &in, "laptop"), nodeOf(t, &in, "anchor") - laptop.Assigned = append(laptop.Assigned, "networkmanager") - anchor.Assigned = append(anchor.Assigned, "resolver") - in.Holdings = []catalogue.Held{{Claim: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Node: "anchor", Module: "resolver", Site: "here"}} - in.Issued = append(in.Issued, Issued{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "networkmanager", Provider: "anchor", ForProvider: "sealed"}) - d, problems, err := Compose(in, "anchor") - if err != nil || len(problems) != 0 { - t.Fatalf("%v %v", err, problems) - } - refusals := strings.Join(d.Refusals(), "\n") - if !strings.Contains(refusals, "networkmanager") || !strings.Contains(refusals, "s3-bucket") || !strings.Contains(refusals, "laptop") { - t.Fatalf("the refusal does not name the module, the provision and the machine: %q", refusals) - } - // And the consumer that fits is granted. - if len(d.Withheld) != 1 || d.Withheld[0].Module != "networkmanager" { - t.Errorf("withheld %+v", d.Withheld) - } -} - -// Round 1, finding 1 (plan.go keepRecorded): a recorded module kept at the build a machine runs is that machine's own resolution -// and no other's; the commit carried for it is the kept one. -func TestAKeptModuleChangesOnlyTheResolutionOfTheNodeThatKeepsIt(t *testing.T) { - in := aSmallMesh(t) - in.Catalogue["tool"] = catalogue.Manifest{Module: "tool", Version: "2"} - for _, name := range []string{"anchor", "laptop"} { - n := nodeOf(t, &in, name) - n.Assigned = append(n.Assigned, "tool") - } - anchor := nodeOf(t, &in, "anchor") - anchor.Kept = map[string]catalogue.Manifest{"tool": {Module: "tool", Version: "1"}} - versionOn := func(in Input, node string) string { - t.Helper() - got, err := Resolve(in, node) - if err != nil { - t.Fatal(err) - } - for _, m := range got.Plan.Modules { - if m.Module == "tool" { - return m.Version - } - } - t.Fatalf("no tool on %s", node) - return "" - } - if v := versionOn(in, "anchor"); v != "1" { - t.Errorf("the node that keeps it runs version %s", v) - } - if v := versionOn(in, "laptop"); v != "2" { - t.Errorf("another node runs version %s: a kept build leaked", v) - } - if got := in.Catalogue["tool"].Version; got != "2" { - t.Errorf("the Input's catalogue was changed: %s", got) - } - // And the commit carried for it is the kept one. - c := newComposer(in) - plan := catalogue.Resolution{Modules: []catalogue.Manifest{{Module: "tool"}, {Module: "other"}}} - in.CurrentBuilds = map[string]string{"tool": "current", "other": "current-other"} - c = newComposer(in) - node := Node{KeptBuilds: map[string]string{"tool": "kept-commit"}} - if got := c.carriedOn(node, plan, nil); got["tool"] != "kept-commit" || got["other"] != "current-other" { - t.Errorf("carried builds are %v", got) - } -} - -// Another node's kept build changes neither the rest of the mesh nor who is on the network: the controller reads both with the -// current manifests, and only then keeps a recorded module for the node it composes (recorded_kept.go keepRecorded). -func TestAnotherNodesKeptBuildDoesNotChangeTheRestOfTheMeshOrWhoIsOnTheNetwork(t *testing.T) { - in := threeNodes(t) - nodeOf(t, &in, "anchor").Kept = map[string]catalogue.Manifest{"objects": {Module: "objects", Version: "0"}, overlayName(): {Module: overlayName(), Version: "0"}} - w, _, _, err := TheRestOfTheMesh(in, "laptop") - if err != nil || len(providers(w, "s3-bucket")) != 1 { - t.Errorf("a kept build changed what anchor offers: %v %v", providers(w, "s3-bucket"), err) - } - where, err := newComposer(in).whereEveryoneIs() - if err != nil || where["anchor"] == "" { - t.Errorf("a kept build took anchor off the network: %v %v", where, err) - } -} - -func overlayName() string { return overlay.Name } - -// Round 1, finding 6: the nodes the composition did not count as on the network are returned, said, not lost. laptop holds no -// complete place (no key): one that does keeps it on the network (issue 1833) and is counted. -func TestANodeNotCountedAsOnTheNetworkIsSaidInTheResult(t *testing.T) { - in := aMesh(t) - nodeOf(t, &in, "laptop").Place.Key = "" - in.Catalogue["rival-one"] = catalogue.Manifest{Module: "rival-one", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}} - in.Catalogue["rival-two"] = catalogue.Manifest{Module: "rival-two", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}} - laptop := nodeOf(t, &in, "laptop") - laptop.Assigned = append(laptop.Assigned, "rival-one", "rival-two") - said := func(lines []string) bool { - for _, l := range lines { - if strings.HasPrefix(l, "laptop is not counted as on the network") { - return true - } - } - return false - } - got, err := Render(in, "anchor", Foreseeing) - if err != nil || !said(got.Said) { - t.Errorf("rendered: %v %v", got.Said, err) - } - d, _, err := Compose(in, "anchor") - if err != nil || !said(d.Said) { - t.Errorf("declared: %v %v", d.Said, err) - } - n := 0 - for _, l := range d.Said { - if strings.HasPrefix(l, "laptop is not counted") { - n++ - } - } - if n != 1 { - t.Errorf("said %d times: %v", n, d.Said) - } -} diff --git a/go/compose/one_machine_alone_test.go b/go/compose/one_machine_alone_test.go deleted file mode 100644 index aa6b4c9..0000000 --- a/go/compose/one_machine_alone_test.go +++ /dev/null @@ -1,167 +0,0 @@ -package compose - -import ( - "slices" - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/overlay" -) - -// fourOnTheNetwork is aMesh and two more machines on the private network at the site `there`: `ace`, dialled at -// ace.example:51820, at 10.77.0.3, and `box`, behind no endpoint, at 10.77.0.4. A module `office` requires -// `document-server`, which nothing in the catalogue provides: the shape of issue 1833 (nextcloud on the hub wanting a -// document server nobody offered). -func fourOnTheNetwork(t *testing.T) Input { - t.Helper() - in := aMesh(t) - in.Catalogue["office"] = catalogue.Manifest{Module: "office", Version: "1", Requires: []string{"document-server"}} - caps := map[string]bool{"container-runtime": true, "wireguard": true, "systemd": true} - in.Nodes = append(in.Nodes, - Node{Name: "ace", Place: Place{Key: keyOf(t, "ace"), Endpoint: "ace.example:51820", Site: "there", Address: "10.77.0.3"}, - Assigned: []string{overlay.Name}, Capabilities: caps}, - Node{Name: "box", Place: Place{Key: keyOf(t, "box"), Site: "there", Address: "10.77.0.4"}, - Assigned: []string{overlay.Name}, Capabilities: caps}) - return in -} - -// unresolvableBy makes one machine's own set fail to resolve: it is also given `office`, whose requirement nothing provides. -func unresolvableBy(t *testing.T, in *Input, name string) { - t.Helper() - n := nodeOf(t, in, name) - n.Assigned = append(n.Assigned, "office") -} - -// networkOf is the generator of an Input's private network, failing the test when it is not composed. -func networkOf(t *testing.T, in Input) *overlay.Generator { - t.Helper() - gens, err := Generators(in) - if err != nil { - t.Fatalf("the private network was not composed: %v", err) - } - return gens[overlay.Name].(*overlay.Generator) -} - -func placesOf(g *overlay.Generator) map[string]overlay.Node { - out := map[string]overlay.Node{} - for _, n := range g.Nodes() { - out[n.Name] = n - } - return out -} - -// 1833/AC1: four machines, the hub's own set does not resolve; the other three compose their declarations, and the hub alone is -// refused, with an error that is its own set failing and names what could not be resolved. -func TestOneMachineThatDoesNotResolveIsRefusedAlone(t *testing.T) { - in := fourOnTheNetwork(t) - unresolvableBy(t, &in, "anchor") - for _, node := range []string{"laptop", "ace", "box"} { - d, problems, err := Compose(in, node) - if err != nil || len(problems) != 0 { - t.Errorf("%s does not compose because another machine does not resolve: %v %v", node, err, problems) - continue - } - if !has(resourceIDs(d), "file:mesh-wireguard.overlay-config") { - t.Errorf("%s is given no private network: %v", node, resourceIDs(d)) - } - } - _, _, err := Compose(in, "anchor") - if err == nil || !IsUnresolvable(err) { - t.Fatalf("the machine that does not resolve is not refused as its own set failing: %v", err) - } - if !strings.Contains(err.Error(), `nothing provides "document-server", wanted by office`) { - t.Errorf("the refusal does not name what could not be resolved: %v", err) - } -} - -// 1833/AC2: the private network is composed with every machine on it: the hub that does not resolve keeps its place (key, -// address, endpoint, site, hub role), so every other machine keeps its path through it. -func TestAMachineThatDoesNotResolveKeepsItsPlaceOnTheNetwork(t *testing.T) { - in := fourOnTheNetwork(t) - unresolvableBy(t, &in, "anchor") - got := placesOf(networkOf(t, in)) - if len(got) != 4 { - t.Fatalf("the network is over %v, want all four machines", got) - } - hub := got["anchor"] - if !hub.Hub || hub.Key != keyOf(t, "anchor") || hub.Address != "10.77.0.1" || hub.Endpoint != "anchor.example:51820" || hub.Site != "here" { - t.Errorf("the hub did not keep its place: %+v", hub) - } - nodes, graph, err := Graph(in) - if err != nil || len(nodes) != 4 { - t.Fatalf("the graph is not shown with every machine: %v %v", nodes, err) - } - for _, node := range []string{"ace", "box"} { - through := false - for _, p := range graph[node] { - through = through || (p.Name == "anchor" && p.Endpoint == "anchor.example:51820") - } - if !through { - t.Errorf("%s has no path through the hub: %+v", node, graph[node]) - } - } -} - -// 1833/AC3: a machine that does not resolve and was never placed (a new machine: no key, no address) is left off the network, -// which is composed from the rest -- here with the hub that does not resolve on its last place -- and that machine alone is refused. -func TestANewMachineThatDoesNotResolveIsLeftOffAndRefusedAlone(t *testing.T) { - in := fourOnTheNetwork(t) - unresolvableBy(t, &in, "anchor") - in.Nodes = append(in.Nodes, Node{Name: "fresh", Place: Place{Site: "there"}, - Assigned: []string{overlay.Name, "office"}, Capabilities: map[string]bool{"wireguard": true, "systemd": true}}) - got := placesOf(networkOf(t, in)) - if _, on := got["fresh"]; on || len(got) != 4 { - t.Fatalf("the network is over %v, want the four placed machines without fresh", got) - } - if _, _, err := Compose(in, "fresh"); err == nil || !IsUnresolvable(err) { - t.Errorf("the new machine is not refused as its own set failing: %v", err) - } - if _, problems, err := Compose(in, "box"); err != nil || len(problems) != 0 { - t.Errorf("another machine does not compose: %v %v", err, problems) - } -} - -// 1833/AC4: a network that truly cannot be composed from what remains still fails: the hub does not resolve and has no -// complete place (no key), so nothing is the hub; the refusal is today's, naming the machine that could not be resolved. -func TestANetworkThatCannotBeComposedFromWhatRemainsStillFails(t *testing.T) { - in := fourOnTheNetwork(t) - unresolvableBy(t, &in, "anchor") - nodeOf(t, &in, "anchor").Place.Key = "" - _, err := Generators(in) - if err == nil { - t.Fatal("a network without a hub was composed") - } - for _, want := range []string{overlay.ErrNoHub.Error(), "and 1 node(s) could not be resolved at all, which is probably why:", " anchor: "} { - if !strings.Contains(err.Error(), want) { - t.Errorf("the refusal does not say %q:\n%v", want, err) - } - } - if _, _, err := Compose(in, "laptop"); err == nil { - t.Error("a declaration was composed over a network that cannot be") - } -} - -// 1833/AC2, a failure the composition's unchecked pass refuses too (two modules claiming one seat on the hub, the 1360/AC3 -// shape): the hub keeps its place in what every other machine is sent besides the tunnel -- its internal name and its address -// among the sources "from the mesh" -- and another machine still composes. -func TestAMachineThatDoesNotResolveKeepsItsNameAndItsAddressInTheMesh(t *testing.T) { - in := fourOnTheNetwork(t) - in.Catalogue["rival-one"] = catalogue.Manifest{Module: "rival-one", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}} - in.Catalogue["rival-two"] = catalogue.Manifest{Module: "rival-two", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}} - hub := nodeOf(t, &in, "anchor") - hub.Assigned = append(hub.Assigned, "rival-one", "rival-two") - if _, err := Resolve(in, "anchor"); !IsUnresolvable(err) { - t.Fatalf("the hub is meant not to resolve: %v", err) - } - c := newComposer(in) - if got := c.namesInTheMesh()["anchor."+c.suffix()]; got != "10.77.0.1" { - t.Errorf("the hub's name is not in the mesh's names: %v", c.namesInTheMesh()) - } - if got := c.onThePrivateNetwork(); !slices.Contains(got, "10.77.0.1") || len(got) != 4 { - t.Errorf("the sources from the mesh are %v, want all four machines with the hub", got) - } - if _, problems, err := Compose(in, "ace"); err != nil || len(problems) != 0 { - t.Errorf("another machine does not compose: %v %v", err, problems) - } -} diff --git a/go/compose/render.go b/go/compose/render.go deleted file mode 100644 index 3354fad..0000000 --- a/go/compose/render.go +++ /dev/null @@ -1,150 +0,0 @@ -package compose - -import ( - "fmt" - "sort" - "strings" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// Rendered is everything a node's declaration is composed with (plan.go renderingFor), over an Input: the place half (Places) -// and the credential half. Nothing was allocated, made, kept or written; what a send would do is returned for the caller to do. -type Rendered struct { - Rendering catalogue.Rendering - // ForeseenPorts are the ports the mesh may assign that the Input holds none for (":"). - ForeseenPorts []string - // Foreseen are the stand-ins the composition used for what the Input does not hold: an own secret the next send makes - // ("/"), a grant's sealed copy, the certificate and the bus's user list. - Foreseen []string - // CoreBoxesWanted are the core credentials whose box the send must ask the vault for and keep. - CoreBoxesWanted []CoreBoxWanted - // Withheld are the consumers the grants leave out because their identity overflows the provision's bound (novox/hq ADR 0225), - // and Unbound those whose credential from this node is on record and whose own resolution binds them elsewhere (issue - // 274). The controller says them; a check refuses a change that adds one (ADR 0446 clause 1). - Withheld []catalogue.Overflow - Unbound []catalogue.Unbound - // Said are the lines the controller says while composing (the bus users left out for want of a credential). - Said []string -} - -// Render composes the rendering of a node: the grants it must create, where it puts its ports, its own secrets, the vault's boxes, -// its certificate, the names of the mesh, the bus's user list and its membership. The refusals are the controller's, in its words; -// where more than one fault stands, the first reported may differ from the controller's, which interleaves the halves. -func Render(in Input, node string, mode Mode) (Rendered, error) { - return newComposer(in).render(node, mode) -} - -// Composing is Resolve, Render and Generators of one node over one composition, which resolves each node of the mesh -// once for all three (issue 1954: three compositions resolved the mesh again each, 11.7 s for one node's plans). Each -// answers as it does alone; the first refusal ends it, in that order, with what follows it empty. -func Composing(in Input, node string, mode Mode) (Resolved, Rendered, map[string]catalogue.Generator, error) { - c := newComposer(in) - resolved, err := c.planFor(node) - if err != nil { - return Resolved{}, Rendered{}, nil, err - } - rendered, err := c.render(node, mode) - if err != nil { - return resolved, Rendered{}, nil, err - } - gens, err := c.generators() - if err != nil { - return resolved, rendered, nil, err - } - return resolved, rendered, gens, nil -} - -func (c *composer) render(nodeName string, mode Mode) (Rendered, error) { - resolved, err := c.planFor(nodeName) - if err != nil { - return Rendered{}, err - } - node, err := c.in.node(nodeName) - if err != nil { - return Rendered{}, err - } - plan := resolved.Plan - var out Rendered - foreseen := func(s string) { out.Foreseen = append(out.Foreseen, s) } - - grants, withheld, unbound, grantsForeseen, err := c.grantsFor(nodeName) - if err != nil { - return Rendered{}, err - } - placed, err := c.places(nodeName, resolved) - if err != nil { - return Rendered{}, err - } - 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 { - foreseen(g) - } - - // 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, Assigned: r.Assigned} - needed, foreseenOwn, err := c.ownSecretsFor(node, plan, handing, mode) - if err != nil { - return Rendered{}, err - } - r.Needed = needed - if mode == Foreseeing || mode == Planning || len(foreseenOwn) > 0 { - r.Foreseen = foreseenOwn - } - var names []string - for module, secrets := range foreseenOwn { - for name := range secrets { - names = append(names, module+"/"+name) - } - } - sort.Strings(names) - for _, n := range names { - foreseen(n) - } - - // The core's own credentials as the vault sealed them, for an engine that opens the vault's box. - if node.Stated && node.Contract >= VaultBoxContract { - sealed, notPlaced, wanted := c.coreBoxesFor(node, plan.Modules, handing) - r.VaultSealed, r.NotPlaced = sealed, notPlaced - out.CoreBoxesWanted = wanted - } - - // And a certificate for this machine's name inside the mesh, when anything on it asks. - for _, m := range plan.Modules { - if m.Certificate == nil { - continue - } - certificate, authority, stood, err := c.certificateFor(node) - if err != nil { - return Rendered{}, err - } - r.Certificate, r.Authority = certificate, authority - if stood { - foreseen("the certificate and the authority of " + nodeName) - } - break - } - - // The bus's user list, for the machine that runs the bus: names and hashes the Input carries, never passwords. - if holdsTheBus(plan.Modules) { - r.BusUsers = c.in.BusUsers - if r.BusUsers == "" { - r.BusUsers = ForeseenBusUsers - foreseen(BusUsersStandIn) - } - if len(c.in.BusMissing) > 0 { - out.Said = append(out.Said, fmt.Sprintf("the bus's user list leaves out %d user(s) the mesh has minted no credential "+ - "for: %s. Each is a user that cannot connect until one is issued", len(c.in.BusMissing), strings.Join(c.in.BusMissing, ", "))) - } - } - r.BusMembership = c.in.BusMemberships[nodeName] - out.Rendering = r - // And what the composition found on the way, which the controller writes to its log: a machine not counted as on the network. - out.Said = c.saidBeside(out.Said) - return out, nil -} diff --git a/go/compose/rendering.go b/go/compose/rendering.go deleted file mode 100644 index d942bc6..0000000 --- a/go/compose/rendering.go +++ /dev/null @@ -1,543 +0,0 @@ -package compose - -import ( - "errors" - "fmt" - "sort" - "strings" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/overlay" -) - -// The contracts of the node-engine's statement that gate what a machine is sent, as the controller's link package names them -// (mesh-controller internal/link/protocol.go at 6c2a8d62). An older engine parses strictly and refuses a field it does not -// know, whole. -const ( - ReadinessContract = 2 // the engine reads a resource's `health` (novox/hq ADR 0240) - RootContract = 3 // the engine judges a user's declared `root` (ADR 0266) - BusyContract = 8 // the engine reads a container's `busy` (ADR 0335) - HandedContract = 11 // the engine writes a filled file on the provisioner client's word alone - VaultBoxContract = 12 // the engine opens the vault's box -) - -// The provisioner client's seat and own secret (the controller's broker.NodeProvisionerSeat and ProvisionerSecret). -const ( - NodeProvisionerSeat = "node-provisioner" - ProvisionerSecret = "provisioner-broker" -) - -// ErrPortTaken is the controller's refusal for a machine port held twice on one machine. -var ErrPortTaken = errors.New("that port is already held on this machine") - -// Placed is the place half of a rendering (plan.go renderingFor): where a machine puts what its modules need reachable, the -// seats and who holds them, the names of the mesh, the networks, accounts and the operator's export. It is a catalogue.Rendering -// with those fields set, and the ports it could not read named foreseen. Nothing was allocated. -type Placed struct { - Rendering catalogue.Rendering - // ForeseenPorts are the ports the mesh may assign that the Input holds none for, as ":": composed with the - // manifest's own number as the Reading mode composes them, and never invented. The controller's adapter allocates and - // composes again. - ForeseenPorts []string -} - -// Places composes the place half of the rendering for a node (the controller's renderingFor in the Reading and Foreseeing modes, -// without the credentials): over an Input, allocating nothing and changing nothing in it. The refusals are the controller's, -// in its words. -func Places(in Input, node string) (Placed, error) { - c := newComposer(in) - resolved, err := c.planFor(node) - if err != nil { - return Placed{}, err - } - return c.places(node, resolved) -} - -func (c *composer) places(nodeName string, resolved Resolved) (Placed, error) { - var out Placed - node, err := c.in.node(nodeName) - if err != nil { - return out, err - } - plan, settings := resolved.Plan, resolved.Settings - r := &out.Rendering - - // Where this machine puts what each module needs reachable (novox/hq ADR 0038): what it was already given, and the ports - // it was given over those (ADR 0100). - given, err := c.portsFor(node, plan, settings, r, &out.ForeseenPorts) - if err != nil { - return out, err - } - - // Hands: whether the engine writes a filled file on the provisioner client's word alone; engines older than a contract are - // 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 - - // And who else is on the private network, which is what a rule saying "from the mesh" resolves to. - r.Mesh = c.onThePrivateNetwork() - // The private network's range, offered to a module as ${machine:mesh-range}. - r.MeshRange = c.overlayRange() - - // The artifact store as this node reaches it now, with what the mesh has built. - store, err := c.artifactStoreAddress(nodeName) - if err != nil { - return out, err - } - r.ArtifactStore = store - r.Built = make(map[string]bool, len(c.in.Built)) - for _, repository := range c.in.Built { - r.Built[repository] = true - } - - // And every machine's name, so a container can reach one. - r.Names = c.namesInTheMesh() - - // Each machine's operator account, so an ssh Host block can name the login for every node (novox/hq to-be 29). - r.Accounts = map[string]string{} - for _, n := range c.in.sorted() { - if n.Account != "" { - r.Accounts[n.Name] = n.Account - } - } - - // The roster is the machines and nothing else (novox/hq ADR 0191). - r.Machines = make(map[string]string, len(r.Names)) - for name, at := range r.Names { - r.Machines[name] = at - } - - // And every zone a module in the mesh answers itself (novox/hq ADR 0199). - zones, err := c.zonesInTheMesh() - if err != nil { - return out, err - } - r.Zones = zones - - // And who holds each replicated seat, where (novox/hq ADR 0223). - r.Holders = c.replicatedHolders() - - r.Suffix, r.TunnelInterface = c.suffix(), overlay.Interface - - // And, for a module that keeps them, every operator-sealed secret in the mesh (novox/hq ADR 0085, amended). - for _, m := range plan.Modules { - if m.Keeps == "" { - continue - } - r.Kept = c.in.OperatorExport - if r.Kept == nil { - r.KeptRuns = c.in.KeptRuns[node.Name] - } - break - } - - // Whether this node is adopted (novox/hq ADR 0100), and which modules were taken there. - r.Adopted = node.Adopted - var taken map[string]bool - if node.Adopted { - taken = map[string]bool{} - for _, m := range node.Taken { - taken[m] = true - } - } - r.Taken = taken - - // Where this node put the foundation's servers (novox/hq 04-ISSUES/102). - seats, err := c.seatsOn(node, plan.Modules, node.Adopted, taken) - if err != nil { - return out, err - } - r.Seats = seats - - // Which of this machine's links face outside (novox/hq ADR 0140), and which of its networks count as its local network - // (ADR 0340). - r.OutwardLinks = node.OutwardLinks - r.LocalNetworks = catalogue.CountedLocalNetworks(localNetworksOf(node)) - - // Where this machine reaches each mesh seat's holder, for ${seat::reach} (novox/hq ADR 0222). - if store != "" { - r.SeatReach = map[string]string{"mesh-artifact-store": store} - } - r.Settings = settings - r.Given = given - return out, nil -} - -// takesHanded says whether a machine's newest statement is of an engine that writes a filled file on the provisioner client's -// word alone. -func takesHanded(n Node) bool { return n.Stated && n.Contract >= HandedContract } - -// provisionerAccount is the account the provisioner's client runs as on this machine (novox/hq ADR 0349 clause 4): the user -// the process of the module running it runs as, empty where no such module is on the machine. -func provisionerAccount(plan catalogue.Resolution) string { - for _, m := range plan.Modules { - if !runsTheProvisionerClient(m) { - continue - } - for _, r := range m.Resources { - if r["type"] == "process" { - if user, _ := r["user"].(string); user != "" && user != "root" { - return user - } - } - } - } - return "" -} - -// runsTheProvisionerClient says a module runs its machine's provisioner client: it claims the seat and declares the own -// secret that account is delivered in. -func runsTheProvisionerClient(m catalogue.Manifest) bool { - _, reads := m.OwnSecrets[ProvisionerSecret] - return reads && m.ClaimsSeat(NodeProvisionerSeat) -} - -// portsFor is where a node puts what each module needs reachable, in the Reading mode: the ports it was given win, then the -// ones the mesh assigned, and a port the Input holds none for is composed as the manifest says and named foreseen. One holder -// per machine port across the node's modules: settings written before this was refused are refused here. -func (c *composer) portsFor(node Node, plan catalogue.Resolution, settings catalogue.SettingsBy, r *catalogue.Rendering, - foreseen *[]string) (map[string]map[int]int, error) { - already := node.Ports - - given := map[string]map[int]int{} - for _, m := range plan.Modules { - g, err := catalogue.GivenPorts(m, settings[m.Module]) - if err != nil { - // A given port its definition no longer publishes: the module is left out of the declaration, by name, when it is - // composed (novox/hq ADR 0163, rule 6) -- never the machine refused here for it. - continue - } - if g != nil { - given[m.Module] = g - } - } - holders := map[int]string{} - for _, m := range plan.Modules { - for _, at := range given[m.Module] { - if other, twice := holders[at]; twice && other != m.Module { - return nil, fmt.Errorf("%w: %s and %s are both given machine port %d on %s", ErrPortTaken, other, m.Module, - at, node.Name) - } - holders[at] = m.Module - } - } - - ports := map[string]map[int]int{} - for _, m := range plan.Modules { - for _, l := range m.Listens { - if at, isGiven := given[m.Module][l.Port]; isGiven { - if ports[m.Module] == nil { - ports[m.Module] = map[int]int{} - } - ports[m.Module][l.Port] = at - continue - } - // Only a port the module actually publishes is the mesh's to move. Recorded either way, because this map means where - // this module's port is on this machine. - where, mayAssign := m.MachineSide(l.Port) - if mayAssign { - // Whatever was chosen last time, and nothing if there was no last time. - if at, known := already[m.Module][l.Port]; known { - where = at - } else { - *foreseen = append(*foreseen, fmt.Sprintf("%s:%d", m.Module, l.Port)) - } - } - if ports[m.Module] == nil { - ports[m.Module] = map[int]int{} - } - ports[m.Module][l.Port] = where - } - } - r.Ports = ports - return given, nil -} - -// onThePrivateNetwork is every node's address on the private network, sorted: the same set the names mean by it, so a rule -// saying "from the mesh" admits exactly the machines the mesh names. A node with no address is left out, not rendered as an -// empty source. -func (c *composer) onThePrivateNetwork() []string { - var out []string - for _, p := range c.onTheNetwork() { - if strings.TrimSpace(p.Place.Address) != "" { - out = append(out, p.Place.Address) - } - } - sort.Strings(out) - return out -} - -// namesInTheMesh is every machine's internal name and the address behind it, for every machine on the private network, and -// the carried peers the operator has named (novox/hq issue 112). -func (c *composer) namesInTheMesh() map[string]string { - out := map[string]string{} - for _, p := range c.onTheNetwork() { - out[c.internalName(p.Name)] = p.Place.Address - } - for _, p := range c.in.CarriedPeers { - if p.Named == "" || p.EnrolledAs != "" { - continue - } - if _, taken := out[c.internalName(p.Named)]; taken { - continue // a node of the mesh owns the name; the stale statement loses - } - out[c.internalName(p.Named)] = p.Address - } - return out -} - -// artifactStoreOnNetwork is the machine on this network that offers the artifact store, and the port that machine put it on. -func (c *composer) artifactStoreOnNetwork(on map[string]bool) (node, port string, found bool, err error) { - providers := map[string]catalogue.Manifest{} - for name, m := range c.in.Catalogue { - if _, offers := m.Serves[catalogue.ArtifactStoreProvision]; offers { - providers[name] = m - } - } - if len(providers) == 0 { - return "", "", false, nil - } - // In a stated order, so two machines offering it would always answer the same one. - machines := make([]string, 0, len(on)) - for machine := range on { - machines = append(machines, machine) - } - sort.Strings(machines) - for _, machine := range machines { - n, err := c.in.node(machine) - if err != nil { - return "", "", false, fmt.Errorf("reading what %s is assigned: %w", machine, err) - } - for _, a := range n.Assigned { - m, offers := providers[a] - if !offers { - continue - } - serves, _, err := c.servedOnNode(machine, m, catalogue.ArtifactStoreProvision) - if err != nil { - return "", "", false, fmt.Errorf("reading where %s puts the artifact store: %w", machine, err) - } - if p, ok := serves["port"]; ok { - return machine, fmt.Sprintf("%v", p), true, nil - } - } - } - return "", "", false, nil -} - -// ArtifactStoreAddress is the artifact store as node reaches it, by the rule its composition routes every kept reference -// with (artifactStoreAddress): empty when node reaches none. A node composing its own modules reads a templates archive -// there (issue 1126). -func ArtifactStoreAddress(in Input, node string) (string, error) { - return newComposer(in).artifactStoreAddress(node) -} - -// artifactStoreAddress is the artifact store as `forNode` reaches it: `.internal:` over the private network, or -// `127.0.0.1:` for the node that holds the store itself when nothing is on the network yet, and empty for any other -// (novox/hq 04-ISSUES/102). -func (c *composer) artifactStoreAddress(forNode string) (string, error) { - onNetwork, err := c.whereEveryoneIs() - if err != nil { - return "", err - } - on := map[string]bool{} - for name := range onNetwork { - on[name] = true - } - node, port, found, err := c.artifactStoreOnNetwork(on) - if err != nil { - return "", err - } - if found { - return c.internalName(node) + ":" + port, nil - } - all := map[string]bool{} - for _, n := range c.in.Nodes { - all[n.Name] = true - } - holder, port, found, err := c.artifactStoreOnNetwork(all) - if err != nil || !found || holder != forNode { - return "", err - } - return "127.0.0.1:" + port, nil -} - -// zonesInTheMesh is every zone a module in the mesh declares, where the mesh placed it (novox/hq ADR 0199): the zone settled -// from that node's settings, the node's private address, the port the answering listen is published on there. A node whose set -// does not compose declares nothing and is passed over; what the mesh refuses about the zones together is refused, by name. -func (c *composer) zonesInTheMesh() ([]catalogue.ZoneAt, error) { - address := map[string]string{} - for _, n := range c.in.sorted() { - if strings.TrimSpace(n.Place.Address) != "" { - address[n.Name] = n.Place.Address - } - } - var zones []catalogue.ZoneAt - var public []string - for _, n := range c.in.sorted() { - resolved, err := c.planFor(n.Name) - switch { - case unresolvable(err): - continue - case err != nil: - return nil, fmt.Errorf("the zones %s answers cannot be read: %w", n.Name, err) - } - plan := resolved.Plan - if plan.PublicDomain != "" { - public = append(public, plan.PublicDomain) - } - for _, m := range plan.Modules { - if m.Zone == nil { - continue - } - at := address[n.Name] - if at == "" { - // Not on the private network yet: nothing could reach its answerer. - continue - } - published, layers, err := c.portsGivenOn(n.Name, m) - if err != nil { - return nil, fmt.Errorf("the zone %s declares on %s cannot be read: %w", m.Module, n.Name, err) - } - z, err := catalogue.ZoneOn(m, layers, published, n.Name, at) - if err != nil { - return nil, err - } - zones = append(zones, *z) - } - } - if problems := catalogue.ZonesProblems(zones, c.suffix(), public); len(problems) > 0 { - return nil, fmt.Errorf("the mesh's zones cannot be forwarded:\n - %s", strings.Join(problems, "\n - ")) - } - return zones, nil -} - -// replicatedHolders is, for each replicated mesh seat, every machine on the private network holding it, by internal name at its -// private address (novox/hq ADR 0223): the holders on record, and only the sole claimant when there are none. A holder off -// the private network is left out. -func (c *composer) replicatedHolders() map[string]map[string]string { - var replicated []catalogue.Seat - for _, s := range catalogue.Seats() { - if s.Replicated && s.Scope == catalogue.ScopeMesh { - replicated = append(replicated, s) - } - } - if len(replicated) == 0 { - return nil - } - address := map[string]string{} - for _, p := range c.onTheNetwork() { - address[p.Name] = p.Place.Address - } - // Every module of the catalogue with the nodes it is assigned on, sorted, as the controller's Catalogued answers. - on := map[string][]string{} - for _, n := range c.in.sorted() { - for _, module := range n.Assigned { - on[module] = append(on[module], n.Name) - } - } - names := make([]string, 0, len(c.in.Catalogue)) - for name := range c.in.Catalogue { - names = append(names, name) - } - sort.Strings(names) - out := map[string]map[string]string{} - for _, seat := range replicated { - var nodes []string - for _, h := range c.in.Holdings { - if hs, ok := catalogue.SeatNamed(h.Claim); ok && hs.Name == seat.Name && h.Scope == seat.Scope { - nodes = append(nodes, h.Node) - } - } - if len(nodes) == 0 { - var derived []string - for _, name := range names { - for _, cl := range c.in.Catalogue[name].Claims { - if cs, ok := catalogue.SeatNamed(cl.Name); ok && cs.Name == seat.Name && cl.At() == seat.Scope { - derived = append(derived, on[name]...) - } - } - } - if len(derived) == 1 { - nodes = derived - } - } - at := map[string]string{} - for _, n := range nodes { - if address[n] != "" { - at[c.internalName(n)] = address[n] - } - } - out[seat.Name] = at - } - return out -} - -// seatsOn is where a node put the holder of each mesh-scoped seat, by seat and by the port the holder's software uses: what -// ${seat:...} answers with (novox/hq 04-ISSUES/102). Read for every module in the catalogue that claims a seat, in this node's set -// or not; a holder in this node's set wins over one that is not. -func (c *composer) seatsOn(node Node, inSet []catalogue.Manifest, adopted bool, taken map[string]bool) (map[string]map[int]int, error) { - assigned := map[string]bool{} - for _, m := range inSet { - assigned[m.Module] = true - } - names := make([]string, 0, len(c.in.Catalogue)) - for name := range c.in.Catalogue { - names = append(names, name) - } - sort.Strings(names) - seats := map[string]map[int]int{} - for _, name := range names { - m := c.in.Catalogue[name] - var claims []string - for _, cl := range m.Claims { - if cl.At() == catalogue.ScopeMesh { - claims = append(claims, cl.Name) - } - } - if len(claims) == 0 { - continue - } - // Only a port the node was given or the mesh assigned, never the manifest's own number. And on an adopted node a holder - // assigned but not yet taken is the found container, so its mesh-assigned ports do not count there; a given port does. - ports, _, err := c.portsGivenOn(node.Name, m) - if err != nil { - return nil, err - } - if adopted && !taken[name] { - layers := node.Settings[m.Module] - ports = map[int]int{} - if given, err := catalogue.GivenPorts(m, layers); err == nil { - ports = given - } - } - if len(ports) == 0 { - continue - } - for _, seat := range claims { - if seats[seat] == nil { - seats[seat] = map[int]int{} - } - for wanted, at := range ports { - if _, said := seats[seat][wanted]; said && !assigned[name] { - continue - } - seats[seat][wanted] = at - } - } - } - return seats, nil -} - -// localNetworksOf is a machine's attached networks judged (novox/hq ADR 0340): which count as its local network, from what it -// last said of itself and the links it said face outside it. A machine that has said none judges to nothing. -func localNetworksOf(n Node) []catalogue.LocalNetwork { - if !n.NetworksSaid { - return nil - } - return catalogue.JudgeLocalNetworks(n.Networks, n.OutwardLinks, overlay.Interface) -} diff --git a/go/compose/rendering_test.go b/go/compose/rendering_test.go deleted file mode 100644 index 7ba4a88..0000000 --- a/go/compose/rendering_test.go +++ /dev/null @@ -1,329 +0,0 @@ -package compose - -import ( - "errors" - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/overlay" -) - -// aWebModule publishes one port in the short form, which the mesh is free to move (MachineSide). -func aWebModule() catalogue.Manifest { - return catalogue.Manifest{Module: "web", Version: "1", - Listens: []catalogue.Listening{{Name: "http", Port: 8080, From: catalogue.FromMesh}}, - Resources: []map[string]any{{"id": "server", "type": "container", "name": "web", "ports": []any{"8080"}, - "image": "registry.example/web@sha256:" + strings.Repeat("a", 64)}}} -} - -// aStoreModule is mesh-controller's sendable_integration_test.go TestConsumersAreToldTheGivenPort store (cmd/mesh-controller -// at 6c2a8d62): publishing 5432:5432, offering a database to the mesh. -func aStoreModule() catalogue.Manifest { - return catalogue.Manifest{Module: "store", Version: "1", - Provides: []catalogue.Offer{{Name: "database", Scope: catalogue.ScopeMesh}}, - Listens: []catalogue.Listening{{Port: 5432, From: catalogue.FromMesh}}, - Guards: []int{5432}, - Resources: []map[string]any{{"id": "server", "type": "container", "name": "mesh-store", - "ports": []any{"5432:5432"}, - "image": "registry.example/pg@sha256:" + strings.Repeat("b", 64)}}} -} - -func placed(t *testing.T, in Input, node string) Placed { - t.Helper() - got, err := Places(in, node) - if err != nil { - t.Fatalf("%v", err) - } - return got -} - -// 1363/AC1 and AC2: a port the Input holds as allocated on the node is the one composed; with none, no number is invented: the -// manifest's own is composed and the port is named foreseen, as the Foreseeing mode does. -func TestAnAllocatedPortIsComposedAndAnUnallocatedOneIsNamedForeseenNotInvented(t *testing.T) { - mk := func() Input { - in := aMesh(t) - in.Catalogue["web"] = aWebModule() - nodeOf(t, &in, "laptop").Assigned = append(nodeOf(t, &in, "laptop").Assigned, "web") - return in - } - in := mk() - got := placed(t, in, "laptop") - if p := got.Rendering.Ports["web"][8080]; p != 8080 { - t.Errorf("a port nobody allocated is composed as %d, not as the manifest's own", p) - } - if len(got.ForeseenPorts) != 1 || got.ForeseenPorts[0] != "web:8080" { - t.Errorf("foreseen ports are %v", got.ForeseenPorts) - } - nodeOf(t, &in, "laptop").Ports = map[string]map[int]int{"web": {8080: 20123}} - got = placed(t, in, "laptop") - if p := got.Rendering.Ports["web"][8080]; p != 20123 || len(got.ForeseenPorts) != 0 { - t.Errorf("the allocated port is composed as %d (foreseen %v)", p, got.ForeseenPorts) - } - // A port the module does not publish is not the mesh's to move, so it is never foreseen. - m := aWebModule() - m.Resources = nil - in.Catalogue["web"] = m - nodeOf(t, &in, "laptop").Ports = nil - got = placed(t, in, "laptop") - if got.Rendering.Ports["web"][8080] != 8080 || len(got.ForeseenPorts) != 0 { - 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 -// (mesh-controller sendable_integration_test.go TestConsumersAreToldTheGivenPort). -func TestAPortTheOperatorGaveIsUsedAndNotReallocated(t *testing.T) { - in := aMesh(t) - in.Catalogue["store"] = aStoreModule() - in.Catalogue["app"] = catalogue.Manifest{Module: "app", Version: "1", Requires: []string{"database"}} - anchor := nodeOf(t, &in, "anchor") - anchor.Assigned = append(anchor.Assigned, "store") - nodeOf(t, &in, "laptop").Assigned = append(nodeOf(t, &in, "laptop").Assigned, "app") - anchor.Ports = map[string]map[int]int{"store": {5432: 20999}} - anchor.Settings = map[string][]catalogue.Layer{"store": {{From: "anchor", - Placement: map[string]any{catalogue.PortsSetting: map[string]any{"5432": 5433}}}}} - - got := placed(t, in, "anchor") - if p := got.Rendering.Ports["store"][5432]; p != 5433 { - t.Errorf("the store is on %d, not the port it was given", p) - } - if g := got.Rendering.Given["store"][5432]; g != 5433 { - t.Errorf("the given port is %d", g) - } - if len(got.ForeseenPorts) != 0 { - t.Errorf("a given port is foreseen: %v", got.ForeseenPorts) - } - resolved, err := Resolve(in, "laptop") - if err != nil { - t.Fatal(err) - } - if told := need(t, resolved.Plan, "app", "database").Serves["port"]; told != 5433 { - t.Errorf("the consumer is told the store is on %v", told) - } - - // One holder per machine port across the node's modules: two given the same refused, in the controller's words. - in.Catalogue["store2"] = func() catalogue.Manifest { - m := aStoreModule() - m.Module, m.Provides = "store2", nil - m.Resources = []map[string]any{{"id": "server", "type": "container", "name": "other", "ports": []any{"5432:5432"}, - "image": "registry.example/pg@sha256:" + strings.Repeat("c", 64)}} - return m - }() - anchor.Assigned = append(anchor.Assigned, "store2") - anchor.Settings["store2"] = []catalogue.Layer{{From: "anchor", - Placement: map[string]any{catalogue.PortsSetting: map[string]any{"5432": 5433}}}} - _, err = Places(in, "anchor") - if err == nil || !errors.Is(err, ErrPortTaken) || !strings.Contains(err.Error(), "store and store2 are both given machine port 5433 on anchor") && - !strings.Contains(err.Error(), "store2 and store are both given machine port 5433 on anchor") { - t.Fatalf("two modules given one machine port: %v", err) - } -} - -// 1363/AC4: the seats held and taken reach the Rendering: where each mesh-scoped seat's holder put its ports, from the node's -// given and assigned ports, never the manifest's own; and on an adopted node a holder assigned but not taken counts only by its -// given ports (novox/hq ADR 0100). -func TestSeatsHeldAndTakenReachTheRendering(t *testing.T) { - in := aMesh(t) - holder := aStoreModule() - holder.Claims = []catalogue.Claim{{Name: "mesh-store", Scope: catalogue.ScopeMesh}} - in.Catalogue["store"] = holder - anchor := nodeOf(t, &in, "anchor") - anchor.Assigned = append(anchor.Assigned, "store") - // The shelf is read whole, in this node's set or not: laptop, running none, is told where the anchor's store is only by the - // settings and ports of its own machine, which it has none of. - anchor.Ports = map[string]map[int]int{"store": {5432: 20999}} - got := placed(t, in, "anchor") - if at := got.Rendering.Seats["mesh-store"][5432]; at != 20999 { - t.Errorf("the seat's holder is told to be on %d", at) - } - if len(placed(t, in, "laptop").Rendering.Seats) != 0 { - t.Errorf("laptop is told of a holder it has no port for: %v", placed(t, in, "laptop").Rendering.Seats) - } - // Adopted, and the holder assigned but not taken: the found container's ports, not the mesh's assignment. - anchor.Adopted = true - if len(placed(t, in, "anchor").Rendering.Seats) != 0 { - t.Errorf("an untaken holder on an adopted node counts by the mesh's assignment: %v", placed(t, in, "anchor").Rendering.Seats) - } - anchor.Taken = []string{"store"} - got = placed(t, in, "anchor") - if at := got.Rendering.Seats["mesh-store"][5432]; at != 20999 || !got.Rendering.Adopted || !got.Rendering.Taken["store"] { - t.Errorf("a taken holder: seats %v adopted %v taken %v", got.Rendering.Seats, got.Rendering.Adopted, got.Rendering.Taken) - } -} - -// 1363/AC4: local networks and outward links (mesh-controller local_network_test.go TestNodeShowsWhichAttachedNetworksCountAndWhy, -// the values): a private network on a link facing outside counts, a public subnet and the private network's own link never do; a -// machine that said none judges to nothing. -func TestLocalNetworksAndOutwardLinksReachTheRendering(t *testing.T) { - in := aMesh(t) - laptop := nodeOf(t, &in, "laptop") - laptop.OutwardLinks = []string{"eth0", "eth1"} - laptop.Networks = []catalogue.AttachedNetwork{ - {Link: "eth0", Network: "192.168.1.10/24"}, {Link: "eth1", Network: "203.0.113.7/24"}, {Link: overlay.Interface, Network: "10.10.0.4/24"}} - laptop.NetworksSaid = true - got := placed(t, in, "laptop").Rendering - if len(got.LocalNetworks) != 1 || got.LocalNetworks[0] != "192.168.1.0/24" { - t.Errorf("the local networks are %v", got.LocalNetworks) - } - if len(got.OutwardLinks) != 2 || got.OutwardLinks[0] != "eth0" { - t.Errorf("the outward links are %v", got.OutwardLinks) - } - laptop.NetworksSaid = false - if got := placed(t, in, "laptop").Rendering; len(got.LocalNetworks) != 0 { - t.Errorf("a machine that never reported has local networks %v", got.LocalNetworks) - } -} - -// 1363/AC4: accounts, the names of the mesh, the mesh range and the operator's export reach the Rendering. -func TestAccountsNamesAndTheOperatorsExportReachTheRendering(t *testing.T) { - in := aMesh(t) - nodeOf(t, &in, "anchor").Account = "jochens" - in.OperatorExport = &catalogue.KeptExport{} - in.OverlayCIDR = "10.99.0.0/16" - got := placed(t, in, "anchor").Rendering - if got.Accounts["anchor"] != "jochens" || len(got.Accounts) != 1 { - t.Errorf("accounts are %v", got.Accounts) - } - if got.Names["anchor.internal"] != "10.77.0.1" || got.Names["laptop.internal"] != "10.77.0.2" || len(got.Names) != 2 { - t.Errorf("names are %v", got.Names) - } - if len(got.Machines) != 2 || got.Machines["laptop.internal"] != "10.77.0.2" { - t.Errorf("machines are %v", got.Machines) - } - if len(got.Mesh) != 2 || got.Mesh[0] != "10.77.0.1" || got.Mesh[1] != "10.77.0.2" { - t.Errorf("the mesh's addresses are %v", got.Mesh) - } - if got.MeshRange != "10.99.0.0/16" || got.Suffix != "internal" || got.TunnelInterface != overlay.Interface { - t.Errorf("range %q suffix %q tunnel %q", got.MeshRange, got.Suffix, got.TunnelInterface) - } - if got.Kept != nil { - t.Error("the operator's export reaches a node where nothing keeps it") - } - keeper := catalogue.Manifest{Module: "keeper", Version: "1", Keeps: "/var/lib/keeper/export.json"} - in.Catalogue["keeper"] = keeper - nodeOf(t, &in, "anchor").Assigned = append(nodeOf(t, &in, "anchor").Assigned, "keeper") - if got := placed(t, in, "anchor").Rendering; got.Kept != in.OperatorExport { - t.Errorf("a module that keeps the export is not given it: %v", got.Kept) - } - // And the names of a machine that left the network leave with it. - nodeOf(t, &in, "laptop").Assigned = nil - if got := placed(t, in, "anchor").Rendering; len(got.Names) != 1 || got.Names["laptop.internal"] != "" { - t.Errorf("a machine off the network is named: %v", got.Names) - } -} - -// The names the operator gave to peers of an adopted tunnel answer until they enrol (novox/hq issue 112); a node of the mesh owns -// its name. -func TestACarriedPeerIsNamedUntilItEnrols(t *testing.T) { - in := aMesh(t) - in.CarriedPeers = []CarriedPeer{{PublicKey: "K1", Address: "192.0.2.5", Named: "printer"}, - {PublicKey: "K2", Address: "192.0.2.6", Named: "laptop"}, {PublicKey: "K3", Address: "192.0.2.7", Named: "late", EnrolledAs: "late"}} - got := placed(t, in, "anchor").Rendering - if got.Names["printer.internal"] != "192.0.2.5" || got.Names["laptop.internal"] != "10.77.0.2" || got.Names["late.internal"] != "" { - t.Errorf("names are %v", got.Names) - } -} - -// A node-engine older than a contract is not sent the field it would refuse (novox/hq ADR 0240 and after). -func TestTheNodeEnginesContractGatesWhatItIsSent(t *testing.T) { - in := aMesh(t) - laptop := nodeOf(t, &in, "laptop") - if r := placed(t, in, "laptop").Rendering; r.ReadsHealth || r.JudgesRoot || r.ReadsBusy || r.Hands { - t.Errorf("an engine that never stated anything reads %+v", r) - } - laptop.Stated, laptop.Contract = true, ReadinessContract - if r := placed(t, in, "laptop").Rendering; !r.ReadsHealth || r.JudgesRoot || r.ReadsBusy || r.Hands { - t.Errorf("an engine at the readiness contract reads %+v", r) - } - laptop.Contract = HandedContract - if r := placed(t, in, "laptop").Rendering; !r.ReadsHealth || !r.JudgesRoot || !r.ReadsBusy || !r.Hands { - t.Errorf("an engine at the handed contract reads %+v", r) - } -} - -// The artifact store as a node reaches it: over the private network by its holder's name and the port it put it on, or on loopback -// for the holder alone while nothing is on the network (novox/hq 04-ISSUES/102). -func TestTheArtifactStoreIsReachedOverTheNetworkOrOnLoopbackForItsHolder(t *testing.T) { - in := aMesh(t) - in.Catalogue["artifacts"] = catalogue.Manifest{Module: "artifacts", Version: "1", - Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5100}}} - anchor := nodeOf(t, &in, "anchor") - anchor.Assigned = append(anchor.Assigned, "artifacts") - if got := placed(t, in, "laptop").Rendering.ArtifactStore; got != "anchor.internal:5100" { - t.Errorf("laptop reaches the store at %q", got) - } - nodeOf(t, &in, "laptop").Assigned = nil - anchor.Assigned = []string{"artifacts"} // off the network: the holder alone reaches it - if got := placed(t, in, "anchor").Rendering.ArtifactStore; got != "127.0.0.1:5100" { - t.Errorf("the holder reaches the store at %q", got) - } - if got := placed(t, in, "laptop").Rendering.ArtifactStore; got != "" { - t.Errorf("a machine off the network reaches the store at %q", got) - } - in.Built = []string{"mesh-sdk"} - if got := placed(t, in, "anchor").Rendering.Built; !got["mesh-sdk"] { - t.Errorf("built is %v", got) - } -} - -// A zone a module declares is placed where the mesh put it: settled from that node's settings, at the node's private address, on -// the port the answering listen is published on there (novox/hq ADR 0199); the same zone declared twice is refused, by name. -func TestAZoneIsPlacedWhereTheMeshPutItAndTheSameZoneTwiceIsRefused(t *testing.T) { - in := aMesh(t) - lab := catalogue.Manifest{Module: "lab", Version: "1", Zone: &catalogue.Zone{Name: "${setting:zone}", Listen: "dns"}, - Listens: []catalogue.Listening{{Name: "dns", Port: 53, Protocol: "udp", From: catalogue.FromMesh}}} - in.Catalogue["lab"] = lab - anchor := nodeOf(t, &in, "anchor") - anchor.Assigned = append(anchor.Assigned, "lab") - anchor.Settings = map[string][]catalogue.Layer{"lab": {{From: "anchor", Values: map[string]any{"zone": "Lab.Example."}}}} - anchor.Ports = map[string]map[int]int{"lab": {53: 5353}} - zones := placed(t, in, "laptop").Rendering.Zones - if len(zones) != 1 || zones[0] != (catalogue.ZoneAt{Zone: "lab.example", Node: "anchor", Module: "lab", Address: "10.77.0.1", Port: 5353}) { - t.Fatalf("zones are %+v", zones) - } - laptop := nodeOf(t, &in, "laptop") - laptop.Assigned = append(laptop.Assigned, "lab") - laptop.Settings = map[string][]catalogue.Layer{"lab": {{From: "laptop", Values: map[string]any{"zone": "lab.example"}}}} - if _, err := Places(in, "laptop"); err == nil || !strings.Contains(err.Error(), "the mesh's zones cannot be forwarded") { - t.Fatalf("one zone declared twice: %v", err) - } -} - -// Every holder of a replicated seat is listed by internal name at its private address (novox/hq ADR 0223): the holders on record, -// and only the sole claimant when there are none; a holder off the private network is left out. -func TestEveryHolderOfAReplicatedSeatIsListedOnTheNetwork(t *testing.T) { - in := aMesh(t) - resolver := catalogue.Manifest{Module: "resolver", Version: "1", - Provides: []catalogue.Offer{{Name: "wildcard-resolution", Scope: catalogue.ScopeMesh}}, - Claims: []catalogue.Claim{{Name: "mesh-dns-resolver", Scope: catalogue.ScopeMesh}}} - in.Catalogue["resolver"] = resolver - anchor, laptop := nodeOf(t, &in, "anchor"), nodeOf(t, &in, "laptop") - anchor.Assigned = append(anchor.Assigned, "resolver") - // One claimant and nothing on record: the sole claimant is listed. - if got := placed(t, in, "laptop").Rendering.Holders["mesh-dns-resolver"]; len(got) != 1 || got["anchor.internal"] != "10.77.0.1" { - t.Errorf("the sole claimant: %v", got) - } - // Both on record: both are listed (two claimants with nothing on record would be refused at resolution). - laptop.Assigned = append(laptop.Assigned, "resolver") - in.Holdings = []catalogue.Held{ - {Claim: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Node: "anchor", Module: "resolver"}, - {Claim: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Node: "laptop", Module: "resolver"}} - got := placed(t, in, "laptop").Rendering.Holders["mesh-dns-resolver"] - if len(got) != 2 || got["anchor.internal"] != "10.77.0.1" || got["laptop.internal"] != "10.77.0.2" { - t.Errorf("both holders on record: %v", got) - } - // A holder off the private network is left out: a resolver named at an address nothing answers waits out its timeout. - in.Nodes = append(in.Nodes, Node{Name: "ace", Place: Place{Address: "10.77.0.3"}, Assigned: []string{"resolver"}}) - in.Holdings = append(in.Holdings, catalogue.Held{Claim: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Node: "ace", Module: "resolver"}) - if got := placed(t, in, "laptop").Rendering.Holders["mesh-dns-resolver"]; len(got) != 2 || got["ace.internal"] != "" { - t.Errorf("a holder off the network is listed: %v", got) - } -} diff --git a/go/compose/resolve.go b/go/compose/resolve.go deleted file mode 100644 index 6931adc..0000000 --- a/go/compose/resolve.go +++ /dev/null @@ -1,347 +0,0 @@ -package compose - -import ( - "fmt" - "slices" - "strings" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// LicenceProvision is what a module requires in order to be given a licence (the controller's licences.Provision). -const LicenceProvision = "model-access" - -// Resolved is what one node should run, from what was assigned to it, with the settings of everything that resolved. -type Resolved struct { - Plan catalogue.Resolution - Settings catalogue.SettingsBy - // Foreseen are the needs composed with a stand-in for a credential the next send makes (or holds with no value given - // here), as " on needs from ". Nothing was made. - Foreseen []string - // NotGiven are the words of each credential only a person gives that nobody has: the consumer still resolves without it - // (novox/hq ADR 0337 clause 4). - NotGiven []string -} - -// Resolve works out everything a node should run, from what was assigned to it (the controller's planFor, plan.go): the -// resolution over the rest of the mesh, the credential of each thing the node takes from elsewhere, and the settings of -// every module that resolved. It makes and writes nothing: a credential not held is composed with a stand-in and named -// foreseen, one only a person gives and nobody has is left out and said, one the send would refuse is refused with the -// controller's words. A failure to compose the node's own modules is an error that satisfies IsUnresolvable, told apart -// from an unreadable Input (novox/hq 04-ISSUES/152). -func Resolve(in Input, node string) (Resolved, error) { - return newComposer(in).planFor(node) -} - -// IsUnresolvable reports whether err is a node's own set failing to compose, rather than the mesh being unable to answer. -func IsUnresolvable(err error) bool { return unresolvable(err) } - -// planFor is the controller's planFor, worked out once per node in one composition (issue 1954): each caller has its -// own copy of what a caller may set (a need's fields, as Resolution.Compose sets its Serves), the rest only read. -func (c *composer) planFor(nodeName string) (Resolved, error) { - p, ok := c.plans[nodeName] - if !ok { - p.resolved, p.err = c.planForNow(nodeName) - if c.plans == nil { - c.plans = map[string]planOf{} - } - c.plans[nodeName] = p - } - if p.err != nil { - return Resolved{}, p.err - } - return p.resolved.copied(), nil -} - -// planOf is planFor's answer for one node, kept. -type planOf struct { - resolved Resolved - err error -} - -// copied is r with its own needs, words and settings lists: what a caller may change of it. -func (r Resolved) copied() Resolved { - out := r - out.Plan.Needs = slices.Clone(r.Plan.Needs) - out.Foreseen = slices.Clone(r.Foreseen) - out.NotGiven = slices.Clone(r.NotGiven) - if r.Settings != nil { - out.Settings = make(catalogue.SettingsBy, len(r.Settings)) - for m, layers := range r.Settings { - out.Settings[m] = slices.Clone(layers) - } - } - return out -} - -// planForNow is planFor worked out. -func (c *composer) planForNow(nodeName string) (Resolved, error) { - resolved, err := c.resolution(nodeName) - if err != nil { - return Resolved{}, err - } - node, err := c.in.node(nodeName) - if err != nil { - return Resolved{}, err - } - shelf := c.shelfFor(node) - var out Resolved - - // The credential for each thing this node takes from elsewhere. - for i, n := range resolved.Needs { - // 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) - if licence.Keys == nil { - // The keys are not given: composed with a stand-in and named, not failed for a value only a send reads. - resolved.Needs[i].Sealed = catalogue.ForeseenSealed - out.Foreseen = append(out.Foreseen, fmt.Sprintf("%s on %s needs %s from %s", n.For, nodeName, n.Name, n.From)) - } else { - // Given, and none for this holder: the mesh holds none. - resolved.Needs[i].Sealed = licence.Keys[Holder{Node: nodeName, Module: n.For}] - } - // If this holder is the licence's manager, hand it the manager node's PUBLIC sealing key in its bound facts - // (novox/hq ADR 0050). - if pub := c.managerPublicKeyFor(licence, nodeName, n.For); pub != "" { - serves := map[string]any{} - for k, v := range resolved.Needs[i].Serves { - serves[k] = v - } - serves["manager_public_key"] = pub - resolved.Needs[i].Serves = serves - // The manager holder: its empty pre-adoption refresh token is a waiting state, not a missing consumer key. - resolved.Needs[i].Manager = true - } - continue - } - if credentialless(n) { - // Its provider's offer presents no credential (form none): nothing is sealed, made or stood in for (issue 1126). - continue - } - cred, held := c.credential(n, nodeName) - _, outside := issuedOutsideBy(shelf, n) - switch { - case cred.State == CredentialRefused: - // Said rather than skipped. A machine that resolves cleanly and receives no credential is one that will fail to - // authenticate at some later, less obvious moment. - return Resolved{}, fmt.Errorf("%s on %s needs %s from %s and no credential could be made for it: %s", - n.For, nodeName, n.Name, n.From, cred.Why) - case cred.State == CredentialNotGiven, outside && !(held && cred.State == CredentialHeld): - // Not given, and the rest of the consumer runs (ADR 0337 clause 4): no credential file, so the module can tell - // it was not given; nothing the mesh made stands in for it. - why := cred.Why - if why == "" { - why = "nobody gave it" - } - out.NotGiven = append(out.NotGiven, fmt.Sprintf("%s on %s holds no %s from %s: %s", n.For, nodeName, n.Name, n.From, why)) - continue - case cred.State == CredentialHeld && cred.Sealed != "": - resolved.Needs[i].Sealed = cred.Sealed - default: - // Held with no value here, or none held: the next send makes it (or reads it), and a stand-in is composed. - resolved.Needs[i].Sealed = catalogue.ForeseenSealed - out.Foreseen = append(out.Foreseen, fmt.Sprintf("%s on %s needs %s from %s", n.For, nodeName, n.Name, n.From)) - } - } - - // Settings for everything that resolved, including modules nobody assigned directly: a requirement pulled in by - // something else is still configurable. - settings := catalogue.SettingsBy{} - for _, m := range resolved.Modules { - layers := node.Settings[m.Module] - if len(layers) == 0 { - continue - } - settings[m.Module] = append([]catalogue.Layer(nil), layers...) - } - out.Plan, out.Settings = resolved, settings - return out, nil -} - -// credential is how the pair credential of one need stands in the Input, and whether the Input names it at all. -func (c *composer) credential(n catalogue.Needed, consumer string) (Credential, bool) { - for _, cr := range c.in.Credentials { - if cr.Provision == n.Name && cr.Consumer == consumer && cr.ConsumerModule == n.For && cr.Provider == n.From && cr.Local == n.Local { - return cr, true - } - } - return Credential{State: CredentialWouldMake}, false -} - -// licence is the licence of that name. -func (c *composer) licence(name string) (Licence, bool) { - for _, l := range c.in.Licences { - if l.Name == name { - return l, true - } - } - return Licence{}, false -} - -// managerPublicKeyFor is the manager node's public sealing key, but only when (node, module) is the licence's manager holder -// and empty otherwise (plan.go managerPublicKeyFor): delivered to the manager module in its bound facts so it can re-seal a -// rotated refresh token to this node (novox/hq ADR 0050), and to no consumer. -func (c *composer) managerPublicKeyFor(l Licence, node, module string) string { - if l.Manager == (Holder{}) || node != l.Manager.Node || module != l.Manager.Module { - return "" - } - n, err := c.in.node(node) - if err != nil { - return "" - } - return n.SealingKey -} - -// issuedOutsideBy is whether a need's provision is offered by its provider with a credential issued outside the mesh -// (novox/hq ADR 0337), and that provider module: the one answering it when known, else any that offers it so. -func issuedOutsideBy(shelf map[string]catalogue.Manifest, n catalogue.Needed) (string, bool) { - if n.Module != "" { - if m, known := shelf[n.Module]; known { - return n.Module, m.IssuedOutsideOf(n.Name) - } - } - return catalogue.OffersIssuedOutside(shelf, n.Name) -} - -// issuedToHolder is whether the vault keeps a need's credential for its holder alone (issues 749, 757 and 779). -func issuedToHolder(shelf map[string]catalogue.Manifest, n catalogue.Needed) bool { - if n.ByRecord || n.Module == "" { - return false - } - provider, known := shelf[n.Module] - if !known || !movedProvider(provider) { - return false - } - if _, issued := provider.IssuedCredentialOf(n.Name); issued { - return true - } - return provider.CredentialFormOf(n.Name) == catalogue.CredentialFormPair -} - -// providerMoved is whether a need is answered by a module marked moved (issue 788), or by one whose offer of the provision -// says the provider helper answers it (issue 792). -func providerMoved(shelf map[string]catalogue.Manifest, n catalogue.Needed) bool { - if n.ByRecord || n.Module == "" { - return false - } - provider, known := shelf[n.Module] - 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 { - return m.Moved || slices.ContainsFunc(m.Claims, func(c catalogue.Claim) bool { return c.Name == "mesh-vault" }) -} - -// resolution works out what one node should run, from what was assigned to it: the controller's planFor up to and -// including the resolution (plan.go planFor), and no further. A failure to compose the node's own modules is wrapped as -// notResolvable; every other failure is returned as it is. -func (c *composer) resolution(nodeName string) (catalogue.Resolution, error) { - node, err := c.in.node(nodeName) - if err != nil { - return catalogue.Resolution{}, err - } - shelf := c.shelfFor(node) - world, _, left, err := c.theRestOfTheMesh(nodeName) - if err != nil { - return catalogue.Resolution{}, err - } - world.Pinned = node.Pins - // Where each of its consumers of a provision that keeps data was last sent (novox/hq ADR 0232). - world.Bound = node.Bound - - onNetwork, err := c.whereEveryoneIs() - if err != nil { - return catalogue.Resolution{}, err - } - - // What this mesh can answer with a record rather than a machine, and which record each of this node's modules was - // put on. - world.Licences, world.Using = c.licencesFor(nodeName) - - resolved, err := catalogue.Resolve(shelf, node.Assigned, - catalogue.Node{Name: nodeName, Site: node.Place.Site, Capabilities: node.Capabilities, - At: onNetwork[nodeName], PublicDomain: node.PublicDomain, - Account: node.Account, AccountHome: node.AccountHome, - AgentAccount: node.AgentAccount, AgentAccountHome: node.AgentAccountHome}, world) - if err != nil { - // What another node's set would have offered is missing when that set does not resolve here: said with the - // refusal, so "nothing provides" is read beside why (issue 1126, probe #532). - if len(left) > 0 { - var why []string - for _, l := range left { - why = append(why, l.String()) - } - err = fmt.Errorf("%w\n\nand %d node(s) offer nothing here, as they could not be resolved: %s", err, len(left), strings.Join(why, "; ")) - } - if len(c.unhostable) > 0 { - err = fmt.Errorf("%w\n\nand these modules offer nothing here, as their node cannot run them in this composition: %s", - err, strings.Join(c.unhostable, "; ")) - } - // The node's own set does not compose: the only failure a mesh-wide gatherer may pass over. - return catalogue.Resolution{}, notResolvable{err} - } - return resolved, nil -} - -// shelfFor is the catalogue as one node composes its own set: the Input's, with each recorded or core module the node keeps at the -// build it runs put in place of the current one (plan.go keepRecorded). A copy: the Input's catalogue is untouched. Only the node's own planFor and -// resolution use it; the rest of the mesh and who is on the network are read with the current manifests, as the controller reads them -// before it keeps a recorded module. -func (c *composer) shelfFor(node Node) map[string]catalogue.Manifest { - if len(node.Kept) == 0 { - return c.in.Catalogue - } - shelf := make(map[string]catalogue.Manifest, len(c.in.Catalogue)+len(node.Kept)) - for name, m := range c.in.Catalogue { - shelf[name] = m - } - for name, m := range node.Kept { - shelf[name] = m - } - return shelf -} - -// licencesFor is what this node can be answered with by record, and what it was put on. A mesh with no licences at all -// is the ordinary case and not an error. -func (c *composer) licencesFor(node string) (map[string][]catalogue.Record, map[string]map[string]catalogue.Record) { - all := c.in.Licences - if len(all) == 0 { - return nil, nil - } - offered := map[string][]catalogue.Record{} - byName := map[string]catalogue.Record{} - for _, one := range all { - record := catalogue.Record{Name: one.Name, Serves: one.Serves} - offered[LicenceProvision] = append(offered[LicenceProvision], record) - byName[one.Name] = record - } - using := map[string]map[string]catalogue.Record{} - for _, one := range all { - for _, h := range one.Holders { - if h.Node != node { - continue - } - if using[h.Module] == nil { - using[h.Module] = map[string]catalogue.Record{} - } - using[h.Module][LicenceProvision] = byName[one.Name] - } - } - return offered, using -} diff --git a/go/compose/resolve_test.go b/go/compose/resolve_test.go deleted file mode 100644 index 8bb30b8..0000000 --- a/go/compose/resolve_test.go +++ /dev/null @@ -1,388 +0,0 @@ -package compose - -import ( - "errors" - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/overlay" -) - -// storeManifests is mesh-controller's bindings_test.go storeManifests (cmd/mesh-controller at 6c2a8d62): a store holding the -// mesh-store seat, a resolver holding mesh-dns-resolver, a network that requires wildcard resolution, and a board that -// requires a database. -func storeManifests() []catalogue.Manifest { - return []catalogue.Manifest{ - {Module: "store", Version: "1", - Provides: []catalogue.Offer{{Name: "postgres-database", Scope: catalogue.ScopeMesh}}, - Claims: []catalogue.Claim{{Name: "mesh-store", Scope: catalogue.ScopeMesh}}, - Serves: map[string]map[string]any{"postgres-database": {"port": 5432}}, - Grants: map[string]string{"postgres-database": "/var/lib/mesh/store/grants"}}, - {Module: "resolver", Version: "1", - Provides: []catalogue.Offer{{Name: "wildcard-resolution", Scope: catalogue.ScopeMesh}}, - Claims: []catalogue.Claim{{Name: "mesh-dns-resolver", Scope: catalogue.ScopeMesh}}}, - {Module: "network", Version: "1", Requires: []string{"wildcard-resolution"}}, - {Module: "board", Version: "1", Requires: []string{"postgres-database"}}, - } -} - -func need(t *testing.T, plan catalogue.Resolution, consumer, provision string) catalogue.Needed { - t.Helper() - for _, n := range plan.Needs { - if n.For == consumer && n.Name == provision { - return n - } - } - t.Fatalf("no %s for %s: %+v", provision, consumer, plan.Needs) - return catalogue.Needed{} -} - -// theStoreMesh is the mesh of mesh-controller's bindings_integration_test.go -// TestTheIncidentAConsumerStaysBesideItsDataUntilAPersonPinsIt: the anchor's store and resolver hold the mesh's seats on -// record; the laptop runs its own of each, and a network and a board. -func theStoreMesh(t *testing.T) Input { - t.Helper() - in := aMesh(t) - for _, m := range storeManifests() { - in.Catalogue[m.Module] = m - } - anchor, laptop := nodeOf(t, &in, "anchor"), nodeOf(t, &in, "laptop") - anchor.Assigned = append(anchor.Assigned, "store", "resolver") - laptop.Assigned = append(laptop.Assigned, "store", "resolver", "network", "board") - in.Holdings = []catalogue.Held{ - {Claim: "mesh-store", Scope: catalogue.ScopeMesh, Node: "anchor", Module: "store", Site: "here"}, - {Claim: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Node: "anchor", Module: "resolver", Site: "here"}, - } - return in -} - -// 1362/AC7 and AC4 (bindings_integration_test.go): a consumer of a provision that keeps its data stays beside its data, one that -// does not follows the seat; the laptop's store taken away, the consumer bound there on record is refused, not moved to the -// anchor's empty one; a person's pin moves it. -func TestAConsumerStaysBesideItsDataUntilAPersonPinsIt(t *testing.T) { - in := theStoreMesh(t) - got, err := Resolve(in, "laptop") - if err != nil { - t.Fatal(err) - } - if n := need(t, got.Plan, "board", "postgres-database"); n.From != "laptop" || n.Module != "store" || !n.KeepsData { - t.Fatalf("the consumer was bound to %s/%s (keeps data: %v); its data is beside it", n.From, n.Module, n.KeepsData) - } - if n := need(t, got.Plan, "network", "wildcard-resolution"); n.From != "anchor" || n.KeepsData { - t.Fatalf("the resolver was bound to %s (keeps data: %v); its seat is held on anchor (issue 258)", n.From, n.KeepsData) - } - - // Sent, and recorded: the binding to data. The laptop's store taken away: refused, not moved to the anchor's empty one. - laptop := nodeOf(t, &in, "laptop") - laptop.Bound = map[string]map[string]catalogue.Chosen{"board": {"postgres-database": {Node: "laptop", Module: "store"}}} - laptop.Assigned = []string{overlay.Name, "resolver", "network", "board"} - _, err = Resolve(in, "laptop") - if err == nil || !IsUnresolvable(err) || - !strings.Contains(err.Error(), "board on laptop is bound to laptop/store") || - !strings.Contains(err.Error(), "pin laptop postgres-database anchor store") { - t.Fatalf("the consumer's store went and it was answered elsewhere: %v", err) - } - - // A person pins the anchor's: it moves, and the resolution says where it was bound (Kept is empty, as it moves). - laptop.Pins = map[string]catalogue.Chosen{"postgres-database": {Node: "anchor", Module: "store"}} - got, err = Resolve(in, "laptop") - if err != nil { - t.Fatal(err) - } - if n := need(t, got.Plan, "board", "postgres-database"); n.From != "anchor" { - t.Fatalf("pinned to the anchor and bound to %s", n.From) - } -} - -// 1362/AC4: a data-keeping consumer bound on record to a provider elsewhere is kept on that provider, and the resolution says -// so (Resolution.Kept) when the mesh would answer it otherwise. -func TestADataKeepingConsumerBoundElsewhereIsKeptThereAndSaid(t *testing.T) { - in := theStoreMesh(t) - laptop := nodeOf(t, &in, "laptop") - laptop.Assigned = []string{overlay.Name, "resolver", "network", "board"} // no store of its own - laptop.Bound = map[string]map[string]catalogue.Chosen{"board": {"postgres-database": {Node: "anchor", Module: "store"}}} - got, err := Resolve(in, "laptop") - if err != nil { - t.Fatal(err) - } - if n := need(t, got.Plan, "board", "postgres-database"); n.From != "anchor" || !n.KeepsData { - t.Fatalf("the consumer is bound to %s (keeps data: %v), not where it was sent", n.From, n.KeepsData) - } - if len(got.Plan.Kept) != 0 { - t.Errorf("a binding that was kept where it is is said as a kept move: %+v", got.Plan.Kept) - } - // And bound somewhere else on record while the mesh would answer from the anchor: kept, and said. - laptop.Bound = map[string]map[string]catalogue.Chosen{"board": {"postgres-database": {Node: "home", Module: "store"}}} - home := Node{Name: "home", Place: Place{Site: "there", Address: "10.77.0.7"}, Assigned: []string{overlay.Name, "store"}, - Capabilities: map[string]bool{"container-runtime": true, "wireguard": true, "systemd": true}} - in.Nodes = append(in.Nodes, home) - got, err = Resolve(in, "laptop") - if err != nil { - t.Fatal(err) - } - if n := need(t, got.Plan, "board", "postgres-database"); n.From != "home" { - t.Errorf("the consumer was moved from where it was sent: %s", n.From) - } -} - -// A provider module whose offer says only a person gives its credential (ADR 0337), and a consumer of it. -func outsideObjects() catalogue.Manifest { - m := objects() - m.Provides[0].Credential = &catalogue.OfferCredential{IssuedBy: catalogue.IssuedOutside} - return m -} - -func theObjectsMesh(t *testing.T, provider catalogue.Manifest) Input { - t.Helper() - in := aMesh(t) - in.Catalogue[provider.Module], in.Catalogue["files"] = provider, files() - nodeOf(t, &in, "anchor").Assigned = append(nodeOf(t, &in, "anchor").Assigned, provider.Module) - nodeOf(t, &in, "laptop").Assigned = append(nodeOf(t, &in, "laptop").Assigned, "files") - nodeOf(t, &in, "anchor").SealingKey = "anchor-key" - nodeOf(t, &in, "laptop").SealingKey = "laptop-key" - return in -} - -func theFilesNeed(t *testing.T, got Resolved) catalogue.Needed { - t.Helper() - return need(t, got.Plan, "files", "s3-bucket") -} - -// 1362/AC1: a pair credential the Input names as existing with no value, and one it names not at all, are composed with a -// stand-in and named foreseen, and nothing is made; one with a value is composed with it and foreseen is empty. -func TestAPairCredentialNotHeldIsComposedWithAStandInAndNamedForeseen(t *testing.T) { - mk := func() Input { return theObjectsMesh(t, objects()) } - for name, creds := range map[string][]Credential{ - "no entry": nil, - "held with no value": {{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", State: CredentialHeld}}, - "to be made": {{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", State: CredentialWouldMake}}, - } { - in := mk() - in.Credentials = creds - got, err := Resolve(in, "laptop") - if err != nil { - t.Fatalf("%s: %v", name, err) - } - if n := theFilesNeed(t, got); n.Sealed != catalogue.ForeseenSealed { - t.Errorf("%s: the credential is %q, not the stand-in", name, n.Sealed) - } - if len(got.Foreseen) != 1 || got.Foreseen[0] != "files on laptop needs s3-bucket from anchor" { - t.Errorf("%s: foreseen is %v", name, got.Foreseen) - } - } - in := mk() - in.Credentials = []Credential{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", - State: CredentialHeld, Sealed: "sealed-to-laptop"}} - got, err := Resolve(in, "laptop") - if err != nil { - t.Fatal(err) - } - if n := theFilesNeed(t, got); n.Sealed != "sealed-to-laptop" || len(got.Foreseen) != 0 { - t.Errorf("a held credential: %q foreseen %v", n.Sealed, got.Foreseen) - } - unchanged(t, mk, func(in Input) { - if _, err := Resolve(in, "laptop"); err != nil { - t.Fatal(err) - } - }) -} - -// 1362/AC2: a credential only a person gives and nobody has is not given and not made: the consumer still resolves without it -// (novox/hq ADR 0337 clause 4), said with the controller's words; one given is composed with it. -func TestACredentialOnlyAPersonGivesIsNotMadeAndTheConsumerStillResolves(t *testing.T) { - for name, creds := range map[string][]Credential{ - "no entry": nil, - "to be made (never, for one issued outside)": {{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", State: CredentialWouldMake}}, - "not given": {{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", State: CredentialNotGiven, - Why: "the value held was made by the mesh, which objects's software would refuse"}}, - } { - in := theObjectsMesh(t, outsideObjects()) - in.Credentials = creds - got, err := Resolve(in, "laptop") - if err != nil { - t.Fatalf("%s: %v", name, err) - } - if n := theFilesNeed(t, got); n.Sealed != "" { - t.Errorf("%s: a value stands in for what nobody gave: %q", name, n.Sealed) - } - if len(got.Foreseen) != 0 || len(got.NotGiven) != 1 || !strings.Contains(got.NotGiven[0], "files on laptop holds no s3-bucket from anchor: ") { - t.Errorf("%s: foreseen %v not given %v", name, got.Foreseen, got.NotGiven) - } - if len(got.Plan.Modules) == 0 { - t.Errorf("%s: the machine did not resolve", name) - } - } - in := theObjectsMesh(t, outsideObjects()) - in.Credentials = []Credential{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", - State: CredentialHeld, Sealed: "given-and-sealed"}} - got, err := Resolve(in, "laptop") - if err != nil || theFilesNeed(t, got).Sealed != "given-and-sealed" || len(got.NotGiven) != 0 { - t.Fatalf("a value a person gave: %+v %v", got.NotGiven, err) - } -} - -// 1362/AC3: a credential the send would refuse, a given value sealed to a key a machine no longer has, refuses the resolution with -// today's words, and is not a node whose set does not compose. -func TestACredentialTheSendWouldRefuseRefusesTheResolutionInTheSendsWords(t *testing.T) { - why := "files's \"s3-bucket\" credential from anchor was accepted from a person, and a sealing key at one end has changed since. " + - "The mesh cannot re-seal a value it does not hold: accept it again with `secret accept laptop files s3-bucket --provider anchor`" - in := theObjectsMesh(t, objects()) - in.Credentials = []Credential{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", - State: CredentialRefused, Why: why}} - _, err := Resolve(in, "laptop") - want := "files on laptop needs s3-bucket from anchor and no credential could be made for it: " + why - if err == nil || err.Error() != want { - t.Fatalf("got %v, want %q", err, want) - } - if IsUnresolvable(err) { - t.Error("a refused credential is a node whose set does not compose") - } -} - -// A record-answered need is filled with the licence key sealed to this holder, and the manager holder is handed the manager node's -// public sealing key (novox/hq ADR 0050) and no other. -func TestARecordAnsweredNeedGetsItsKeyAndTheManagerItsPublicKey(t *testing.T) { - in := aMesh(t) - in.Catalogue["assistant"] = catalogue.Manifest{Module: "assistant", Version: "1", Requires: []string{LicenceProvision}} - laptop := nodeOf(t, &in, "laptop") - laptop.Assigned = append(laptop.Assigned, "assistant") - laptop.SealingKey = "laptop-key" - in.Licences = []Licence{{Name: "personal", Serves: map[string]any{"model": "x"}, - Holders: []Holder{{Node: "laptop", Module: "assistant"}}, - Keys: map[Holder]string{{Node: "laptop", Module: "assistant"}: "key-sealed-to-laptop"}}} - got, err := Resolve(in, "laptop") - if err != nil { - t.Fatal(err) - } - n := need(t, got.Plan, "assistant", LicenceProvision) - if !n.ByRecord || n.Sealed != "key-sealed-to-laptop" || n.Manager || n.Serves["manager_public_key"] != nil { - t.Fatalf("the record-answered need is %+v", n) - } - in.Licences[0].Manager = Holder{Node: "laptop", Module: "assistant"} - got, err = Resolve(in, "laptop") - if err != nil { - t.Fatal(err) - } - n = need(t, got.Plan, "assistant", LicenceProvision) - if !n.Manager || n.Serves["manager_public_key"] != "laptop-key" || n.Serves["model"] != "x" { - t.Fatalf("the manager is %+v", n) - } - if len(got.Foreseen) != 0 || len(got.NotGiven) != 0 { - t.Errorf("a record-answered need needs no pair credential: %v %v", got.Foreseen, got.NotGiven) - } -} - -// 1362/AC5: a node whose own set does not compose returns an error callers can tell from an unreadable Input. -func TestANodeWhoseOwnSetDoesNotComposeIsToldApartFromAnUnreadableInput(t *testing.T) { - in := aMesh(t) - in.Catalogue["rival-one"] = catalogue.Manifest{Module: "rival-one", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}} - in.Catalogue["rival-two"] = catalogue.Manifest{Module: "rival-two", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}} - laptop := nodeOf(t, &in, "laptop") - laptop.Assigned = append(laptop.Assigned, "rival-one", "rival-two") - if _, err := Resolve(in, "laptop"); err == nil || !IsUnresolvable(err) { - t.Fatalf("a set that does not compose: %v", err) - } - if _, err := Resolve(in, "nowhere"); err == nil || IsUnresolvable(err) || !errors.Is(err, ErrNoSuchNode) { - t.Fatalf("a node the mesh does not have: %v", err) - } - in.Catalogue = nil - if _, err := Resolve(in, "anchor"); err == nil || IsUnresolvable(err) { - t.Fatalf("an Input without the catalogue: %v", err) - } -} - -// 1362/AC6: settings are returned for every resolved module that has layers in the Input, including one pulled in by another. -func TestSettingsAreReturnedForEveryResolvedModuleIncludingOneThatWasPulledIn(t *testing.T) { - in := aMesh(t) - in.Catalogue["lib"] = catalogue.Manifest{Module: "lib", Version: "1", Provides: catalogue.Offers("blob")} - in.Catalogue["app"] = catalogue.Manifest{Module: "app", Version: "1", Requires: []string{"blob"}} - laptop := nodeOf(t, &in, "laptop") - laptop.Assigned = append(laptop.Assigned, "app") - laptop.Settings = map[string][]catalogue.Layer{ - "lib": {{From: "mesh", Values: map[string]any{"x": 1}}}, - "app": {{From: "laptop", Values: map[string]any{"y": 2}}}, - "other": {{From: "laptop", Values: map[string]any{"z": 3}}}, // not resolved here: no settings come of it - "empty": nil, - } - got, err := Resolve(in, "laptop") - if err != nil { - t.Fatal(err) - } - pulled := false - for _, m := range got.Plan.Modules { - pulled = pulled || m.Module == "lib" - } - if !pulled { - t.Fatalf("lib was not pulled in: %+v", got.Plan.Modules) - } - if len(got.Settings) != 2 || got.Settings["lib"][0].Values["x"] != 1 || got.Settings["app"][0].Values["y"] != 2 { - t.Errorf("settings are %+v", got.Settings) - } - // The settings returned are copies: changing them does not change the Input. - got.Settings["app"][0].From = "changed" - if laptop.Settings["app"][0].From != "laptop" { - t.Error("the settings returned share the Input's layers") - } -} - -// Round 1, finding 3: a licence key the Input does not carry (Keys not given) is composed with a stand-in and named foreseen, so the -// node still composes; Keys given that lack the holder is the mesh holding none, and the need stays empty. -func TestALicenceKeyTheInputDoesNotCarryIsAStandInAndNamedForeseen(t *testing.T) { - in := aMesh(t) - in.Catalogue["assistant"] = catalogue.Manifest{Module: "assistant", Version: "1", Requires: []string{LicenceProvision}} - laptop := nodeOf(t, &in, "laptop") - laptop.Assigned = append(laptop.Assigned, "assistant") - in.Licences = []Licence{{Name: "personal", Serves: map[string]any{"model": "x"}, Holders: []Holder{{Node: "laptop", Module: "assistant"}}}} - got, err := Resolve(in, "laptop") - if err != nil { - t.Fatal(err) - } - if n := need(t, got.Plan, "assistant", LicenceProvision); n.Sealed != catalogue.ForeseenSealed { - t.Errorf("a key not given is composed as %q", n.Sealed) - } - if len(got.Foreseen) != 1 || !strings.Contains(got.Foreseen[0], "assistant on laptop needs model-access") { - t.Errorf("foreseen %v", got.Foreseen) - } - // And a whole node composes with it. - if _, problems, err := Compose(in, "laptop"); err != nil || len(problems) != 0 { - t.Errorf("a node using a licence whose key is not carried: %v %v", err, problems) - } - // Keys given, and none for this holder: the mesh holds none. - in.Licences[0].Keys = map[Holder]string{} - got, err = Resolve(in, "laptop") - if err != nil { - t.Fatal(err) - } - if n := need(t, got.Plan, "assistant", LicenceProvision); n.Sealed != "" || len(got.Foreseen) != 0 { - 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"]) - } -} diff --git a/go/compose/rest.go b/go/compose/rest.go deleted file mode 100644 index 7ddb928..0000000 --- a/go/compose/rest.go +++ /dev/null @@ -1,255 +0,0 @@ -package compose - -import ( - "errors" - "fmt" - "slices" - "sort" - "strings" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// notResolvable marks the one failure in a node's resolution that is a statement about the node: its assigned modules -// do not compose. Every other failure means the mesh could not be asked. Whoever gathers across the mesh skips a node -// that cannot resolve and never a node that could not be read (novox/hq 04-ISSUES/152). -type notResolvable struct{ err error } - -func (n notResolvable) Error() string { return n.err.Error() } -func (n notResolvable) Unwrap() error { return n.err } - -// unresolvable reports whether err is a node's own set failing to compose. -func unresolvable(err error) bool { - var n notResolvable - return errors.As(err, &n) -} - -// Passes is what a composition found that the mesh must record, and did not: each seat held once whose holder on record -// is assigned nowhere that exactly one claimant takes over (novox/hq ADR 0449 clause 4), and the lines that say what was -// found. The caller records a pass and writes its hand act; this package does neither. -type Passes struct { - // Passes are the seats to pass, in the order they were found. - Passes []catalogue.SeatPass - // Said are the lines the finding says (a seat two claimants could take, one nothing can). - Said []string -} - -// LeftOut is a node that does not resolve in the view of the rest of the mesh, with why: it offers nothing and holds -// nothing as far as every other machine's plan can tell (novox/hq issue 188). -type LeftOut struct { - Node string - Err error -} - -// String is the line the controller says of it. -func (l LeftOut) String() string { - return fmt.Sprintf("%s is left out of the rest of the mesh: it does not resolve: %v", l.Node, l.Err) -} - -// TheRestOfTheMesh is what every other node holds and offers, as the controller works it out (plan.go theRestOfTheMesh): -// two passes over the others, because what a node offers needs it resolved and resolving it may need what the mesh -// offers. The first takes brokered requirements on trust and answers only what each node offers; the second answers -// everything with that in hand. Nothing is declared from the first. -// -// It writes nothing. A seat the mesh passes on is returned in Passes for the caller to record; the World carries the -// holdings as they stand after the passes, and Input's own are unchanged. A node left out is returned with its reason, in -// the order the controller said them (the first pass's, then the second's when no node is excluded). -func TheRestOfTheMesh(in Input, exclude string) (catalogue.World, Passes, []LeftOut, error) { - c := newComposer(in) - return c.theRestOfTheMesh(exclude) -} - -func (c *composer) theRestOfTheMesh(exclude string) (catalogue.World, Passes, []LeftOut, error) { - r, ok := c.rest[exclude] - if !ok { - r.world, r.passes, r.left, r.err = c.theRestOfTheMeshNow(exclude) - r.unhostable = c.unhostable - if c.rest == nil { - c.rest = map[string]restOf{} - } - c.rest[exclude] = r - } - // Each caller has its own copy of what it may set or add to; the maps and the slices within are only read. - c.unhostable = slices.Clone(r.unhostable) - return r.world, r.passes, slices.Clone(r.left), r.err -} - -// theRestOfTheMeshNow is theRestOfTheMesh worked out: every other machine's set resolved, twice. -func (c *composer) theRestOfTheMeshNow(exclude string) (catalogue.World, Passes, []LeftOut, error) { - shelf := c.in.Catalogue - holdings := append([]catalogue.Held(nil), c.in.Holdings...) - nodes := c.in.sorted() - - onNetwork, err := c.whereEveryoneIs() - if err != nil { - return catalogue.World{}, Passes{}, nil, err - } - - type candidate struct { - node catalogue.Node - assigned []string - pins map[string]catalogue.Chosen - } - var others []candidate - // Every machine's assignments, before the holdings are read by any resolution (novox/hq ADR 0449 clause 4): a seat - // held once whose holder on record is assigned on no machine passes to its only claimant here. - everyAssigned := map[string][]string{} - for _, n := range nodes { - if len(n.Assigned) > 0 { - everyAssigned[n.Name] = n.Assigned - } - } - var passes Passes - holdings, passes = passSeatsOn(holdings, assignmentsOf(shelf, everyAssigned)) - for _, n := range nodes { - if n.Name == exclude { - continue - } - theirs := everyAssigned[n.Name] - if len(theirs) == 0 { - continue - } - others = append(others, candidate{ - node: catalogue.Node{Name: n.Name, Site: n.Place.Site, Capabilities: n.Capabilities, - At: onNetwork[n.Name], PublicDomain: n.PublicDomain}, - assigned: theirs, pins: n.Pins}) - } - - var left []LeftOut - offered := map[string][]catalogue.Provider{} - var firstHeld []catalogue.Held - c.unhostable = nil - for _, o := range others { - got, err := catalogue.Resolve(shelf, o.assigned, o.node, catalogue.World{Unchecked: true, Holdings: holdings}) - if err != nil { - // Said, not skipped: a machine dropped here offers nothing and holds nothing. - left = append(left, LeftOut{Node: o.node.Name, Err: err}) - continue - } - for _, u := range got.Unhostable { - c.unhostable = append(c.unhostable, fmt.Sprintf("%s on %s lacks %s", u.Module, o.node.Name, strings.Join(u.Missing, ", "))) - } - firstHeld = append(firstHeld, got.Claims...) - for _, m := range got.Modules { - for _, name := range m.OffersAt(catalogue.ScopeMesh) { - // What that module says a consumer needs to know, with that node's settings on it. - serves, layers, err := c.servedOnNode(o.node.Name, m, name) - if err != nil { - return catalogue.World{}, Passes{}, nil, err - } - // And its own composed names, as that node composes them (novox/hq ADR 0346). - serves, leftOut, err := got.ServedOwnNames(m, name, serves, catalogue.WithDefaults(m, layers)) - if err != nil { - return catalogue.World{}, Passes{}, nil, err - } - offered[name] = append(offered[name], catalogue.Provider{ - Node: o.node.Name, At: o.node.At, Serves: serves, Module: m.Module, LeftOut: leftOut}) - } - } - } - for k := range offered { - sort.Slice(offered[k], func(i, j int) bool { - return offered[k][i].Node < offered[k][j].Node - }) - } - - // The second pass is given the first pass's holdings (novox/hq ADR 0110). - world := catalogue.World{Offered: offered, Held: firstHeld, Holdings: holdings} - var held []catalogue.Held - for _, o := range others { - // Each machine is resolved with its own pins, as its plan is. - theirs := world - theirs.Pinned = o.pins - got, err := catalogue.Resolve(shelf, o.assigned, o.node, theirs) - if err != nil { - // Said only for the whole-mesh view: with one machine excluded, the view is partial by design. - if exclude == "" { - left = append(left, LeftOut{Node: o.node.Name, Err: err}) - } - continue - } - held = append(held, got.Claims...) - } - world.Held = held - return world, passes, left, nil -} - -// passSeatsOn finds, in the composition that finds it, the new holder of each seat held once whose holder on record is -// assigned nowhere and which has exactly one claimant able to hold it (novox/hq ADR 0449 clause 4), and answers the -// holdings as they are after it, with the passes. Nothing is recorded. -func passSeatsOn(holdings []catalogue.Held, assignments []catalogue.Assignment) ([]catalogue.Held, Passes) { - passes, said := catalogue.SeatsToPassOn(holdings, assignments) - out := Passes{Said: said} - for _, p := range passes { - out.Passes = append(out.Passes, p) - var next []catalogue.Held - for _, h := range holdings { - if hs, ok := catalogue.SeatNamed(h.Claim); ok && hs.Name == p.Seat.Name { - continue - } - next = append(next, h) - } - p.To.Site = p.From.Site - holdings = append(next, p.To) - } - return holdings, out -} - -// assignmentsOf are the assignments of every machine, as the passing of a seat reads them. A module assigned and -// missing from the shelf is still assigned: an assignment with no claims, so its holder is not taken for gone. -func assignmentsOf(shelf map[string]catalogue.Manifest, assigned map[string][]string) []catalogue.Assignment { - var out []catalogue.Assignment - names := make([]string, 0, len(assigned)) - for node := range assigned { - names = append(names, node) - } - sort.Strings(names) - for _, node := range names { - for _, module := range assigned[node] { - m, ok := shelf[module] - if !ok { - m = catalogue.Manifest{Module: module} - } - out = append(out, catalogue.Assignment{Node: node, Manifest: m}) - } - } - return out -} - -// servedOnNode is what a module on a node tells a consumer of one of its provisions, with THAT node's ports on it: the -// port the node was given over the one the mesh assigned over the manifest's own, settled with the node's settings -// layers. The one derivation for every reader of a provider's address. -func (c *composer) servedOnNode(node string, m catalogue.Manifest, provision string) (map[string]any, []catalogue.Layer, error) { - ports, layers, err := c.portsGivenOn(node, m) - if err != nil { - return nil, nil, err - } - serves := catalogue.ServedOn(m, provision, ports) - if len(serves) > 0 { - serves, err = catalogue.Settle(serves, catalogue.WithDefaults(m, layers)) - if err != nil { - return nil, nil, err - } - } - return serves, layers, nil -} - -// portsGivenOn is where a node puts a module's ports — assigned, then given over them — and the node's settings layers -// for the module. -func (c *composer) portsGivenOn(node string, m catalogue.Manifest) (map[int]int, []catalogue.Layer, error) { - n, err := c.in.node(node) - if err != nil { - return nil, nil, err - } - ports := map[int]int{} - for wanted, at := range n.Ports[m.Module] { - ports[wanted] = at - } - layers := n.Settings[m.Module] - if given, err := catalogue.GivenPorts(m, layers); err == nil { - for wanted, at := range given { - ports[wanted] = at - } - } - return ports, layers, nil -} diff --git a/go/compose/rest_test.go b/go/compose/rest_test.go deleted file mode 100644 index fa1e627..0000000 --- a/go/compose/rest_test.go +++ /dev/null @@ -1,231 +0,0 @@ -package compose - -import ( - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/overlay" -) - -// objects and files are the provider and consumer of mesh-controller's facts_test.go aMeshWithSecrets (cmd/mesh-controller at -// 6c2a8d62): a module providing s3-bucket at mesh scope with an identity bound, and one requiring it. -func objects() catalogue.Manifest { - return catalogue.Manifest{Module: "objects", Version: "1", - Provides: []catalogue.Offer{{Name: "s3-bucket", Scope: catalogue.ScopeMesh, - Identity: &catalogue.OfferIdentity{Max: 20, In: "an S3 access key"}}}, - Receives: map[string]string{"s3-bucket": "/var/lib/mesh/objects/mesh.json"}} -} - -func files() catalogue.Manifest { - return catalogue.Manifest{Module: "files", Version: "1", Requires: []string{"s3-bucket"}, - Resources: []map[string]any{{"id": "config", "type": "file", "path": "/etc/files/config.json", - "mode": "0600", "content": "{}", "merge": "json"}}} -} - -// threeNodes is aMesh and a third node `ace` running a database, so the mesh has three nodes that each offer something: -// anchor objects, ace a database, laptop a consumer of the first. -func threeNodes(t *testing.T) Input { - t.Helper() - in := aMesh(t) - db := catalogue.Manifest{Module: "pg", Version: "1", Provides: catalogue.FromAnywhere("postgres-database")} - in.Catalogue["objects"], in.Catalogue["files"], in.Catalogue["pg"] = objects(), files(), db - anchor, laptop := nodeOf(t, &in, "anchor"), nodeOf(t, &in, "laptop") - anchor.Assigned = append(anchor.Assigned, "objects") - laptop.Assigned = append(laptop.Assigned, "files") - in.Nodes = append(in.Nodes, Node{Name: "ace", Place: Place{Site: "there", Address: "10.77.0.3"}, - Assigned: []string{"pg"}, Capabilities: map[string]bool{}}) - return in -} - -func providers(w catalogue.World, provision string) []string { - var out []string - for _, p := range w.Offered[provision] { - out = append(out, p.Node+"/"+p.Module) - } - return out -} - -// 1361/AC1: three nodes each offering a provision; the rest of the mesh, for one of them, offers every other node's and none of its -// own, and holds what they hold. -func TestTheRestOfTheMeshOffersEveryOtherNodesProvisionsAndNoneOfTheExcluded(t *testing.T) { - in := threeNodes(t) - w, passes, left, err := TheRestOfTheMesh(in, "laptop") - if err != nil || len(left) != 0 || len(passes.Passes) != 0 { - t.Fatalf("%v %v %+v", err, left, passes) - } - if got := providers(w, "s3-bucket"); len(got) != 1 || got[0] != "anchor/objects" { - t.Errorf("s3-bucket is offered by %v", got) - } - if got := providers(w, "postgres-database"); len(got) != 1 || got[0] != "ace/pg" { - t.Errorf("postgres-database is offered by %v", got) - } - if p := w.Offered["s3-bucket"][0]; p.At != "anchor.internal" { - t.Errorf("anchor is at %q on the private network", p.At) - } - // Excluding anchor takes its offer away and keeps the others'. - w, _, _, err = TheRestOfTheMesh(in, "anchor") - if err != nil { - t.Fatal(err) - } - if got := providers(w, "s3-bucket"); len(got) != 0 { - t.Errorf("the excluded node's offer is in the world: %v", got) - } - if got := providers(w, "postgres-database"); len(got) != 1 { - t.Errorf("another node's offer is missing: %v", got) - } - // With nobody excluded, every offer is there, and everyone's claims are held. - w, _, _, err = TheRestOfTheMesh(in, "") - if err != nil || len(providers(w, "s3-bucket")) != 1 || len(providers(w, "postgres-database")) != 1 { - t.Fatalf("%v %v", providers(w, "s3-bucket"), err) - } - // Each machine is resolved with its own pins, as its plan is: the world is the same for any pin of the excluded node. - nodeOf(t, &in, "laptop").Pins = map[string]catalogue.Chosen{"s3-bucket": {Node: "anchor", Module: "objects"}} - if _, _, _, err := TheRestOfTheMesh(in, "laptop"); err != nil { - t.Fatal(err) - } -} - -// 1361/AC2: a node whose assigned set does not resolve is left out and named with today's reason, and the others still offer. -func TestANodeWhoseSetDoesNotResolveIsLeftOutAndTheOthersStillOffer(t *testing.T) { - in := threeNodes(t) - rivalOne := catalogue.Manifest{Module: "rival-one", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}} - rivalTwo := catalogue.Manifest{Module: "rival-two", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}} - in.Catalogue["rival-one"], in.Catalogue["rival-two"] = rivalOne, rivalTwo - ace := nodeOf(t, &in, "ace") - ace.Assigned = append(ace.Assigned, "rival-one", "rival-two") - w, _, left, err := TheRestOfTheMesh(in, "laptop") - if err != nil { - t.Fatal(err) - } - if len(left) == 0 { - t.Fatal("a node whose set does not resolve was not left out") - } - for _, l := range left { - if l.Node != "ace" || !strings.Contains(l.String(), "ace is left out of the rest of the mesh: it does not resolve: ") { - t.Errorf("left out %q", l.String()) - } - } - if got := providers(w, "s3-bucket"); len(got) != 1 { - t.Errorf("the others do not offer: %v", got) - } - if got := providers(w, "postgres-database"); len(got) != 0 { - t.Errorf("the node left out offers: %v", got) - } - // Said for the whole-mesh view in both passes (as the controller says it), and for a view with one machine excluded in the first only. - _, _, whole, _ := TheRestOfTheMesh(in, "") - if len(whole) != 2 { - t.Errorf("the whole-mesh view says %d line(s), want the two passes'", len(whole)) - } - if len(left) != 1 { - t.Errorf("a view with one excluded says %d line(s), want the first pass's", len(left)) - } -} - -// 1361/AC3 and AC4 (mesh-controller seat_pass_test.go TestACompositionPassesTheSeatAndWritesOneHandActByTheController, in -// the part that is not a write): a seat held once whose holder on record is assigned nowhere and one claimant: the seat -// passes to that claimant in the World, and is returned as a pass; Input's holdings are unchanged. -func TestASeatWhoseHolderIsAssignedNowhereGoesToItsOnlyClaimant(t *testing.T) { - withSeat(t) - mk := func() Input { - return Input{ - Catalogue: map[string]catalogue.Manifest{"gate": aBrokerClaiming("gate")}, - Nodes: []Node{{Name: "novox", Place: Place{Site: "here"}, Assigned: []string{"gate"}}}, - Holdings: []catalogue.Held{{Claim: "mesh-broker", Scope: catalogue.ScopeMesh, Node: "novox", Module: "old-broker", Site: "here"}}, - } - } - in := mk() - w, passes, _, err := TheRestOfTheMesh(in, "") - if err != nil { - t.Fatal(err) - } - if len(w.Holdings) != 1 || w.Holdings[0].Module != "gate" || w.Holdings[0].Node != "novox" { - t.Fatalf("the holdings after the composition do not name the gate: %+v", w.Holdings) - } - if len(passes.Passes) != 1 { - t.Fatalf("passes=%d, want one", len(passes.Passes)) - } - p := passes.Passes[0] - if p.Seat.Name != "mesh-broker" || p.From.Module != "old-broker" || p.To.Module != "gate" || p.To.Node != "novox" { - t.Errorf("the pass is %+v", p) - } - unchanged(t, mk, func(in Input) { - if _, _, _, err := TheRestOfTheMesh(in, ""); err != nil { - t.Fatal(err) - } - }) -} - -// 1317/AC2 (seat_pass_test.go TestACompositionWithTwoClaimantsRecordsNoneAndSaysSo): two claimants, no pass, the refusal said with -// both and the remedy. 1317/AC3 and AC4 (TestACompositionChangesNothingWhileTheHolderIsAssignedOrNobodyClaims): nothing changes while -// the holder is assigned, nor with no claimant. -func TestNoSeatIsPassedForTwoClaimantsAnAssignedHolderOrNoClaimant(t *testing.T) { - withSeat(t) - held := []catalogue.Held{{Claim: "mesh-broker", Scope: catalogue.ScopeMesh, Node: "novox", Module: "old-broker"}} - _, passes := passSeatsOn(held, []catalogue.Assignment{ - {Node: "novox", Manifest: aBrokerClaiming("gate")}, {Node: "ace", Manifest: aBrokerClaiming("other")}}) - said := strings.Join(passes.Said, "\n") - if len(passes.Passes) != 0 || !strings.Contains(said, "gate") || !strings.Contains(said, "other") || - !strings.Contains(said, "seat mesh-broker --to") { - t.Fatalf("passes %+v said %q", passes.Passes, said) - } - got, passes := passSeatsOn(held, []catalogue.Assignment{ - {Node: "novox", Manifest: aBrokerClaiming("old-broker")}, {Node: "novox", Manifest: aBrokerClaiming("gate")}}) - if len(passes.Passes) != 0 || len(got) != 1 || got[0].Module != "old-broker" { - t.Fatalf("holder assigned: %+v %+v", got, passes) - } - got, passes = passSeatsOn(held, nil) - if len(passes.Passes) != 0 || len(got) != 1 || got[0].Module != "old-broker" { - t.Fatalf("no claimant: %+v %+v", got, passes) - } -} - -// seat_pass_test.go TestAHolderAssignedButNotOnTheShelfIsNotTakenForGone. -func TestAHolderAssignedButNotOnTheShelfIsNotTakenForGone(t *testing.T) { - withSeat(t) - as := assignmentsOf(map[string]catalogue.Manifest{"gate": aBrokerClaiming("gate")}, - map[string][]string{"novox": {"gate", "old-broker"}}) - passes, _ := catalogue.SeatsToPassOn( - []catalogue.Held{{Claim: "mesh-broker", Scope: catalogue.ScopeMesh, Node: "novox", Module: "old-broker"}}, as) - if len(passes) != 0 { - t.Fatalf("a holder missing from the shelf lost its seat: %+v", passes) - } -} - -// The not-resolving node of a view with a holder on record: holdings reach the first pass (novox/hq ADR 0131), so a node running -// the next holder beside the current one is not refused as a second holder. -func TestAHolderOnRecordSettlesTheSeatInTheRestOfTheMesh(t *testing.T) { - withSeat(t) - in := Input{ - Catalogue: map[string]catalogue.Manifest{"old-broker": aBrokerClaiming("old-broker"), "gate": aBrokerClaiming("gate")}, - Nodes: []Node{ - {Name: "novox", Assigned: []string{"old-broker", "gate"}}, - {Name: "ace", Assigned: []string{}}}, - Holdings: []catalogue.Held{{Claim: "mesh-broker", Scope: catalogue.ScopeMesh, Node: "novox", Module: "old-broker"}}, - } - _, passes, left, err := TheRestOfTheMesh(in, "ace") - if err != nil || len(left) != 0 || len(passes.Passes) != 0 { - t.Fatalf("%v %v %+v", err, left, passes) - } - in.Holdings = nil - _, _, left, err = TheRestOfTheMesh(in, "ace") - if err != nil || len(left) == 0 { - t.Fatalf("two claimants with nothing on record were not refused: %v %v", err, left) - } -} - -// Each machine is resolved in the second pass with its own pins, as its plan is (novox/hq issue 188): a pin to a provider that -// does not answer is its own refusal, so the machine is left out for it; the same machine without the pin is not. -func TestEachNodeIsResolvedWithItsOwnPinsInTheRestOfTheMesh(t *testing.T) { - in := threeNodes(t) - in.Nodes = append(in.Nodes, Node{Name: "newt", Place: Place{Site: "there", Address: "10.77.0.4"}, Assigned: []string{overlay.Name, "files"}, Capabilities: map[string]bool{}}) - _, _, left, err := TheRestOfTheMesh(in, "") - if err != nil || len(left) != 0 { - t.Fatalf("without a pin the consumer is left out: %v %v", left, err) - } - nodeOf(t, &in, "newt").Pins = map[string]catalogue.Chosen{"s3-bucket": {Node: "ace", Module: "nothing"}} - _, _, left, err = TheRestOfTheMesh(in, "") - if err != nil || len(left) != 1 || left[0].Node != "newt" || !strings.Contains(left[0].String(), "was told to get \"s3-bucket\" from") { - t.Fatalf("a pin to a provider that does not answer did not leave the consumer out: %v %v", left, err) - } -} diff --git a/go/compose/seats_test.go b/go/compose/seats_test.go deleted file mode 100644 index a07e0c2..0000000 --- a/go/compose/seats_test.go +++ /dev/null @@ -1,22 +0,0 @@ -package compose - -import ( - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// aBrokerClaiming is mesh-controller's seat_pass_test.go helper of the same name. -func aBrokerClaiming(module string) catalogue.Manifest { - return catalogue.Manifest{Module: module, - Provides: []catalogue.Offer{{Name: "mesh-bus", Scope: catalogue.ScopeMesh}}, - Claims: []catalogue.Claim{{Name: "mesh-broker", Scope: catalogue.ScopeMesh}}} -} - -// withSeat is mesh-controller's withBusSeat: a single mesh seat, mesh-broker, for the test. -func withSeat(t *testing.T) { - t.Helper() - was := catalogue.Seats() - t.Cleanup(func() { catalogue.UseSeats(was) }) - catalogue.UseSeats([]catalogue.Seat{{Name: "mesh-broker", Scope: catalogue.ScopeMesh, Delivers: "mesh-bus", Decision: "test"}}) -} diff --git a/go/composer/composer.go b/go/composer/composer.go index 566f6ac..69b729e 100644 --- a/go/composer/composer.go +++ b/go/composer/composer.go @@ -1,6 +1,7 @@ -// Package composer is mesh-sdk's composition as one function over a plain request (novox/hq ADR 0482 -// clause 4, issue 1621): what the command mesh-compose runs, so a program that links any one mesh-sdk can have the -// composition of another commit judged by building that commit's command and reading its verdict. +// Package composer is the shape of a composition over a plain request (novox/hq ADR 0482 clause 4, issue 1621, as ADR +// 0593 §7 changes it): the request the command mesh-compose reads and the verdict it answers. The command and the +// composer that runs it are the node-engine's (mesh-host cmd/mesh-compose), built from its repository at a group's +// head, so a change to its composition code is judged by that code. // // **It reads its request and nothing else.** No bus, store, network, file or environment: the manifests come inside the // request (raw, as their repository holds them), the rest of the mesh is a compose.Input, and the answer is a value. @@ -13,21 +14,17 @@ import ( "errors" "fmt" "slices" - "sort" "git.novox.be/novox/mesh-sdk/go/catalogue" - "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" - "git.novox.be/novox/mesh-sdk/go/sdkversion" ) // Version is the version of a Request and of a Verdict; a reader refuses another. const Version = 1 -// Package is the import path of the command, which a compose job builds at a commit (in mesh-sdk's own module, or in -// a module that requires mesh-sdk, at the version it pins). -const Package = "git.novox.be/novox/mesh-sdk/go/cmd/mesh-compose" +// Package is the import path of the command, which a compose job builds at a commit of the node-engine's repository +// (novox/hq ADR 0593 §7). +const Package = "github.com/novox/mesh-host/cmd/mesh-compose" // The depths of a composition. const ( @@ -201,173 +198,3 @@ func Fill(r *Request, get func(digest string) ([]byte, error)) error { } return nil } - -// Run composes every case of r and answers the verdict, its SDK this binary's mesh-sdk. An error is a request it -// cannot run (an unknown depth, a case named twice, a manifest left as a digest); a machine that does not compose is a -// verdict. -func Run(r Request) (Verdict, error) { - depth := r.Depth - if depth == "" { - depth = DepthCompose - } - if depth != DepthResolve && depth != DepthCompose { - return Verdict{}, fmt.Errorf("composer: the depth %q is not resolve or compose", r.Depth) - } - v := Verdict{V: Version, SDK: sdkversion.Version()} - seen := map[string]bool{} - for _, c := range r.Cases { - if seen[c.Name] { - return Verdict{}, fmt.Errorf("composer: the case %q is named twice", c.Name) - } - seen[c.Name] = true - cv, err := runCase(r, c, depth) - if err != nil { - return Verdict{}, err - } - v.Cases = append(v.Cases, cv) - } - return v, nil -} - -// parse decodes every manifest leniently and takes its verdict (novox/hq ADR 0578 §3, §5): the composer judges none. A -// manifest refused by its verdict is held, composed as its stub without its items (as the bus reader holds one); bytes -// that do not decode are left out. Both are named in reads. -func parse(m map[string]Manifest, reads map[string]ManifestRead, verdicts map[string]delivery.ManifestVerdict) (map[string]catalogue.Manifest, error) { - out := map[string]catalogue.Manifest{} - for name, man := range m { - if len(man.Raw) == 0 { - return nil, fmt.Errorf("%w: %s (digest %s)", ErrUnfilled, name, man.Digest) - } - decoded, err := catalogue.Decode(man.Raw) - read := ManifestRead{Accepted: err == nil, SetAside: decoded.SetAside()} - if err != nil { - read.Refused = fmt.Sprintf("the manifest of %s does not decode: %v", name, err) - } else if v, ok := verdicts[name]; ok { - if refused, _ := delivery.TakeManifestVerdict(&v); refused { - if v.Module == "" { - v.Module = name - } - read = ManifestRead{Refused: v.Why()} - decoded.Items = nil - decoded = compose.Stub(decoded) - } - } - if reads != nil { - reads[name] = read - } - if err == nil { - out[name] = decoded - } - } - return out, nil -} - -func runCase(r Request, c Case, depth string) (CaseVerdict, error) { - cv := CaseVerdict{Name: c.Name, Manifests: map[string]ManifestRead{}} - shelf, err := parse(c.Manifests, cv.Manifests, c.Verdicts) - if err != nil { - return CaseVerdict{}, err - } - var input compose.Input - if len(r.Input) > 0 { - if err := json.Unmarshal(r.Input, &input); err != nil { - return CaseVerdict{}, fmt.Errorf("composer: the request's input is not a composition input: %w", err) - } - } - in, err := frombus.Placed(input, c.Placements) - if err != nil { - return CaseVerdict{}, fmt.Errorf("composer: the case %q: %w", c.Name, err) - } - in.Catalogue = shelf - in.Nodes = slices.Clone(in.Nodes) - in.Holdings = slices.Clone(in.Holdings) - for i := range in.Nodes { - in.Nodes[i].Kept = nil - if kept := c.Kept[in.Nodes[i].Name]; len(kept) > 0 { - k, err := parse(kept, nil, nil) - if err != nil { - return CaseVerdict{}, err - } - in.Nodes[i].Kept = k - } - } - if r.HoldingsFromClaims { - in.Holdings = append(in.Holdings, HoldingsOf(shelf, in.Nodes)...) - } - if len(r.Machines) == 0 { - return cv, nil - } - cv.Machines = map[string]Machine{} - for _, node := range r.Machines { - cv.Machines[node] = machine(in, node, depth, r.Declarations) - } - return cv, nil -} - -// HoldingsOf is every seat an assigned module claims, held on the node it is assigned to, in node order: the holdings -// of an input that has none of record (the assigner's, mesh-delivery-assigner internal/assign input). -func HoldingsOf(shelf map[string]catalogue.Manifest, nodes []compose.Node) []catalogue.Held { - var out []catalogue.Held - sorted := slices.Clone(nodes) - sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name }) - for _, n := range sorted { - for _, mod := range n.Assigned { - for _, cl := range shelf[mod].Claims { - scope := cl.Scope - if seat, ok := catalogue.SeatNamed(cl.Name); ok && scope == "" { - scope = seat.Scope - } - out = append(out, catalogue.Held{Claim: cl.Name, Scope: scope, Node: n.Name, Module: mod}) - } - } - } - return out -} - -func machine(in compose.Input, node, depth string, declarations bool) Machine { - if depth == DepthResolve { - if _, err := compose.Resolve(in, node); err != nil { - return Machine{Reason: err.Error()} - } - return Machine{Composes: true} - } - d, problems, err := compose.Compose(in, node) - if err != nil { - return Machine{Reason: err.Error()} - } - m := Machine{Composes: len(problems) == 0, Problems: problems, Refusals: d.Refusals()} - if len(problems) > 0 { - m.Reason = "the node-engine's validator refuses the declaration" - } - if declarations { - m.Declared = pathsOf(d) - } - return m -} - -// pathsOf is every resource of a declaration that names a path, with its owner, group and mode as declared, sorted by -// path. -func pathsOf(d compose.Declared) []Path { - var out []Path - str := func(r map[string]any, key string) string { - if v, ok := r[key]; ok && v != nil { - return fmt.Sprint(v) - } - return "" - } - for _, r := range d.Resources { - p := str(r, "path") - if p == "" { - continue - } - out = append(out, Path{Path: p, Kind: str(r, "type"), Owner: str(r, "owner"), Group: str(r, "group"), - Mode: str(r, "mode"), Module: d.ModuleOf[str(r, "id")]}) - } - sort.Slice(out, func(i, j int) bool { - if out[i].Path != out[j].Path { - return out[i].Path < out[j].Path - } - return out[i].Kind < out[j].Kind - }) - return out -} diff --git a/go/composer/composer_test.go b/go/composer/composer_test.go index 82a3de0..108e3c7 100644 --- a/go/composer/composer_test.go +++ b/go/composer/composer_test.go @@ -3,14 +3,8 @@ package composer import ( "encoding/json" "errors" - "os" - "reflect" "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" ) const demo = `{"module": "demo", "version": "1", "claims": [{"name": "node-files"}]}` @@ -32,9 +26,6 @@ func TestAManifestIsItsBytesOrItsDigest(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := Run(back); !errors.Is(err, ErrUnfilled) { - t.Fatalf("a digest left unfilled runs: %v", err) - } asked := 0 if err := Fill(&back, func(d string) ([]byte, error) { asked++ @@ -53,112 +44,3 @@ func TestAManifestIsItsBytesOrItsDigest(t *testing.T) { t.Fatalf("a digest the indexer does not hold: %v", err) } } - -// The input travels as JSON and comes back equal, so the command composes what its caller meant. -func TestTheInputTravelsAsJSON(t *testing.T) { - in := compose.Input{ - Nodes: []compose.Node{{Name: "anchor", Assigned: []string{"nats"}, Capabilities: map[string]bool{"systemd": true}, - Place: compose.Place{Endpoint: "anchor.example:51820", Site: "here", Hub: true, Address: "10.77.0.1"}, - Pins: map[string]catalogue.Chosen{"bus": {Node: "anchor", Module: "nats"}}, - Settings: map[string][]catalogue.Layer{"nats": {{Values: map[string]any{"port": float64(4222)}}}}}}, - Holdings: []catalogue.Held{{Claim: "mesh-broker", Scope: "mesh", Node: "anchor", Module: "nats"}}, - Issued: []compose.Issued{{Provision: "bus", Consumer: "laptop", ConsumerModule: "x", Provider: "anchor"}}, - } - raw, err := json.Marshal(Request{V: Version, Input: rawInput(in)}) - if err != nil { - t.Fatal(err) - } - back, err := Decode(raw) - if err != nil { - t.Fatal(err) - } - var got compose.Input - if err := json.Unmarshal(back.Input, &got); err != nil || !reflect.DeepEqual(got, in) { - t.Fatalf("the input came back as\n%+v (%v)\nwant\n%+v", got, err, in) - } -} - -// The seats a node's assigned modules claim are held there when the request says the holdings come from the claims. -func TestHoldingsComeFromTheClaims(t *testing.T) { - m, err := catalogue.Decode([]byte(demo)) - if err != nil { - t.Fatal(err) - } - got := HoldingsOf(map[string]catalogue.Manifest{"demo": m}, []compose.Node{{Name: "b", Assigned: []string{"demo"}}, {Name: "a", Assigned: []string{"demo"}}}) - if len(got) != 2 || got[0].Node != "a" || got[0].Claim != "node-files" || got[0].Scope != "node" { - t.Fatalf("holdings = %+v", got) - } -} - -// A request the command cannot run is refused, never answered as a verdict: another version, a case named twice, an -// unknown depth. -func TestARequestItCannotRunIsRefused(t *testing.T) { - if _, err := Decode([]byte(`{"v": 2}`)); err == nil { - t.Error("a request of version 2 is read") - } - if _, err := Run(Request{V: Version, Cases: []Case{{Name: "a"}, {Name: "a"}}}); err == nil { - t.Error("a case named twice runs") - } - if _, err := Run(Request{V: Version, Depth: "deep"}); err == nil { - t.Error("an unknown depth runs") - } - // A case's own placement (issue 1126) composes over a node the input holds, and is refused for one it does not. - placed := Request{V: Version, Input: rawInput(compose.Input{Nodes: []compose.Node{{Name: "a"}}}), Machines: []string{"a"}, Depth: DepthResolve, - Cases: []Case{{Name: "trunk", Manifests: map[string]Manifest{"demo": {Raw: json.RawMessage(demo)}}}, - {Name: "change", Manifests: map[string]Manifest{"demo": {Raw: json.RawMessage(demo)}}, - Placements: map[string]*delivery.Placement{"a": {Node: "a", Modules: []delivery.PlacedModule{{Module: "ghost"}}}}}}} - if v, err := Run(placed); err != nil || !v.Cases[0].Machines["a"].Composes || v.Cases[1].Machines["a"].Composes || - !strings.Contains(v.Cases[1].Machines["a"].Reason, "ghost") { - t.Errorf("a case composes its own placement: %+v, %v", v, err) - } - placed.Cases[1].Placements = map[string]*delivery.Placement{"z": {Node: "z"}} - if _, err := Run(placed); err == nil || !strings.Contains(err.Error(), "holds no node for") { - t.Errorf("a case placing a node the input lacks: %v", err) - } - v, err := Run(Request{V: Version, Cases: []Case{{Name: "only-read", Manifests: map[string]Manifest{"demo": {Raw: json.RawMessage(demo)}}}}}) - if err != nil || len(v.Cases) != 1 || !v.Cases[0].Manifests["demo"].Accepted || v.Cases[0].Machines != nil || v.SDK == "" { - t.Fatalf("a request with no machines: %+v, %v", v, err) - } -} - -// The composer judges no manifest (novox/hq ADR 0578 §5): xterm's module.json from mesh-catalog at 835bf22b, whose -// config value falls back to "font-size", is read whatever list of predefined keys a request carries (a group touching -// xterm failed its composition on this, group check 18df81380db3); a module whose verdict in the input refuses it is -// held, composed as its stub, and named in the verdict's words. -func TestTheComposerTakesTheVerdictsOfItsInput(t *testing.T) { - raw, err := os.ReadFile("../catalogue/testdata/catalogue-835bf22b-xterm.module.json") - if err != nil { - t.Fatal(err) - } - r := Request{V: Version, Predefined: catalogue.PredefinedList{"something-else": {Type: "string", Description: "x"}}, - Cases: []Case{{Name: "change", Manifests: map[string]Manifest{"xterm": {Raw: raw}}}}} - v, err := Run(r) - if err != nil { - t.Fatal(err) - } - if got := v.Cases[0].Manifests["xterm"]; !got.Accepted { - t.Fatalf("xterm: %+v, want accepted", got) - } - r.Cases[0].Verdicts = map[string]delivery.ManifestVerdict{"xterm": {Module: "xterm", State: delivery.VerdictRefused, - Problems: []string{"falls back to a key not listed"}, Judge: "src2:j"}} - shelf, err := parse(r.Cases[0].Manifests, map[string]ManifestRead{}, r.Cases[0].Verdicts) - if err != nil || !shelf["xterm"].Stubbed { - t.Fatalf("the refused module is not held as its stub: %+v %v", shelf["xterm"], err) - } - if v, err = Run(r); err != nil { - t.Fatal(err) - } - if got := v.Cases[0].Manifests["xterm"]; got.Accepted || - !strings.Contains(got.Refused, "the indexer's judge (src2:j) refused the manifest of xterm: falls back to a key not listed") { - t.Fatalf("xterm refused by its verdict: %+v, want refused in the verdict's words", got) - } -} - -// rawInput is a composition input as a request carries it. -func rawInput(in compose.Input) json.RawMessage { - raw, err := json.Marshal(in) - if err != nil { - panic(err) - } - return raw -} diff --git a/go/delivery/answer_paths.go b/go/delivery/answer_paths.go index da4cb42..f797166 100644 --- a/go/delivery/answer_paths.go +++ b/go/delivery/answer_paths.go @@ -134,44 +134,3 @@ func sameAny(a, b any) bool { y, errB := json.Marshal(b) return errA == nil && errB == nil && bytes.Equal(x, y) } - -// FitAnswer is a with its shown bodies cut until its JSON is within limit bytes (MaxAnswerBytes for the node's key): -// first the bodies' texts, from the last differing module back, keeping their paths; then the paths too. What is cut -// is said in the answer's note. The verdict and every list of modules are never cut. -func FitAnswer(a CandidateAnswer, limit int) CandidateAnswer { - size := func() int { - raw, err := json.Marshal(a) - if err != nil { - return 0 - } - return len(raw) - } - if size() <= limit { - return a - } - a.Differs = append([]ModuleDiffers(nil), a.Differs...) - for i := range a.Differs { - a.Differs[i].Bodies = append([]ResourceBodies(nil), a.Differs[i].Bodies...) - } - cutTexts, cutPaths := false, false - for i := len(a.Differs) - 1; i >= 0 && size() > limit; i-- { - for j := range a.Differs[i].Bodies { - a.Differs[i].Bodies[j].Composed, a.Differs[i].Bodies[j].Running = "", "" - } - cutTexts = true - } - for i := len(a.Differs) - 1; i >= 0 && size() > limit; i-- { - for j := range a.Differs[i].Bodies { - a.Differs[i].Bodies[j].Paths = nil - } - cutPaths = true - } - why := "the bodies shown were cut to keep the answer within " + strconv.Itoa(limit>>10) + " KiB" - if cutPaths { - why += ", their paths too" - } - if cutTexts { - a.Note = strings.TrimPrefix(a.Note+"; ", "; ") + why - } - return a -} diff --git a/go/delivery/judge.go b/go/delivery/judge.go deleted file mode 100644 index a060fc7..0000000 --- a/go/delivery/judge.go +++ /dev/null @@ -1,365 +0,0 @@ -package delivery - -import ( - "bytes" - "encoding/json" - "fmt" - "path" - "sort" - "strings" - - "git.novox.be/novox/mesh-sdk/go/declaration" -) - -// The judgement of a move (ADR 0377 §3), as a pure function over two compositions of one module, so the node agent that -// composes its own modules judges what it would apply (novox/hq ADR 0547 §3; issue 1126's S8). Moved from -// mesh-controller cmd/mesh-controller/composed_judge.go at origin/main (the controller keeps its copy, unchanged, until -// it loses the lease, ADR 0547 §1): judged, onlyTheUserList and sameResources, with their helpers, are its code. -// -// ADR 0377 §3 over two compositions of one module: **recreates** when a container resource is changed, added or -// removed; **moves data** when, with no container changed, a directory or file resource is changed, added or removed -// that a container of the module mounts or that holds or lies within one of its directories (where a module keeps its -// data), when its `data` declaration changes, or, for a module that declares data, when one of its long-running -// processes changes; **neither** otherwise, and for a module the node has never run. - -// Judged is one move's judgement: what it does, its words, and whether only the bus's user list changes (ADR 0539 §7: -// a file change and a reload of the bus, no move of it, never asked). -type Judged struct { - Judgement Judgement - Words string - UserListOnly bool -} - -// JudgeMove judges moving a node's module from the composition it runs to the one composed for it. Running is nil when the -// node runs no composition of the module. A composition equal to the one running (resource for resource, each -// canonical) is unchanged: no move, and no ask (ADR 0547 §3). -func JudgeMove(running *Composed, composed Composed) Judged { - if composed.Module == "" { - return Judged{Judgement: JudgedNeither, Words: "the node's own composition"} - } - if running == nil { - return Judged{Judgement: JudgedNeither, Words: "the node runs no composition of " + composed.Module} - } - if running.Data == composed.Data && SameComposedResources(*running, composed) { - return Judged{Judgement: JudgedUnchanged, Words: "equal to the composition the node runs"} - } - if onlyTheUserList(*running, composed) { - return Judged{Judgement: JudgedNeither, Words: "only the bus's user list changes", UserListOnly: true} - } - j, words := judged(*running, composed) - return Judged{Judgement: j, Words: words} -} - -// busUsersSuffix ends the id of the bus module's user list as the composer writes it: `.bus-users`. -const busUsersSuffix = ".bus-users" - -// onlyTheUserList says two compositions of a module differ, and only in its file of the bus's user list. -func onlyTheUserList(from, to Composed) bool { - if from.Data != to.Data { - return false - } - before, after := judgedResourcesByID(from), judgedResourcesByID(to) - if len(before) != len(after) { - return false - } - changed := false - for id, now := range after { - was, ok := before[id] - if !ok { - return false - } - if bytes.Equal(was.raw, now.raw) { - continue - } - if !strings.HasSuffix(id, busUsersSuffix) || now.kind != string(declaration.TypeFile) { - return false - } - changed = true - } - return changed -} - -// judged applies ADR 0377 §3 to two compositions of one module. -func judged(from, to Composed) (Judgement, string) { - before, after := judgedResourcesByID(from), judgedResourcesByID(to) - if ids := changedOf(before, after, func(kind string) bool { return kind == string(declaration.TypeContainer) }); len(ids) > 0 { - return JudgedRecreates, "it recreates " + strings.Join(ids, ", ") - } - places := dataPlaces(before, after) - held := func(kind string) bool { - return kind == string(declaration.TypeDirectory) || kind == string(declaration.TypeFile) - } - var moved []string - for _, id := range changedOf(before, after, held) { - r := before[id] - if r == nil { - r = after[id] - } - if withinPlaces(r.path, places) { - moved = append(moved, id) - } - } - 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) - // 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 " on ". -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 { - kind string - path string - mounts []string - raw []byte -} - -func judgedResourcesByID(c Composed) map[string]*judgedResource { - out := map[string]*judgedResource{} - for _, raw := range c.Resources { - var head struct { - ID string `json:"id"` - Type string `json:"type"` - Path string `json:"path"` - // A container's volumes, `:[:]`. - Volumes []string `json:"volumes"` - } - if err := json.Unmarshal(raw, &head); err != nil { - continue - } - r := &judgedResource{kind: head.Type, path: head.Path, raw: compactResource(raw)} - for _, v := range head.Volumes { - source, _, _ := strings.Cut(v, ":") - r.mounts = append(r.mounts, source) - } - out[head.ID] = r - } - return out -} - -// compactResource is a resource's bytes canonical: its keys sorted, its spacing dropped, its numbers as written, so two -// compositions that say the same compare equal however each was written. -func compactResource(raw []byte) []byte { - var v any - d := json.NewDecoder(bytes.NewReader(raw)) - d.UseNumber() - if err := d.Decode(&v); err != nil { - return raw - } - out, err := json.Marshal(v) - if err != nil { - return raw - } - return out -} - -// changedOf are the ids of the resources of the kinds counted that are changed, added or removed, sorted. -func changedOf(before, after map[string]*judgedResource, counted func(kind string) bool) []string { - var out []string - for id, now := range after { - if !counted(now.kind) { - continue - } - if was, ok := before[id]; !ok || !bytes.Equal(was.raw, now.raw) { - out = append(out, id) - } - } - for id, was := range before { - if _, ok := after[id]; !ok && counted(was.kind) { - out = append(out, id) - } - } - sort.Strings(out) - return out -} - -// dataPlaces are the paths where the module keeps what a move must not lose: what its containers mount, and its -// directories, before and after. -func dataPlaces(before, after map[string]*judgedResource) []string { - var out []string - for _, rs := range []map[string]*judgedResource{before, after} { - for _, r := range rs { - if r.kind == string(declaration.TypeContainer) { - out = append(out, r.mounts...) - } - if r.kind == string(declaration.TypeDirectory) && r.path != "" { - out = append(out, r.path) - } - } - } - return out -} - -// withinPlaces says p is one of places, or lies within one, or holds one. -func withinPlaces(p string, places []string) bool { - if p == "" { - return false - } - p = path.Clean(p) - for _, place := range places { - if place == "" || !strings.HasPrefix(place, "/") { - continue - } - place = path.Clean(place) - if p == place || strings.HasPrefix(p, place+"/") || strings.HasPrefix(place, p+"/") { - return true - } - } - return false -} - -// SameComposedResources says two compositions of a module hold the same resources in the same order, each compared -// canonical. -func SameComposedResources(a, b Composed) bool { - if len(a.Resources) != len(b.Resources) { - return false - } - for i := range a.Resources { - if !bytes.Equal(compactResource(a.Resources[i]), compactResource(b.Resources[i])) { - return false - } - } - return true -} diff --git a/go/delivery/judge_test.go b/go/delivery/judge_test.go deleted file mode 100644 index ad17be4..0000000 --- a/go/delivery/judge_test.go +++ /dev/null @@ -1,94 +0,0 @@ -package delivery - -import ( - "encoding/json" - "fmt" - "strings" - "testing" -) - -func composedOf(module string, c byte, data bool, resources ...string) Composed { - out := Composed{Node: "ace", Module: module, FP: SourcePrefix2 + strings.Repeat(string(c), 64), Data: data} - for _, r := range resources { - out.Resources = append(out.Resources, json.RawMessage(r)) - } - return out -} - -// The pairs mesh-controller's composed_judge_test.go captured (issue 1945 AC12), and the bus's user list, judged as the -// controller judges them (ADR 0377 §3, ADR 0539 §7), by the node agent's function (ADR 0547 §3, issue 1126 S8). -func TestJudgeMove(t *testing.T) { - const ( - container = `{"id":"mesh-mail.server","type":"container","module":"mesh-mail","name":"mail","image":"mail@sha256:1111111111111111111111111111111111111111111111111111111111111111","volumes":["/var/lib/mesh-mail/spool:/spool"]}` - container2 = `{"id":"mesh-mail.server","type":"container","module":"mesh-mail","name":"mail","image":"mail@sha256:2222222222222222222222222222222222222222222222222222222222222222","volumes":["/var/lib/mesh-mail/spool:/spool"]}` - spool = `{"id":"mesh-mail.spool","type":"directory","module":"mesh-mail","path":"/var/lib/mesh-mail/spool"}` - spoolMoved = `{"id":"mesh-mail.spool","type":"directory","module":"mesh-mail","path":"/srv/mesh-mail/spool"}` - tools = `{"id":"mesh-mail.tools","type":"file","module":"mesh-mail","path":"/usr/lib/mesh-mail/tools.json","content":"a"}` - tools2 = `{"id":"mesh-mail.tools","type":"file","module":"mesh-mail","path":"/usr/lib/mesh-mail/tools.json","content":"b"}` - config = `{"id":"mesh-mail.conf","type":"file","module":"mesh-mail","path":"/var/lib/mesh-mail/spool/conf","content":"a"}` - 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"}` - natsConf2 = `{"id":"nats.conf","type":"file","module":"nats","path":"/etc/nats/conf","content":"b"}` - ) - mail := func(c byte, data bool, rs ...string) *Composed { - m := composedOf("mesh-mail", c, data, rs...) - return &m - } - bus := func(rs ...string) *Composed { m := composedOf("nats", 'b', false, rs...); return &m } - for _, c := range []struct { - name string - 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, "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) - } -} diff --git a/go/delivery/placement_test.go b/go/delivery/placement_test.go index 6e662c4..7dd7855 100644 --- a/go/delivery/placement_test.go +++ b/go/delivery/placement_test.go @@ -555,12 +555,6 @@ func TestPlacement(t *testing.T) { !strings.Contains(far.Differs[0].Bodies[0].Composed, "cut at") || strings.Contains(fmt.Sprint(far), "hunter") { t.Errorf("a difference past the cut: %+v", far.Differs) } - // And an answer too large for the node's key is fitted: the texts go first, the verdict and the paths stay. - big := CandidateAnswer{Proposal: "p-x1", Revision: 1, Verdict: CandidateDiffers, Differs: far.Differs} - if fitted := FitAnswer(big, 4<<10); len(fitted.Differs[0].Bodies[0].Composed) != 0 || len(fitted.Differs[0].Bodies[0].Paths) != 2 || - fitted.Verdict != CandidateDiffers || !strings.Contains(fitted.Note, "cut") || big.Differs[0].Bodies[0].Composed == "" { - t.Errorf("fitted as %+v", fitted) - } if c, r := MaskedPair(json.RawMessage(`{"env":{"DB_PASSWORD":"hunter2","PORT":"80"},"list":[{"token":"t1"}]}`), json.RawMessage(`{"env":{"DB_PASSWORD":"hunter3","PORT":"81"},"list":[{"token":"t1"}]}`), nil); strings.Contains(c+r, "hunter") || strings.Contains(c+r, `"t1"`) || !strings.Contains(r, `"PORT":"81"`) || !strings.Contains(c, "") || diff --git a/go/overlay/declaration.go b/go/overlay/declaration.go deleted file mode 100644 index 7c100af..0000000 --- a/go/overlay/declaration.go +++ /dev/null @@ -1,197 +0,0 @@ -package overlay - -import ( - "encoding/json" - "fmt" - "strings" -) - -// The overlay is delivered as an ordinary declaration. -// -// novox/hq 08-connectivity: what the host receives is an interface configuration and a peer list, -// as files. It does not compute them and it does not know what a private network is — the shapes -// it already has are enough, which is why connectivity needs nothing new from tier 0. -// -// **No private key travels.** The configuration points at a file the node wrote from a key the -// mesh has never seen, using WireGuard's own ability to set one after the interface is up. So the -// control plane composes a complete configuration for a node it cannot impersonate. - -// Interface is what the private network is called on a machine, and where the node's own key -// lives. A constant rather than a setting: two nodes disagreeing about the name would produce a -// mesh where each is configured correctly and nothing meets. -const ( - Interface = "mesh0" - ConfigPath = "/etc/wireguard/" + Interface + ".conf" - Unit = "wg-quick@" + Interface - DefaultKeyPath = "/var/lib/mesh-host/overlay.key" -) - -// Resource is one entry in a declaration, built here and read by the host. -type Resource map[string]any - -// Declaration is what the mesh sends a node to put it on the network. -// -// Three resources and nothing clever: the tools, the configuration, and the interface running. A -// person can read it, which is the point — this is the first thing a node is ever told, and if it -// is wrong the node is unreachable and the mistake has to be findable by eye. -func Declaration(node Node, peers []Peer, keyPath string) ([]byte, error) { - if node.Address == "" { - return nil, fmt.Errorf("%s has no address on the overlay, so there is nothing to configure", - node.Name) - } - if keyPath == "" { - keyPath = DefaultKeyPath - } - - up := Resource{ - "id": "overlay-up", "type": "service", "unit": Unit, - "state": "running", - // Enabled, so the node comes back onto the network after a reboot without waiting to - // be told again. A node whose overlay only exists while something is watching is not - // a node that survives being switched off and on. - "boot": "enabled", - // And restarted when the peer list changes, because a running interface does not - // re-read its configuration. - // - // This is the whole of it: a node joins, every existing node's peer list changes, - // each file is replaced — and without this the service is already running, nothing - // reloads it, and every node keeps a network that no longer matches the mesh. It - // reports complete success. The lab found it the moment a third node arrived. - // - // Declared state rather than a command. The service must reflect the file; the host - // works out that it does not. A command to restart would be an action, and the link - // may not carry one (novox/hq ADR 0005) — the host refused exactly that, correctly, - // which is how this shape was arrived at. - "restart-on": []string{"overlay-config"}, - } - if node.TakesOver != nil { - // The private network takes over the tunnel it found (novox/hq ADR 0105): before this - // unit starts, the host stops and disables the found one — never flushing it — and keeps - // its configuration like any held file. The key is already the found one: the node took - // it as its own overlay key when it enrolled, which is why the mesh's peer list for it - // carries the found peers under the key they know. - up["takes-over"] = map[string]any{ - "interface": node.TakesOver.Interface, - "unit": node.TakesOver.Unit, - "config": node.TakesOver.Config, - } - } - - resources := []Resource{ - { - "id": "overlay-tools", "type": "package", "package": "wireguard-tools", - }, - { - "id": "overlay-config", "type": "file", "path": ConfigPath, - // Readable only by root: it lists every peer's key and endpoint, which is a map of - // the mesh. Not secret in the way a private key is, and not something to leave - // world-readable on a machine somebody else also uses. - "mode": "0600", - "content": config(node, peers, keyPath), - }, - up, - } - - // The names used to be appended here, on the argument that a node with peers and no names is - // half on the network. True, and the wrong place to fix it: names would be identical over a - // different private network, so bundling them with WireGuard made one module out of two - // things. They are their own module now, requiring this one — which is what keeps them - // arriving together without pretending they are the same concern. - return json.Marshal(map[string]any{"declaration": 1, "resources": resources}) -} - -// config writes the interface file. -// -// Deliberately in the order a person would read it: who I am, then who I talk to, each with a -// line saying why it is there. A generated file that cannot be understood by the person it -// confuses is a generated file that gets edited by hand. -func config(node Node, peers []Peer, keyPath string) string { - var b strings.Builder - b.WriteString("# Generated by the mesh. Do not edit — this file is replaced whenever the\n") - b.WriteString("# peer graph changes, and an edit would survive until the next change and\n") - b.WriteString("# then vanish, which is worse than not being applied at all.\n") - fmt.Fprintf(&b, "#\n# node %s", node.Name) - if node.Site != "" { - fmt.Fprintf(&b, ", at %s", node.Site) - } - if !node.Reachable() { - b.WriteString(", not dialable — it opens every path itself") - } - b.WriteString("\n\n[Interface]\n") - fmt.Fprintf(&b, "Address = %s/32\n", node.Address) - if node.Reachable() { - if port := portOf(node.Endpoint); port != "" { - fmt.Fprintf(&b, "ListenPort = %s\n", port) - } - } else if node.TakesOver != nil && node.TakesOver.Port != 0 { - // Not dialable from the hub, but a LAN peer dials this node on the tunnel it took over, - // so the mesh's interface must listen on that same port (novox/hq: a taken tunnel brings - // its port). Without this the takeover guard refuses overlay-up, and re-placing the node - // with an endpoint — the guard's suggested remedy — breaks a NAT'd node's path. - fmt.Fprintf(&b, "ListenPort = %d\n", node.TakesOver.Port) - } - // The MTU the found tunnel carried, when it set one: a path tuned to 1380 (say) stalls TLS - // and hangs transfers if the mesh's interface comes up at the 1420 default, and no ping shows - // it (novox/hq: a taken tunnel carries its MTU). - if node.TakesOver != nil && node.TakesOver.MTU != 0 { - fmt.Fprintf(&b, "MTU = %d\n", node.TakesOver.MTU) - } - // The private key is set from a file the node wrote, so it never appears here and never - // travelled. Everything else in this file came from the mesh; this one line is the node's. - fmt.Fprintf(&b, "PostUp = wg set %%i private-key %s\n", keyPath) - - if node.Hub { - // A hub carries traffic *between* its spokes, and a Linux machine does not forward - // packets unless it is told to. Without this every spoke reaches the hub perfectly and - // no spoke reaches any other — which is exactly how it failed in the lab, and it looks - // like a peering problem rather than a kernel setting. - // - // Here rather than in a separate resource because it is part of what being a hub means, - // and because it should last exactly as long as the interface does: a machine that stops - // being the hub should stop forwarding, and PostDown below is how that happens. - b.WriteString("PostUp = sysctl -q -w net.ipv4.ip_forward=1\n") - b.WriteString("PostDown = sysctl -q -w net.ipv4.ip_forward=0\n") - - // And past the machine's own firewall, which on any node with a container runtime is - // closed. Docker sets the FORWARD policy to DROP and inserts its chains, so the foundation - // this mesh installs at tier 1 silently breaks the network it builds at tier 2: every - // spoke reaches the hub, no spoke reaches any other, and every part of it reports - // success. Found in the lab; nothing about it is visible from the mesh's own state. - // - // Inserted at the top so it precedes those chains, and removed on the way down so a - // machine that stops being the hub stops carrying other people's traffic. Guarded on - // iptables existing: a machine without it has no policy to get past. - for _, direction := range []string{"-i", "-o"} { - fmt.Fprintf(&b, - "PostUp = command -v iptables >/dev/null && iptables -I FORWARD 1 %s %%i -j ACCEPT || true\n", - direction) - } - for _, direction := range []string{"-i", "-o"} { - fmt.Fprintf(&b, - "PostDown = command -v iptables >/dev/null && iptables -D FORWARD %s %%i -j ACCEPT || true\n", - direction) - } - } - - for _, p := range peers { - fmt.Fprintf(&b, "\n# %s — %s\n[Peer]\n", p.Name, p.Why) - fmt.Fprintf(&b, "PublicKey = %s\n", p.Key) - fmt.Fprintf(&b, "AllowedIPs = %s\n", p.Allowed) - if p.Endpoint != "" { - fmt.Fprintf(&b, "Endpoint = %s\n", p.Endpoint) - } else { - b.WriteString("# no endpoint: this peer cannot be dialled and opens the path itself\n") - } - if p.Keepalive { - b.WriteString("PersistentKeepalive = 25\n") - } - } - return b.String() -} - -func portOf(endpoint string) string { - if i := strings.LastIndex(endpoint, ":"); i >= 0 { - return endpoint[i+1:] - } - return "" -} diff --git a/go/overlay/declaration_test.go b/go/overlay/declaration_test.go deleted file mode 100644 index 64fabd9..0000000 --- a/go/overlay/declaration_test.go +++ /dev/null @@ -1,311 +0,0 @@ -package overlay - -import ( - "encoding/json" - "fmt" - "strings" - "testing" -) - -func declarationFor(t *testing.T, node Node, peers []Peer) (string, []map[string]any) { - t.Helper() - raw, err := Declaration(node, peers, "") - if err != nil { - t.Fatal(err) - } - var d struct { - Declaration int `json:"declaration"` - Resources []map[string]any `json:"resources"` - } - if err := json.Unmarshal(raw, &d); err != nil { - t.Fatal(err) - } - if d.Declaration != 1 { - t.Fatalf("declaration version %d", d.Declaration) - } - for _, r := range d.Resources { - if r["type"] == "file" { - return r["content"].(string), d.Resources - } - } - t.Fatal("the declaration has no configuration file in it") - return "", nil -} - -func TestNoPrivateKeyEverTravels(t *testing.T) { - // The property the whole design rests on: the node generated its keypair and kept the private - // half, so the mesh composes a configuration for a node it cannot impersonate. A private key - // appearing here would mean the control plane had one — and a copy of its database would then - // be every node's network identity. - config, _ := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, - []Peer{{Name: "anchor", Key: "HUB", Allowed: "10.42.0.0/16", Endpoint: "198.51.100.1:51820"}}) - - if strings.Contains(config, "PrivateKey") { - t.Error("the configuration carries a PrivateKey line; the mesh must never hold one") - } - if !strings.Contains(config, "private-key "+DefaultKeyPath) { - t.Error("the configuration does not point at the key file the node wrote, so the " + - "interface would come up with no key at all") - } -} - -func TestTheDeclarationUsesOnlyShapesTheHostAlreadyHas(t *testing.T) { - // Connectivity needs nothing new from tier 0, and that is worth holding: the host does not - // know what a private network is, and should not learn. - _, resources := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil) - - allowed := map[string]bool{"package": true, "file": true, "service": true, - "directory": true, "container": true, "action": true} - for _, r := range resources { - if !allowed[r["type"].(string)] { - t.Errorf("the overlay declaration uses %q, which the host does not have", r["type"]) - } - } -} - -func TestTheInterfaceComesBackAfterAReboot(t *testing.T) { - // A node whose overlay only exists while something is watching is not a node that survives - // being switched off and on — and it would come back unreachable, which is the worst way to - // come back. - _, resources := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil) - for _, r := range resources { - if r["type"] == "service" { - if r["boot"] != "enabled" { - t.Error("the overlay interface is not enabled at boot") - } - if r["state"] != "running" { - t.Error("the overlay interface is not asked to be running") - } - return - } - } - t.Error("nothing in the declaration brings the interface up") -} - -func TestTheConfigurationIsNotWorldReadable(t *testing.T) { - // It lists every peer's key and endpoint, which is a map of the mesh. Not secret the way a - // private key is, and not something to leave readable on a machine somebody else also uses. - // The peer list specifically, not every file. `/etc/hosts` is in here too and must be - // world-readable, or nothing on the machine resolves anything — a check that swept all files - // would force it to 0600 and break the machine to protect a file that is not secret. - _, resources := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil) - for _, r := range resources { - if r["id"] == "overlay-config" && r["mode"] != "0600" { - t.Errorf("the peer list is mode %v", r["mode"]) - } - if r["id"] == "mesh-names" && r["mode"] != "0644" { - t.Errorf("the name file is mode %v; nothing on the machine could read it", r["mode"]) - } - } -} - -func TestAPeerThatCannotBeDialledSaysSo(t *testing.T) { - // A [Peer] with no Endpoint is correct and looks like a mistake. Saying why stops somebody - // helpfully adding one that cannot work. - config, _ := declarationFor(t, Node{Name: "anchor", Key: "HUB", Address: "10.42.0.1", - Endpoint: "198.51.100.1:51820", Hub: true}, - []Peer{{Name: "laptop", Key: "PUB", Allowed: "10.42.0.2/32", Why: "routes through this hub"}}) - - if strings.Contains(config, "Endpoint =") { - t.Error("an endpoint was written for a peer that has none") - } - if !strings.Contains(config, "cannot be dialled") { - t.Error("the file does not say why that peer has no endpoint") - } -} - -func TestTheFileSaysNotToEditIt(t *testing.T) { - // It is replaced whenever the graph changes. An edit survives until then and vanishes, which - // is worse than never being applied — the machine works, then stops, and nothing changed - // that anybody remembers. - config, _ := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil) - if !strings.Contains(config, "Do not edit") { - t.Error("a generated file does not say it is generated") - } -} - -func TestANodeWithNoAddressIsRefused(t *testing.T) { - // Rather than a configuration with a blank address, which wg-quick would reject on the - // machine, at boot, where the failure is much harder to see. - if _, err := Declaration(Node{Name: "laptop", Key: "PUB"}, nil, ""); err == nil { - t.Fatal("a node with no overlay address was given a configuration") - } -} - -func TestOnlyAReachableNodeListens(t *testing.T) { - // A ListenPort on a node nothing can dial is a port open for no reason. - config, _ := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil) - if strings.Contains(config, "ListenPort") { - t.Error("a node that cannot be dialled was told to listen") - } - config, _ = declarationFor(t, Node{Name: "anchor", Key: "HUB", Address: "10.42.0.1", - Endpoint: "198.51.100.1:51820"}, nil) - if !strings.Contains(config, "ListenPort = 51820") { - t.Error("a reachable node does not listen on the port its endpoint names") - } -} - -func TestTheServiceIsRestartedWhenThePeerListChanges(t *testing.T) { - // The fault this exists to catch, found in the lab the moment a third node arrived: a running - // WireGuard interface does not re-read its configuration. The file was replaced, the service - // was already running so nothing reloaded it, and every existing node kept a network that no - // longer matched the mesh — while looking entirely successful. - // - // So the declaration has to verify what the interface is *carrying*, not what the file says. - // - // And it must be declared state rather than a command: the host refuses an action arriving - // over the link (novox/hq ADR 0005), correctly, which is how this shape was arrived at. There - // is a test below that no action is ever in here. - _, resources := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, - []Peer{{Name: "anchor", Key: "HUBKEY", Allowed: "10.42.0.0/16"}}) - - for _, r := range resources { - if r["type"] != "service" { - continue - } - reflects := fmt.Sprint(r["restart-on"]) - if !strings.Contains(reflects, "overlay-config") { - t.Errorf("the interface does not restart when its configuration changes: %v", reflects) - } - return - } - t.Error("nothing in the declaration brings the interface up") -} - -func TestTheOverlayDeclarationCarriesNoAction(t *testing.T) { - // The link may not carry an action, and the host refuses a declaration containing one — whole, - // not in part. An overlay declaration with an action in it does not half-apply: it leaves the - // node with no network at all. - _, resources := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil) - for _, r := range resources { - if r["type"] == "action" { - t.Errorf("the declaration contains an action (%v); the host will refuse the whole "+ - "thing and the node will have no network", r["id"]) - } - } -} - -func TestTheHubForwardsAndNobodyElseDoes(t *testing.T) { - // A hub carries traffic between its spokes, and Linux does not forward packets unless told - // to. Without it every spoke reaches the hub perfectly and no spoke reaches any other — which - // is how it failed in the lab, and it presents as a peering problem rather than a kernel - // setting, so it is worth being sure of. - hub, _ := declarationFor(t, Node{Name: "anchor", Key: "HUB", Address: "10.42.0.1", - Endpoint: "198.51.100.1:51820", Hub: true}, nil) - if !strings.Contains(hub, "ip_forward=1") { - t.Error("the hub does not enable forwarding, so its spokes cannot reach each other") - } - if !strings.Contains(hub, "ip_forward=0") { - t.Error("the hub never stops forwarding; a machine that stops being the hub would keep " + - "passing traffic it is no longer part of") - } - - // And past the machine's own firewall. Any node with a container runtime has FORWARD set to - // DROP by Docker, so enabling ip_forward alone changes nothing — which is exactly how it - // failed, with every spoke reaching the hub and no spoke reaching any other. - // Checked as PostUp specifically. "contains FORWARD" passes on the PostDown line alone, - // which would leave a hub that tears down rules it never put up. - if !strings.Contains(hub, "PostUp = command -v iptables") { - t.Error("the hub does not open its own firewall, so a container runtime's DROP policy " + - "silently eats everything it was supposed to carry") - } - if !strings.Contains(hub, "PostDown = command -v iptables") { - t.Error("the hub never removes those rules, so a machine that stops being the hub keeps " + - "passing traffic it is no longer part of") - } - - spoke, _ := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil) - if strings.Contains(spoke, "FORWARD") { - t.Error("a spoke was given forwarding rules it has no use for") - } - if strings.Contains(spoke, "ip_forward") { - t.Error("a spoke was told to forward packets, which is not its job and widens what a " + - "compromised one could do") - } -} - -// novox/hq ADR 0105: a node whose private network takes over the tunnel it found is told so on -// the interface's service, and nothing else about the declaration changes — the key is already -// the found one, taken at enrolment. -func TestTakingOverAFoundTunnelIsSaidOnTheInterfacesService(t *testing.T) { - node := Node{Name: "anchor", Key: "PUB", Address: "192.0.2.1", Hub: true, - Endpoint: "198.51.100.1:51900", - TakesOver: &TakeOver{Interface: "wg0", Unit: "wg-quick@wg0", Config: "/etc/wireguard/wg0.conf"}} - config, resources := declarationFor(t, node, nil) - - var up map[string]any - for _, r := range resources { - if r["type"] == "service" { - up = r - } - } - takes, ok := up["takes-over"].(map[string]any) - if !ok { - t.Fatalf("the interface's service does not say what it takes over: %+v", up) - } - if takes["unit"] != "wg-quick@wg0" || takes["config"] != "/etc/wireguard/wg0.conf" || takes["interface"] != "wg0" { - t.Errorf("the takeover names the wrong tunnel: %+v", takes) - } - if !strings.Contains(config, "ListenPort = 51900") { - t.Errorf("the hub's interface does not listen on the tunnel's port:\n%s", config) - } - if strings.Contains(config, "PrivateKey") { - t.Error("the found key travelled in the configuration; it is the node's own, set from its key file") - } - - _, plain := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "192.0.2.4"}, nil) - for _, r := range plain { - if _, says := r["takes-over"]; says { - t.Error("a node taking over nothing was told to take something over") - } - } -} - -func TestATakenTunnelBringsItsListenPortEvenWhenNotDialable(t *testing.T) { - // A home node behind NAT (no Endpoint, so not Reachable) that took over a tunnel must still - // listen on that tunnel's port, because its LAN peers dial it there (novox/hq: a taken tunnel - // brings its port). Without this the takeover guard refuses overlay-up. - config, _ := declarationFor(t, Node{ - Name: "shanks", Key: "SPOKE", Address: "10.10.0.3", - TakesOver: &TakeOver{Interface: "wg0", Unit: "wg-quick@wg0", Port: 51820}, - }, nil) - - if !strings.Contains(config, "ListenPort = 51820") { - t.Fatalf("a taken tunnel's port must be the mesh interface's ListenPort:\n%s", config) - } - if strings.Contains(config, "Endpoint =") { - t.Error("a node that only listens for LAN peers must not advertise an endpoint") - } -} - -func TestANodeWithNoTunnelAndNoEndpointStillListensOnNothing(t *testing.T) { - // The guard against over-emitting: a plain spoke with neither an endpoint nor a taken tunnel - // writes no ListenPort — it purely dials out. - config, _ := declarationFor(t, Node{Name: "laptop", Key: "K", Address: "10.10.0.9"}, nil) - if strings.Contains(config, "ListenPort") { - t.Fatalf("a dial-only node needs no ListenPort:\n%s", config) - } -} - -func TestATakenTunnelCarriesItsMTU(t *testing.T) { - // A path tuned to a smaller MTU (1380 here) must survive the takeover — the mesh interface - // comes up with the same MTU, or transfers hang silently (novox/hq: a taken tunnel carries - // its MTU). - config, _ := declarationFor(t, Node{ - Name: "shanks", Key: "SPOKE", Address: "10.10.0.3", - TakesOver: &TakeOver{Interface: "wg0", Port: 51820, MTU: 1380}, - }, nil) - if !strings.Contains(config, "MTU = 1380") { - t.Fatalf("the tuned MTU was dropped:\n%s", config) - } -} - -func TestNoMTULineWhenTheTunnelSetNone(t *testing.T) { - config, _ := declarationFor(t, Node{ - Name: "shanks", Key: "SPOKE", Address: "10.10.0.3", - TakesOver: &TakeOver{Interface: "wg0", Port: 51820}, - }, nil) - if strings.Contains(config, "MTU") { - t.Fatalf("no MTU was found, so none should be written:\n%s", config) - } -} diff --git a/go/overlay/doc.go b/go/overlay/doc.go index 8a9f1b7..47879cb 100644 --- a/go/overlay/doc.go +++ b/go/overlay/doc.go @@ -1,5 +1,5 @@ -// Package overlay is the private network's generator, copied from mesh-controller internal/overlay at commit -// 6c2a8d62100cb7d491f8eb9a623e1eeea5ec0de1 (novox/hq ADR 0453 clause 3, ADR 0446 clause 4): the files are unchanged but -// for the import path of the catalogue, which is mesh-sdk's go/catalogue, and this note. mesh-controller keeps its own copy -// until release 4b. +// Package overlay is the private network's names and its manifest: the module the mesh provides for itself, the +// requirement and seat it answers, the interface's name and files, and the mesh's internal names. Its generator, the +// peer graph and the declaration it renders are the node-engine's own (novox/hq ADR 0593 §1, §7), in mesh-host's +// internal/overlay. package overlay diff --git a/go/overlay/generator.go b/go/overlay/generator.go deleted file mode 100644 index d74087d..0000000 --- a/go/overlay/generator.go +++ /dev/null @@ -1,195 +0,0 @@ -package overlay - -import ( - "encoding/json" - "fmt" - "strconv" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// The private network as a module rather than as code beside the module system. -// -// A machine's peer list is derived from every other machine, so it differs on each one and -// changes when any of them changes — there is nothing that could be written in a manifest. So the -// module says its resources are computed, and this computes them. -// -// What that buys, beyond one mechanism instead of two: **a machine is on the private network -// because it was assigned the module.** Before this, every machine with a key and an address was -// on it, and there was no way to say a machine should stay off. -// -// It is worth saying what is *not* here, because networking looks like it should be foundational. -// The host needs none of this. It has an address and a route to the broker before the mesh exists -// — that is the machine's own networking — and the broker's address is carried in the enrolment -// token rather than resolved. So the private network is something the mesh installs on top, like -// anything else, and a node without it is an ordinary node that nothing reaches directly. - -// What a module asks for when it needs machines to reach each other. **This is the name that -// matters** — WireGuard is one way to answer it, and naming the requirement after the answer is -// how a mesh ends up unable to have a second one. -const Requirement = "private-network" - -// Name is the module that answers it with WireGuard. -// -// **It writes no names.** A machine's mesh names are answered by the mesh's one resolver (novox/hq -// ADR 0194), and /etc/hosts is the file of one module, the holder of `node-hostname` (ADR 0199, -// ADR 0223): the controller writes into no file another seat's holder owns. If the mesh ever needs a line there, it -// asks that holder to register it. This module asked for a `node-names` fact written into /etc/hosts -// until 2026-10-05; the host gives that region back at the first push without it. -const Name = "mesh-wireguard" - -// Addressing is the mesh handing out addresses on the private network itself. -// -// The mesh's resolver answers names from it, which is why it requires this rather than a private -// network in general. A different VPN that hands out its own addresses would come with its own -// names — the mesh has nothing to answer about a machine whose address it did not choose. -const Addressing = "mesh-addressing" - -// TheNetwork is what a machine can only have one of. -// -// Providing a private network is not the singular part — a machine could reasonably run two VPNs -// for two different purposes. Being **the** one the mesh runs over is singular, and without -// saying so a person who chose a different VPN can still end up with this one dragged back in by -// something that needed the mesh's own addresses. Which is exactly what happened, once. -const TheNetwork = "the-private-network" - -// **A resolver's data went the same way as the names.** Two constants used to sit here — a -// `mesh-resolver` module that would write the machines as wildcards, and a `resolver-data` -// requirement a daemon would ask for. Nothing ever provided or consumed either: a module that -// answers names asks for the `node-zones` fact in its own manifest (catalogue.FactsInto) and -// requires Addressing, since the file is made of the mesh's addresses and means nothing off the -// network. A requirement nothing provides is refused at resolution, so leaving the names here -// would only have documented a mechanism that does not exist. - -// **There is no bundle any more** (novox/hq ADR 0226). A `networking` module requiring this one and -// nothing else used to be what a machine was assigned, so that "get the network working" was one -// word and a second VPN could be chosen by assigning it instead. The mesh has one private network, -// every machine is on it, and the bundle was a second name for this module that every machine -// carried. A machine is assigned Name directly; another VPN is still chosen by assigning it in its -// place, which is all the bundle ever did. - -// Generator answers what one node's network configuration is. -type Generator struct { - nodes []Node - graph Graph - // keyPath is where each node keeps the private half it generated. Named rather than carried: - // the mesh has never seen it and never will. - keyPath string - // No registry. Being on the network is still what grants a machine the right to pull from the - // mesh's artifact store in the clear (novox/hq ADR 0082), but the runtime's file is the runtime's - // module's: the private network writes nothing into it, and that module states the registry - // itself, told where the store is reached by ${seat:mesh-artifact-store:reach} (novox/hq ADR - // 0222, issue 190). -} - -// From builds a generator over the machines that are part of the network. -// -// The nodes given are the ones assigned the module — not every node the mesh knows. A machine -// that was never given it is absent from everybody's peer list and from the names, which is what -// "not on the network" has to mean. -func From(nodes []Node, cidr, keyPath string) (*Generator, error) { - graph, err := Compute(nodes, cidr) - if err != nil { - return nil, err - } - return &Generator{nodes: nodes, graph: graph, keyPath: keyPath}, nil -} - -// Resources is one node's interface, peers and names. -func (g *Generator) Resources(node string) ([]map[string]any, bool, error) { - peers, part := g.graph[node] - if !part { - // Assigned and not yet placed on the network. Ordinary and brief, so it is an answer - // rather than an error. - return nil, false, nil - } - var self Node - for _, n := range g.nodes { - if n.Name == node { - self = n - } - } - - raw, err := Declaration(self, peers, g.keyPath) - if err != nil { - return nil, false, err - } - var parsed struct { - Resources []map[string]any `json:"resources"` - } - if err := json.Unmarshal(raw, &parsed); err != nil { - return nil, false, err - } - return parsed.Resources, true, nil -} - -// Nodes are the machines this generator was built over, so a caller can say who is on the network. -func (g *Generator) Nodes() []Node { return g.nodes } - -// Graph is the peer list per node, for showing. -func (g *Generator) Graph() Graph { return g.graph } - -// Manifest is the module the mesh provides for itself. -// -// It ships with the control plane rather than coming from a repository, because the thing that -// computes it ships with the control plane. That is the only way it is unusual: it is assigned, -// unassigned, resolved and settled exactly like a module somebody wrote. -func Manifest() map[string]any { - return map[string]any{ - "module": Name, - "version": "1", - "computed": Name, - "provides": []string{Requirement, Addressing}, - "claims": []map[string]any{{"name": TheNetwork, "scope": "node"}}, - } -} - -// Empty is a network nobody is on. -// -// A mesh where no machine was given the module. Legitimate rather than broken — every node still -// reaches the broker, which is what being in the mesh is — so it answers "not part of this" for -// everyone instead of refusing for want of a hub. -func Empty() *Generator { return &Generator{graph: Graph{}} } - -// Listens is the port this node accepts the private network on, which only a hub has. -// -// **A fact about this machine's place in the mesh, not about the module.** Every machine on the -// network runs the same module; a hub is dialled by every node at other sites and needs its port -// open, and a machine that is not a hub dials out and needs nothing open at all. A static field in -// a manifest is one answer for every machine that runs it, so it cannot say this — and the machine -// it would get wrong is the one facing the public internet, which is the machine that most needs -// filtering. -// -// The port is the one in the endpoint, which is also where the interface takes its ListenPort -// from. One source, so a rule set cannot open a port the interface is not on. -func (g *Generator) Listens(node string) ([]catalogue.Listening, error) { - for _, n := range g.nodes { - if n.Name != node { - continue - } - if !n.Reachable() { - // It dials out and nothing dials it. Opening a port here would be opening one on a - // machine nothing connects to, which is not harmless — it is a rule with no source - // that somebody later has to work out the reason for. - return nil, nil - } - port := portOf(n.Endpoint) - if port == "" { - return nil, fmt.Errorf( - "%s is reachable at %q and no port can be read from it, so what it must accept "+ - "the private network on is unknown", node, n.Endpoint) - } - number, err := strconv.Atoi(port) - if err != nil { - return nil, fmt.Errorf("%s is reachable at %q, and %q is not a port", node, n.Endpoint, port) - } - return []catalogue.Listening{{ - Port: number, Protocol: "udp", From: catalogue.FromEverywhere, - // From everywhere, and deliberately: a node at another site is not on the private - // network until this port lets it on, so restricting this to the mesh would be a - // rule that can never be satisfied by the thing it exists for. - Why: "the private network — a node at another site has no other way in", - }}, nil - } - return nil, nil -} diff --git a/go/overlay/generator_test.go b/go/overlay/generator_test.go deleted file mode 100644 index 569322b..0000000 --- a/go/overlay/generator_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package overlay - -import ( - "testing" -) - -// novox/hq ADR 0222, issue 190: the runtime's file and service are the runtime's module's. The -// private network writes nothing into either — being on it still grants the right to pull from the -// mesh's store in the clear (ADR 0082), and the runtime's module states that trust itself. -func TestTheNetworkWritesNothingOfTheRuntimes(t *testing.T) { - nodes := []Node{ - {Name: "anchor", Site: "lab", Hub: true, Endpoint: "192.0.2.10:51820", Key: "k1", Address: "10.42.0.1"}, - {Name: "node2", Site: "lab", Key: "k2", Address: "10.42.0.2"}, - } - g, err := From(nodes, "10.42.0.0/16", "") - if err != nil { - t.Fatal(err) - } - for _, node := range []string{"anchor", "node2"} { - resources, part, err := g.Resources(node) - if err != nil || !part { - t.Fatalf("%s: resources: %v part=%v", node, err, part) - } - for _, r := range resources { - if r["path"] == "/etc/docker/daemon.json" || r["unit"] == "docker.service" { - t.Errorf("%s: the private network declares the runtime's %v", node, r) - } - } - } -} diff --git a/go/overlay/graph.go b/go/overlay/graph.go deleted file mode 100644 index 4be1a43..0000000 --- a/go/overlay/graph.go +++ /dev/null @@ -1,231 +0,0 @@ -// Package overlay computes the private network every node runs on. -// -// novox/hq 08-connectivity. This is control-plane work by definition: a peer list is derived from -// every node at once, and no node has that. A node computes nothing about the mesh — it generates -// a keypair, publishes the public half, and receives the rest. -// -// The shape is a hub, with direct peering between nodes at the same site. Not a full mesh, and -// the reason is a property of WireGuard rather than a preference: there is no failover. A more -// specific route to a dead endpoint blackholes; it does not fall back to the general one. So a -// node gets exactly one path to any peer, because two would mean one of them silently swallowing -// traffic. -package overlay - -import ( - "errors" - "fmt" - "sort" - "strings" -) - -// Node is one machine's place on the network, as the mesh holds it. -type Node struct { - Name string - Key string - Endpoint string - Site string - Hub bool - Address string - - // Carried are the peers of the tunnel this node took over (novox/hq ADR 0105): machines the - // mesh has no record of, each known by the public key and the address the found tunnel routed - // to it. Only a hub has any. They stay in its peer list until a node enrols with that key — - // from then on the node is the peer. - Carried []Carried - // TakesOver names the found tunnel this node's private network replaces: its unit is stopped - // and disabled, never flushed, and its configuration kept, before the mesh's interface comes - // up with the found key. Nil on a node that raises the mesh's interface beside whatever it has. - TakesOver *TakeOver -} - -// Carried is one peer of an adopted tunnel that has not enrolled: a peer of the tunnel, not a -// node of the mesh. -type Carried struct { - Key string - Address string -} - -// TakeOver is the found tunnel a node's private network takes over, as the host is told it. -type TakeOver struct { - Interface string - Unit string - Config string - // MTU is the found tunnel's, when it set one — emitted so a tuned path keeps its MTU. - MTU int - // Port is the port the found tunnel listened on. The mesh's interface must listen on it too, - // even on a node that is not dialable from the hub: a home node's LAN peers dial it there - // (novox/hq: a taken tunnel brings its port). Listening is "a peer dials me here"; it is not - // "the hub can dial me", which is Reachable — the two were conflated. - Port int -} - -// HostPrefix is one address as a route: /32 for IPv4, /128 for IPv6. -func HostPrefix(address string) string { - if strings.Contains(address, ":") { - return address + "/128" - } - return address + "/32" -} - -// CarriedName is how a carried peer is named in a peer list: it has no node name, so it is named -// by the key its packets arrive under. -func CarriedName(key string) string { - short := key - if len(short) > 8 { - short = short[:8] + "…" - } - return "a peer of the tunnel (" + short + ")" -} - -// Reachable reports whether other nodes can dial this one. Declared, never inferred. -func (n Node) Reachable() bool { return strings.TrimSpace(n.Endpoint) != "" } - -// Peer is one entry in a node's peer list. -type Peer struct { - Name string - Key string - // Endpoint is empty when this peer cannot be dialled — it must dial us instead. - Endpoint string - // Allowed is what traffic goes down this tunnel. A single address for a direct peer; the - // whole overlay for the hub, which is what makes it the route of last resort. - Allowed string - // Keepalive matters only on the side behind NAT: a node that cannot be dialled has to keep - // the path open from its end, or the peer's first packet arrives at a mapping that has - // already expired. - Keepalive bool - // Why this peer is in the list, for a person reading a generated file and wondering. - Why string -} - -// Graph is every node's peer list. -type Graph map[string][]Peer - -// ErrNoHub means nobody has said which node is the hub. -// -// Its own error rather than an empty graph: a mesh with no hub has no path between sites, and -// answering with "no peers" would look like a working mesh where nothing can reach anything. -var ErrNoHub = errors.New("this mesh has no hub, so there is no path between sites") - -// Compute derives every node's peer list. -// -// Nodes without a key or an address are skipped rather than refused: a node that has enrolled and -// not yet been given a place on the network is an ordinary in-between state, and failing the whole -// graph because one node is half-configured would mean no node gets a network. -func Compute(nodes []Node, overlayCIDR string) (Graph, error) { - var hub *Node - usable := make([]Node, 0, len(nodes)) - for i := range nodes { - n := nodes[i] - if n.Key == "" || n.Address == "" { - continue - } - usable = append(usable, n) - if n.Hub { - hub = &usable[len(usable)-1] - } - } - if len(usable) == 0 { - return Graph{}, nil - } - if hub == nil { - return nil, ErrNoHub - } - if !hub.Reachable() { - return nil, fmt.Errorf( - "%s is the hub and has no endpoint, so nothing can dial it. The hub is the one node "+ - "that must be reachable from wherever the others are", hub.Name) - } - - graph := Graph{} - for _, self := range usable { - var peers []Peer - - // A node may share a site with the hub, and then the hub is one peer rather than two. - // Written before the loop because it changes what that loop may emit: WireGuard takes one - // entry per public key, so a hub appearing twice is a configuration it refuses — and the - // mesh would have produced it silently. The lab found this on the first two machines that - // shared a site with their hub. - hubIsHere := !self.Hub && self.Site != "" && self.Site == hub.Site - - for _, other := range usable { - if other.Name == self.Name || (hubIsHere && other.Name == hub.Name) { - continue - } - // Two nodes at the same site peer directly — but only if one of them can be - // dialled. If neither can, nobody opens the path, and the direct route is more - // specific than the hub's, so it wins and blackholes. That is this design's own - // stated hazard arriving in it: *a more specific route to a dead endpoint - // blackholes; it does not fall back to the general one.* - // - // Found in the lab with two machines at one site behind no reachable address, which - // is the ordinary shape of a home: they were given each other as peers, neither - // could start, and they could not reach each other at all while both reached the hub - // perfectly. - if self.Site != "" && self.Site == other.Site && (self.Reachable() || other.Reachable()) { - peers = append(peers, Peer{ - Name: other.Name, Key: other.Key, - Endpoint: other.Endpoint, - Allowed: other.Address + "/32", - Keepalive: !self.Reachable(), - Why: "at the same site", - }) - } - } - - if !self.Hub { - // Everything else goes through the hub, including a node that roams. AllowedIPs is - // the whole overlay, so this is the route of last resort — and because direct peers - // above are single addresses, they win on specificity without either being ambiguous. - why := "the hub — everything not at this site" - if hubIsHere { - // One entry doing both jobs: the direct path to a machine that happens to be - // here, and the route to everywhere else. Splitting them would need two entries - // for one key, which is the thing being avoided. - why = "the hub, which is also at this site — everything goes here" - } - peers = append(peers, Peer{ - Name: hub.Name, Key: hub.Key, - Endpoint: hub.Endpoint, - Allowed: overlayCIDR, - Keepalive: !self.Reachable(), - Why: why, - }) - } else { - // The hub holds every node that does not share a site with it, because those nodes - // route through it and it must know where to send the replies. Ones it cannot dial - // will dial it. - enrolled := map[string]bool{} - for _, other := range usable { - enrolled[other.Key] = true - if other.Name == self.Name || (self.Site != "" && self.Site == other.Site) { - continue - } - peers = append(peers, Peer{ - Name: other.Name, Key: other.Key, - Endpoint: other.Endpoint, - Allowed: other.Address + "/32", - Why: "routes through this hub", - }) - } - // And every peer of the tunnel it took over that has not enrolled (novox/hq ADR - // 0105): the same key and the same address the found tunnel had for it, so the - // machine behind it cannot tell the tunnel changed hands. No endpoint — it dials in, - // as it always did. Once a node enrols with that key, the node's entry above is the - // peer, and WireGuard takes one entry per key. - for _, c := range self.Carried { - if enrolled[c.Key] { - continue - } - peers = append(peers, Peer{ - Name: CarriedName(c.Key), Key: c.Key, - Allowed: HostPrefix(c.Address), - Why: "carried from the tunnel this hub took over — a peer of the tunnel, not yet a node of the mesh", - }) - } - } - - sort.Slice(peers, func(i, j int) bool { return peers[i].Name < peers[j].Name }) - graph[self.Name] = peers - } - return graph, nil -} diff --git a/go/overlay/graph_test.go b/go/overlay/graph_test.go deleted file mode 100644 index 5cfe6ea..0000000 --- a/go/overlay/graph_test.go +++ /dev/null @@ -1,342 +0,0 @@ -package overlay - -import ( - "errors" - "strings" - "testing" -) - -const cidr = "10.42.0.0/16" - -func at(name, site, address, endpoint string, hub bool) Node { - return Node{Name: name, Key: "key-" + name, Site: site, Address: address, - Endpoint: endpoint, Hub: hub} -} - -func peersOf(t *testing.T, g Graph, node string) map[string]Peer { - t.Helper() - out := map[string]Peer{} - for _, p := range g[node] { - out[p.Name] = p - } - return out -} - -func TestEveryNodeReachesTheHub(t *testing.T) { - // The property that makes this a network at all. Without it a node has no route to anything - // it does not share a site with. - g, err := Compute([]Node{ - at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), - at("laptop", "", "10.42.0.2", "", false), - at("home", "house", "10.42.0.3", "", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - for _, node := range []string{"laptop", "home"} { - hub, ok := peersOf(t, g, node)["anchor"] - if !ok { - t.Fatalf("%s has no route to the hub", node) - } - if hub.Allowed != cidr { - t.Errorf("%s routes %s through the hub; it must be the whole overlay or the hub is "+ - "not a route of last resort", node, hub.Allowed) - } - } -} - -func TestNodesAtOneSitePeerDirectly(t *testing.T) { - // Machines that share a site have a path that does not need the hub, and using it keeps their - // traffic off a link that may be on another continent. - g, err := Compute([]Node{ - at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), - at("desk", "house", "10.42.0.2", "192.0.2.2:51820", false), - at("server", "house", "10.42.0.3", "192.0.2.3:51820", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - - direct, ok := peersOf(t, g, "desk")["server"] - if !ok { - t.Fatal("two machines at one site do not peer directly") - } - if direct.Allowed != "10.42.0.3/32" { - t.Errorf("the direct peer allows %s; a single address is what makes it win on "+ - "specificity over the hub's whole-overlay route", direct.Allowed) - } -} - -func TestARoamingNodeGetsExactlyOnePath(t *testing.T) { - // Hub-only, and not as a simplification. WireGuard has no failover: a more specific route to - // a dead endpoint blackholes rather than falling back, so two paths would mean one of them - // silently swallowing traffic. - g, err := Compute([]Node{ - at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), - at("laptop", "", "10.42.0.2", "", false), - at("other", "", "10.42.0.3", "", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - if got := len(g["laptop"]); got != 1 { - t.Fatalf("a roaming node has %d peers; it gets exactly one path", got) - } - if g["laptop"][0].Name != "anchor" { - t.Errorf("a roaming node's single path is %s, not the hub", g["laptop"][0].Name) - } -} - -func TestTwoRoamingNodesDoNotPeerWithEachOther(t *testing.T) { - // An empty site is not a site. Nodes that roam have no shared location, and treating "" as - // one would have every roaming machine try to dial every other, none of which can be dialled. - g, err := Compute([]Node{ - at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), - at("laptop", "", "10.42.0.2", "", false), - at("phone", "", "10.42.0.3", "", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - if _, ok := peersOf(t, g, "laptop")["phone"]; ok { - t.Error("two nodes with no site peered as though they shared one") - } -} - -func TestOnlyTheSideThatCannotBeDialledKeepsThePathOpen(t *testing.T) { - // Keepalive matters exactly once: on the node behind NAT. Without it the peer's first packet - // arrives at a mapping that has already expired. On the reachable side it is pointless - // traffic for ever. - g, err := Compute([]Node{ - at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), - at("laptop", "", "10.42.0.2", "", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - if !peersOf(t, g, "laptop")["anchor"].Keepalive { - t.Error("a node that cannot be dialled does not keep its path open") - } - if peersOf(t, g, "anchor")["laptop"].Keepalive { - t.Error("a reachable node sends keepalives it does not need") - } - - // And between two direct peers at one site, where whether to keep the path open depends on - // which end you are. Checked here because the hub's own peer list happens not to set the - // field at all, so asserting only against the hub tests an absence rather than the rule. - same, err := Compute([]Node{ - at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), - at("desk", "house", "10.42.0.2", "192.0.2.2:51820", false), - at("server", "house", "10.42.0.3", "", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - if !peersOf(t, same, "server")["desk"].Keepalive { - t.Error("the peer that cannot be dialled does not keep the path open") - } - if peersOf(t, same, "desk")["server"].Keepalive { - t.Error("the peer that can be dialled sends keepalives it does not need") - } -} - -func TestTheHubKnowsEveryoneItRoutesFor(t *testing.T) { - // The replies have to get back. A hub that does not hold a peer cannot answer it. - g, err := Compute([]Node{ - at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), - at("laptop", "", "10.42.0.2", "", false), - at("home", "house", "10.42.0.3", "", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - hub := peersOf(t, g, "anchor") - for _, want := range []string{"laptop", "home"} { - if _, ok := hub[want]; !ok { - t.Errorf("the hub does not hold %s, so replies to it have nowhere to go", want) - } - } -} - -func TestAMeshWithNoHubIsRefused(t *testing.T) { - // Not an empty graph. A mesh with no hub has no path between sites, and answering "no peers" - // would look like a working network in which nothing can reach anything — which is how the - // old arrangement failed, silently, when nobody knew the address convention. - _, err := Compute([]Node{ - at("a", "", "10.42.0.1", "", false), - at("b", "", "10.42.0.2", "", false), - }, cidr) - if !errors.Is(err, ErrNoHub) { - t.Fatalf("a mesh with no hub gave %v", err) - } -} - -func TestAnUnreachableHubIsRefused(t *testing.T) { - // The hub is the one node that must be dialable from wherever the others are. Left - // unchecked, every node would be given a peer it can never reach and the mesh would look - // configured and be silent. - _, err := Compute([]Node{ - at("anchor", "datacentre", "10.42.0.1", "", true), - at("laptop", "", "10.42.0.2", "", false), - }, cidr) - if err == nil { - t.Fatal("a hub with no endpoint was accepted") - } - if !strings.Contains(err.Error(), "must be reachable") { - t.Errorf("the refusal does not say why: %v", err) - } -} - -func TestANodeWithNoPlaceYetIsSkippedRatherThanFatal(t *testing.T) { - // A node that has enrolled and not yet been given an address is an ordinary in-between state. - // Failing the whole graph over it would mean no node gets a network because one is half done. - g, err := Compute([]Node{ - at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), - at("laptop", "", "10.42.0.2", "", false), - {Name: "newcomer", Key: "", Address: ""}, - }, cidr) - if err != nil { - t.Fatal(err) - } - if _, ok := g["newcomer"]; ok { - t.Error("a node with no key or address was given a peer list") - } - if _, ok := peersOf(t, g, "laptop")["newcomer"]; ok { - t.Error("a node with no key was put in somebody's peer list") - } -} - -func TestNobodyPeersWithThemselves(t *testing.T) { - g, err := Compute([]Node{ - at("anchor", "house", "10.42.0.1", "198.51.100.1:51820", true), - at("desk", "house", "10.42.0.2", "192.0.2.2:51820", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - for node, peers := range g { - for _, p := range peers { - if p.Name == node { - t.Errorf("%s peers with itself", node) - } - } - } -} - -func TestAHubAtYourOwnSiteAppearsOnceNotTwice(t *testing.T) { - // WireGuard takes one entry per public key. A hub that shares a site with a spoke was being - // emitted twice — once as a direct peer and once as the route of last resort — producing a - // configuration the interface refuses, from a mesh that thought it had succeeded. - // - // Found in the lab on the first two machines that shared a site with their hub, which is the - // ordinary case for a small mesh and was in none of the tests above. - g, err := Compute([]Node{ - at("anchor", "lab", "10.42.0.1", "192.0.2.10:51820", true), - at("laptop", "lab", "10.42.0.2", "", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - - seen := map[string]int{} - for _, p := range g["laptop"] { - seen[p.Key]++ - } - for key, count := range seen { - if count > 1 { - t.Errorf("the key %s appears %d times; WireGuard takes one entry per key", key, count) - } - } - - if len(g["laptop"]) != 1 { - t.Fatalf("laptop has %d peers; the hub at its own site is one peer, not two", len(g["laptop"])) - } - // And that single entry has to carry everything, or the node keeps a direct path to the hub - // and loses its route to everywhere else. - if got := g["laptop"][0].Allowed; got != cidr { - t.Errorf("the single entry allows %s; it is both the direct path and the route of last "+ - "resort, so it carries the whole overlay", got) - } -} - -func TestTwoUnreachableNodesAtOneSiteDoNotPeerDirectly(t *testing.T) { - // Nobody would open the path, and the direct route is more specific than the hub's — so it - // wins and blackholes. Both nodes would reach the hub perfectly and be unable to reach each - // other, which is the worst arrangement available: it looks configured and is not. - // - // This is the design's own warning arriving in its implementation, and the lab found it with - // two machines at one site behind no reachable address — the ordinary shape of a house. - g, err := Compute([]Node{ - at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), - at("desk", "house", "10.42.0.2", "", false), - at("server", "house", "10.42.0.3", "", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - - if _, ok := peersOf(t, g, "desk")["server"]; ok { - t.Error("two nodes that neither can be dialled were peered directly; neither could open " + - "the path and the route is more specific than the hub's, so it blackholes") - } - // And they must still be able to reach each other — through the hub. - if hub := peersOf(t, g, "desk")["anchor"]; hub.Allowed != cidr { - t.Errorf("desk's route to everything else allows %s", hub.Allowed) - } -} - -func TestOneReachableNodeIsEnoughToPeerDirectly(t *testing.T) { - // The other side of it: if either end can be dialled, the path can be opened, and using it - // keeps their traffic off a hub that may be on another continent. - g, err := Compute([]Node{ - at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), - at("desk", "house", "10.42.0.2", "192.0.2.2:51820", false), - at("server", "house", "10.42.0.3", "", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - if _, ok := peersOf(t, g, "server")["desk"]; !ok { - t.Error("a node did not peer with a reachable neighbour at its own site") - } - if _, ok := peersOf(t, g, "desk")["server"]; !ok { - t.Error("the reachable node did not hold its unreachable neighbour, so replies have " + - "nowhere to go") - } -} - -// novox/hq ADR 0105: a hub that took over the predecessor's tunnel carries every peer that tunnel -// had, under the key and at the address the peer knows, until a node enrols with that key. -func TestTheHubCarriesTheTunnelsPeersUntilTheyEnrol(t *testing.T) { - hub := at("anchor", "hosting", "192.0.2.1", "198.51.100.1:51900", true) - hub.Carried = []Carried{ - {Key: "key-home", Address: "192.0.2.2"}, - {Key: "key-workstation", Address: "192.0.2.3"}, - } - // The machine behind key-home enrolled: it is a node now, at the address it kept. - home := at("home", "house", "192.0.2.2", "", false) - home.Key = "key-home" - - g, err := Compute([]Node{hub, home}, "192.0.2.0/24") - if err != nil { - t.Fatal(err) - } - peers := peersOf(t, g, "anchor") - carried, ok := peers[CarriedName("key-workstation")] - if !ok { - t.Fatalf("the hub does not carry the peer that has not enrolled: %+v", g["anchor"]) - } - if carried.Allowed != "192.0.2.3/32" || carried.Endpoint != "" || carried.Key != "key-workstation" { - t.Errorf("a carried peer is not the tunnel's own entry — same key, its one address, no endpoint: %+v", carried) - } - if _, twice := peers[CarriedName("key-home")]; twice { - t.Error("a peer that enrolled is carried as well as listed as a node: WireGuard takes one entry per key") - } - if node, ok := peers["home"]; !ok || node.Key != "key-home" || node.Allowed != "192.0.2.2/32" { - t.Errorf("the enrolled peer is not the node it became: %+v", node) - } - // Carried peers are the hub's business only: a spoke routes everything through the hub. - if _, leaked := peersOf(t, g, "home")[CarriedName("key-workstation")]; leaked { - t.Error("a carried peer appeared in a spoke's peer list") - } -} diff --git a/go/overlay/opens_test.go b/go/overlay/opens_test.go deleted file mode 100644 index 7abf1bd..0000000 --- a/go/overlay/opens_test.go +++ /dev/null @@ -1,113 +0,0 @@ -package overlay - -import ( - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -func networkOf(t *testing.T, nodes []Node) *Generator { - t.Helper() - made, err := From(nodes, "10.42.0.0/16", "/var/lib/mesh-host/overlay.key") - if err != nil { - t.Fatal(err) - } - return made -} - -// A hub is dialled by every node at other sites, and needs its port open. A machine that is not a -// hub dials out and needs nothing open at all. -// -// They are the same module, which is why a static field in a manifest cannot say it — and the -// machine it gets wrong is the one facing the public internet, which is the machine that most -// needs filtering. -func TestOnlyAMachineThatCanBeDialledOpensTheOverlaysPort(t *testing.T) { - network := networkOf(t, []Node{ - {Name: "anchor", Key: "a", Endpoint: "198.51.100.10:51820", Site: "one", Hub: true}, - {Name: "laptop", Key: "b", Site: "one"}, - }) - - opens, err := network.Listens("anchor") - if err != nil { - t.Fatal(err) - } - if len(opens) != 1 { - t.Fatalf("the machine every other one dials opens %d ports", len(opens)) - } - if opens[0].Port != 51820 || opens[0].At() != "udp" { - t.Fatalf("the port is not the one the interface listens on: %+v", opens[0]) - } - // From everywhere, and deliberately: a node at another site is not on the private network - // until this port lets it on, so restricting it to the mesh would be a rule that can never be - // satisfied by the thing it exists for. - if opens[0].From != catalogue.FromEverywhere { - t.Fatalf("the way onto the private network is restricted to the private network: %+v", opens[0]) - } - if opens[0].Why == "" { - t.Fatal("a port is opened and nothing says why, which is what makes a rule set unreadable") - } - - // And the machine nothing dials opens nothing. Not harmless to get wrong: a rule with no - // reason is one somebody later has to work out the reason for. - quiet, err := network.Listens("laptop") - if err != nil { - t.Fatal(err) - } - if len(quiet) != 0 { - t.Fatalf("a machine nothing dials opened %d port(s)", len(quiet)) - } -} - -// The port comes from the endpoint, which is where the interface takes its ListenPort from. One -// source, so a rule set cannot open a port the interface is not on. -func TestTheOpenedPortIsTheOneTheInterfaceListensOn(t *testing.T) { - network := networkOf(t, []Node{ - {Name: "anchor", Key: "a", Endpoint: "198.51.100.10:60000", Site: "one", Hub: true}, - }) - opens, err := network.Listens("anchor") - if err != nil { - t.Fatal(err) - } - if len(opens) != 1 || opens[0].Port != 60000 { - t.Fatalf("the rule set would open a port the interface is not on: %+v", opens) - } - - written, err := Declaration(Node{Name: "anchor", Key: "a", Address: "10.42.0.1", - Endpoint: "198.51.100.10:60000", Hub: true}, nil, "/k") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(written), "ListenPort = 60000") { - t.Fatalf("the interface and the rule set disagree about the port:\n%s", written) - } -} - -// An endpoint with no port is refused rather than treated as a machine that opens nothing. -// -// A generator that cannot say what a machine opens is not one that says it opens nothing, and -// closing a port on the evidence of a failure to look is how a machine is severed by a fault -// somewhere else entirely. -func TestAnEndpointWithNoPortIsRefusedRatherThanTakenAsSilence(t *testing.T) { - network := networkOf(t, []Node{ - {Name: "anchor", Key: "a", Endpoint: "198.51.100.10", Site: "one", Hub: true}, - }) - if _, err := network.Listens("anchor"); err == nil { - t.Fatal("a machine whose port could not be read was treated as opening nothing") - } -} - -// A machine the network has never heard of opens nothing, and that is an answer rather than an -// error: a node assigned the module before it is placed is in exactly that state. -func TestAMachineNotOnTheNetworkOpensNothing(t *testing.T) { - network := networkOf(t, []Node{ - {Name: "anchor", Key: "a", Endpoint: "198.51.100.10:51820", Site: "one", Hub: true}, - }) - opens, err := network.Listens("a-stranger") - if err != nil { - t.Fatal(err) - } - if len(opens) != 0 { - t.Fatalf("a machine not on the network opened %d port(s)", len(opens)) - } -} diff --git a/go/overlay/overlay.go b/go/overlay/overlay.go new file mode 100644 index 0000000..c2dc32a --- /dev/null +++ b/go/overlay/overlay.go @@ -0,0 +1,79 @@ +package overlay + +// The private network as a module rather than as code beside the module system. +// +// A machine's peer list is derived from every other machine, so it differs on each one and +// changes when any of them changes — there is nothing that could be written in a manifest. So the +// module says its resources are computed, and this computes them. +// +// What that buys, beyond one mechanism instead of two: **a machine is on the private network +// because it was assigned the module.** Before this, every machine with a key and an address was +// on it, and there was no way to say a machine should stay off. +// +// It is worth saying what is *not* here, because networking looks like it should be foundational. +// The host needs none of this. It has an address and a route to the broker before the mesh exists +// — that is the machine's own networking — and the broker's address is carried in the enrolment +// token rather than resolved. So the private network is something the mesh installs on top, like +// anything else, and a node without it is an ordinary node that nothing reaches directly. + +// What a module asks for when it needs machines to reach each other. **This is the name that +// matters** — WireGuard is one way to answer it, and naming the requirement after the answer is +// how a mesh ends up unable to have a second one. +const Requirement = "private-network" + +// Name is the module that answers it with WireGuard. +// +// **It writes no names.** A machine's mesh names are answered by the mesh's one resolver (novox/hq +// ADR 0194), and /etc/hosts is the file of one module, the holder of `node-hostname` (ADR 0199, +// ADR 0223): the controller writes into no file another seat's holder owns. If the mesh ever needs a line there, it +// asks that holder to register it. This module asked for a `node-names` fact written into /etc/hosts +// until 2026-10-05; the host gives that region back at the first push without it. +const Name = "mesh-wireguard" + +// Addressing is the mesh handing out addresses on the private network itself. +// +// The mesh's resolver answers names from it, which is why it requires this rather than a private +// network in general. A different VPN that hands out its own addresses would come with its own +// names — the mesh has nothing to answer about a machine whose address it did not choose. +const Addressing = "mesh-addressing" + +// TheNetwork is what a machine can only have one of. +// +// Providing a private network is not the singular part — a machine could reasonably run two VPNs +// for two different purposes. Being **the** one the mesh runs over is singular, and without +// saying so a person who chose a different VPN can still end up with this one dragged back in by +// something that needed the mesh's own addresses. Which is exactly what happened, once. +const TheNetwork = "the-private-network" + +// **A resolver's data went the same way as the names.** Two constants used to sit here — a +// `mesh-resolver` module that would write the machines as wildcards, and a `resolver-data` +// requirement a daemon would ask for. Nothing ever provided or consumed either: a module that +// answers names asks for the `node-zones` fact in its own manifest (catalogue.FactsInto) and +// requires Addressing, since the file is made of the mesh's addresses and means nothing off the +// network. A requirement nothing provides is refused at resolution, so leaving the names here +// would only have documented a mechanism that does not exist. + +// Manifest is the module the mesh provides for itself. +// +// It ships with the control plane rather than coming from a repository, because the thing that +// computes it ships with the control plane. That is the only way it is unusual: it is assigned, +// unassigned, resolved and settled exactly like a module somebody wrote. +func Manifest() map[string]any { + return map[string]any{ + "module": Name, + "version": "1", + "computed": Name, + "provides": []string{Requirement, Addressing}, + "claims": []map[string]any{{"name": TheNetwork, "scope": "node"}}, + } +} + +// Interface is what the private network is called on a machine, and where the node's own key +// lives. A constant rather than a setting: two nodes disagreeing about the name would produce a +// mesh where each is configured correctly and nothing meets. +const ( + Interface = "mesh0" + ConfigPath = "/etc/wireguard/" + Interface + ".conf" + Unit = "wg-quick@" + Interface + DefaultKeyPath = "/var/lib/mesh-host/overlay.key" +) diff --git a/go/takein/takein.go b/go/takein/takein.go deleted file mode 100644 index 657ab45..0000000 --- a/go/takein/takein.go +++ /dev/null @@ -1,134 +0,0 @@ -// Package takein is the one rule that turns a builder's done key into the manifest the mesh composes a module from -// (issue 1126): the module's manifest resolved with what its build made. It is mesh-controller's take-in of a done key -// (cmd/mesh-controller/key_takein.go builtOfKey, unpackTexts and Manifest.Resolve) moved into the SDK, so a node agent -// composing its own modules (novox/hq ADR 0547 §2) composes from the same built manifest the controller composes from: -// every artifact a resource names resolved to its reference and digest, a TypeScript bundle's launchers, and, for a -// moved module, each template's text read back from its templates archive and what its build keeps for the -// provisioner (catalogue.Handed). Composing from the manifest the indexer read instead hands no moved module and -// leaves every artifact unbuilt. -// -// It reads nothing itself: the archive is read through the caller's Archive, from wherever the caller reaches the -// artifact store. -package takein - -import ( - "archive/tar" - "bytes" - "compress/gzip" - "context" - "errors" - "fmt" - "io" - "strings" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -// Archive reads the archive a recorded reference names (catalogue.Recorded: the artifact store's address left out), -// checked against its digest. -type Archive func(ctx context.Context, reference, digest string) ([]byte, error) - -// Manifest is m, as its manifest at b's fingerprint reads, resolved with what b made: the built manifest. b must be done -// and every artifact must name its reference. -// A templated module's templates archive is read through archive; a module without templates reads none, and archive -// may then be nil. -func Manifest(ctx context.Context, m catalogue.Manifest, b delivery.BuilderBuild, archive Archive) (catalogue.Manifest, error) { - if b.State != delivery.BuildDone { - return catalogue.Manifest{}, fmt.Errorf("takein: the build of %s is %s, not done: nothing to resolve it with", m.Module, b.State) - } - // **A done key whose artifact names no reference is waited for, never composed from** (novox/hq ADR 0498 §8), in the - // controller's words (mesh-controller internal/builderkeys Key.Says): a key done before R4e, or a candidate's image or - // package not yet built again for the trunk. - var missing []string - for _, a := range b.Artifacts { - if strings.TrimSpace(a.Reference) == "" { - missing = append(missing, a.Name) - } - } - if len(missing) > 0 { - return catalogue.Manifest{}, fmt.Errorf("takein: the build of %s is done and waits for the reference of %s", - m.Module, strings.Join(missing, ", ")) - } - built, err := Built(ctx, m, b.Artifacts, archive) - if err != nil { - return catalogue.Manifest{}, err - } - resolved, err := m.Resolve(built) - if err != nil { - return catalogue.Manifest{}, fmt.Errorf("takein: the build of %s does not resolve: %w", m.Module, err) - } - return resolved, nil -} - -// Built is each of a done key's artifacts as catalogue.Manifest.Resolve reads it: its recorded reference and digest, a -// TypeScript bundle's launchers by the build modules' one rule (catalogue.Launchers), and, for the archive a module packs -// its templates into, each template's text read back from that archive. -func Built(ctx context.Context, m catalogue.Manifest, artifacts []delivery.BuiltArtifact, archive Archive) ([]catalogue.Built, error) { - declared := map[string]catalogue.Artifact{} - if m.Build != nil { - for _, a := range m.Build.Artifacts { - declared[a.Name] = a - } - } - templates := "" - if m.Templated() { - templates = catalogue.TemplatesArtifact(m) - } - var out []catalogue.Built - for _, a := range artifacts { - d := declared[a.Name] - kind := a.Kind - if kind == "" { - kind = d.Kind - } - b := catalogue.Built{Name: a.Name, Kind: kind, Reference: catalogue.Recorded(a.Reference), Digest: a.Digest} - if launchers := catalogue.Launchers(d.Language, d.Entrypoints); len(launchers) > 0 { - b.Launchers = launchers - } - if a.Name == templates && templates != "" { - if archive == nil { - return nil, fmt.Errorf("takein: %s packs its templates into %q, and nothing here reads the artifact store", - m.Module, a.Name) - } - body, err := archive(ctx, b.Reference, b.Digest) - if err != nil { - return nil, fmt.Errorf("takein: the templates archive %q of %s cannot be read from the artifact store: %w", - a.Name, m.Module, err) - } - if b.Texts, err = UnpackTexts(body); err != nil { - return nil, fmt.Errorf("takein: the templates archive %q of %s cannot be read back: %w", a.Name, m.Module, err) - } - } - out = append(out, b) - } - return out, nil -} - -// UnpackTexts is every regular file of a packed templates archive (gzip over tar), by its path inside it. -func UnpackTexts(body []byte) (map[string]string, error) { - zipped, err := gzip.NewReader(bytes.NewReader(body)) - if err != nil { - return nil, err - } - defer zipped.Close() - reader := tar.NewReader(zipped) - texts := map[string]string{} - for { - header, err := reader.Next() - if errors.Is(err, io.EOF) { - return texts, nil - } - if err != nil { - return nil, err - } - if header.Typeflag != tar.TypeReg { - continue - } - text, err := io.ReadAll(io.LimitReader(reader, 16<<20)) - if err != nil { - return nil, err - } - texts[strings.TrimPrefix(header.Name, "./")] = string(text) - } -} diff --git a/go/validate/sdk_test.go b/go/validate/sdk_test.go deleted file mode 100644 index d946fff..0000000 --- a/go/validate/sdk_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package validate - -import ( - "errors" - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/declaration" -) - -// The SDK's validator speaks the copied parser's vocabulary version (issue 1353 AC3). -func TestVersionIsTheParsersVersion(t *testing.T) { - if Version != declaration.Version { - t.Fatalf("validate.Version = %d, declaration.Version = %d", Version, declaration.Version) - } -} - -// A body of another declaration version is refused whole, by both the validator and the parser (AC3). The body is -// the shape mesh-host's validate_test.go uses for its good declaration, with the version changed. -func TestAnotherVersionIsRefusedWhole(t *testing.T) { - body := `{"declaration": 99, "resources": [{"id": "d", "type": "directory", "path": "/var/lib/x", "mode": "0755"}]}` - problems := Declaration([]byte(body)) - if len(problems) == 0 { - t.Fatal("a declaration of another version was taken") - } - _, err := declaration.Parse([]byte(body)) - var refused *declaration.RefusalError - if !errors.As(err, &refused) || strings.Join(refused.Problems, "\n") != strings.Join(problems, "\n") { - t.Fatalf("the parser and the validator disagree: %v / %v", err, problems) - } -} diff --git a/go/validate/validate.go b/go/validate/validate.go deleted file mode 100644 index 1291866..0000000 --- a/go/validate/validate.go +++ /dev/null @@ -1,42 +0,0 @@ -// Package validate is the node-engine's own judgement of a declaration, for whoever must know before -// a declaration is sent whether a machine would take it (novox/hq to-be 45 §4, D1; §9, the merge gate). -// -// **One validator.** The controller composed declarations the host then refused whole — a manifest -// the catalogue check passed (novox/hq issue 236), a consumer's identity too long for the machine's -// names (issue 263) — because the only validator was the one the host runs as it applies. A second, -// written in the controller from the host's rules, would drift from them the first time either -// changed. So the host's own parsing is exported here, unchanged: what the controller's self-check and -// the merge gate run is what every machine runs. -// -// Copied from mesh-host at commit 619e14aa8e572c5056e3d2a2412c7f8b364bf840 (validate/validate.go), unchanged but for -// the import path of the parser, which is mesh-sdk's go/declaration (novox/hq ADR 0453 clause 3, ADR 0446 clause 4). -// mesh-host keeps its own copy until release 4b. -// -// It judges a declaration as it arrives over the link: actions are refused, as the host refuses them -// from the link (novox/hq ADR 0005). Nothing here touches a machine. -package validate - -import ( - "errors" - - "git.novox.be/novox/mesh-sdk/go/declaration" -) - -// Declaration is every problem the node-engine would refuse a declaration for, each in its own words; -// nil when it would take it. The body is the declaration as the controller composes it — the bytes a -// signature is made over — not the signed envelope. -func Declaration(raw []byte) []string { - _, err := declaration.Parse(raw) - if err == nil { - return nil - } - var refused *declaration.RefusalError - if errors.As(err, &refused) { - return refused.Problems - } - return []string{err.Error()} -} - -// Version is the declaration vocabulary this validator speaks: a declaration of another version is -// refused whole by Declaration, as by the host. -const Version = declaration.Version diff --git a/go/validate/validate_test.go b/go/validate/validate_test.go deleted file mode 100644 index 51ade73..0000000 --- a/go/validate/validate_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package validate - -import ( - "strings" - "testing" -) - -// **The validator a controller imports is the host's**: a declaration the host takes passes, and one -// it refuses is refused with the host's own words, every problem at once. -func TestTheValidatorIsTheHosts(t *testing.T) { - good := `{"declaration": 1, "resources": [{"id": "d", "type": "directory", "path": "/var/lib/x", "mode": "0755"}]}` - if problems := Declaration([]byte(good)); problems != nil { - t.Fatalf("a declaration the host takes was refused: %v", problems) - } - // And one carrying the lease epoch and sequence the controller sends it under (novox/hq to-be 45 - // §6): a controller validating with this package may send them. - ordered := `{"declaration": 1, "epoch": 57, "sequence": 3, "resources": [{"id": "d", "type": "directory", "path": "/var/lib/x", "mode": "0755"}]}` - if problems := Declaration([]byte(ordered)); problems != nil { - t.Fatalf("a declaration carrying its order was refused: %v", problems) - } - // And one carrying a file's owner-at-runtime and the files the provisioner fills (novox/hq ADR 0349 §5): the - // reader takes them before any controller sends them. - owned := `{"declaration": 1, "filled_files": ["/etc/x"], "resources": [{"id": "f", "type": "file", "path": "/etc/x", "content": "a", "owner-at-runtime": "owned"}]}` - if problems := Declaration([]byte(owned)); problems != nil { - t.Fatalf("a declaration saying who owns a file at runtime was refused: %v", problems) - } - // And one naming the provisioner client's account (novox/hq ADR 0349 §4). - named := `{"declaration": 1, "provisioner_account": "mesh-node-provisioner", "filled_files": ["/etc/x"], "resources": [{"id": "f", "type": "file", "path": "/etc/x", "content": "a", "owner-at-runtime": "owned"}]}` - if problems := Declaration([]byte(named)); problems != nil { - t.Fatalf("a declaration naming the provisioner client's account was refused: %v", problems) - } - for _, bad := range []string{ - `{"declaration": 1, "provisioner_account": "root", "resources": [{"id": "f", "type": "file", "path": "/etc/x", "content": "a"}]}`, - `{"declaration": 1, "filled_files": ["/etc/none"], "resources": [{"id": "f", "type": "file", "path": "/etc/x", "content": "a", "owner-at-runtime": "owned"}]}`, - `{"declaration": 1, "resources": []}`, - `{"declaration": 99, "resources": [{"id": "d", "type": "directory", "path": "/x"}]}`, - `{"declaration": 1, "resources": [{"id": "d", "type": "nothing-the-host-knows"}]}`, - `not json`, - } { - problems := Declaration([]byte(bad)) - if len(problems) == 0 { - t.Errorf("a declaration the host refuses was passed: %s", bad) - } - for _, p := range problems { - if strings.TrimSpace(p) == "" { - t.Errorf("a problem said nothing, for %s", bad) - } - } - } -} -- 2.54.0 From 018243a86ce593ca87ca80eb99cc8ac3aa9c8fa0 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 18:48:13 +0100 Subject: [PATCH 14/22] =?UTF-8?q?buscompose=20keeps=20only=20the=20bus's?= =?UTF-8?q?=20names=20and=20shapes;=20the=20composer=20of=20the=20user=20l?= =?UTF-8?q?ist=20is=20the=20grants=20part's=20own=20(ADR=200593=20=C2=A71)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/buscompose/accounts.go | 19 - go/buscompose/agreement.go | 125 -- go/buscompose/announced.go | 74 - go/buscompose/buildbacklog.go | 47 - go/buscompose/buscompose_test.go | 880 ----------- go/buscompose/callers.go | 117 -- go/buscompose/cancelled.go | 11 - go/buscompose/composed.go | 71 - go/buscompose/create.go | 115 -- go/buscompose/create_test.go | 46 - go/buscompose/creates_streams_test.go | 155 -- go/buscompose/declaration_files.go | 103 -- go/buscompose/derived.go | 322 ----- go/buscompose/doc.go | 15 +- go/buscompose/engine.go | 70 - go/buscompose/fixtures_test.go | 119 -- go/buscompose/health_grants.go | 113 -- go/buscompose/log.go | 155 -- go/buscompose/membership.go | 178 --- go/buscompose/modstreams.go | 234 --- go/buscompose/names_test.go | 21 + go/buscompose/nats.go | 1287 ----------------- go/buscompose/onnats.go | 5 - go/buscompose/process_grants.go | 56 - go/buscompose/provisioner.go | 229 --- go/buscompose/raise.go | 73 - go/buscompose/readiness.go | 139 -- go/buscompose/sdk_membership.go | 30 - go/buscompose/seattraffic.go | 312 ---- go/buscompose/state.go | 319 ---- go/buscompose/streams.go | 158 -- go/buscompose/testdata/composed.conf | 73 - .../testdata/controller-credentials.json | 21 - go/buscompose/testdata/user-lists.json | 289 ---- go/buscompose/userlist_switch.go | 174 --- .../userlist_switch_integration_test.go | 213 --- go/buscompose/users.go | 463 ------ go/buscompose/waits.go | 76 - go/buscompose/writers.go | 282 ---- 39 files changed, 25 insertions(+), 7164 deletions(-) delete mode 100644 go/buscompose/agreement.go delete mode 100644 go/buscompose/announced.go delete mode 100644 go/buscompose/buildbacklog.go delete mode 100644 go/buscompose/buscompose_test.go delete mode 100644 go/buscompose/callers.go delete mode 100644 go/buscompose/cancelled.go delete mode 100644 go/buscompose/composed.go delete mode 100644 go/buscompose/create.go delete mode 100644 go/buscompose/create_test.go delete mode 100644 go/buscompose/creates_streams_test.go delete mode 100644 go/buscompose/fixtures_test.go delete mode 100644 go/buscompose/membership.go create mode 100644 go/buscompose/names_test.go delete mode 100644 go/buscompose/onnats.go delete mode 100644 go/buscompose/process_grants.go delete mode 100644 go/buscompose/provisioner.go delete mode 100644 go/buscompose/raise.go delete mode 100644 go/buscompose/readiness.go delete mode 100644 go/buscompose/testdata/composed.conf delete mode 100644 go/buscompose/testdata/controller-credentials.json delete mode 100644 go/buscompose/testdata/user-lists.json delete mode 100644 go/buscompose/userlist_switch.go delete mode 100644 go/buscompose/userlist_switch_integration_test.go delete mode 100644 go/buscompose/writers.go diff --git a/go/buscompose/accounts.go b/go/buscompose/accounts.go index 1402804..924afc7 100644 --- a/go/buscompose/accounts.go +++ b/go/buscompose/accounts.go @@ -8,31 +8,12 @@ import ( "time" ) -// **The accounts the grants part wants, and the one shape a held account takes** (novox/hq ADR 0561 §1, §2; issue 774). -// -// The bus composer derives every user from placement, the manifests and the wants. For each account the mesh mints (a -// module's, a module's process's, a machine's provisioner client's), the grants part writes one key of its state -// `accounts`, keyed by the bus user, holding the account's fields that are not secret: what the controller's credential -// carries today beside the password. The vault watches that state, mints the user it holds no value for, and keeps the -// value as the module's own secret on that node, built by HeldCredential, which gives byte for byte the controller's -// shape, so a runtime reads it unchanged. The state is read by the vault alone (NamedReaders), as `bus-accounts` is read -// by the grants part alone. -// -// The node accounts, the controller's own, an enrolment user and a person are never wanted here: the vault mints none -// of them (ADR 0561 §4, §5). - // AccountsState is the grants part's state of wanted accounts, by bus user. const AccountsState = "accounts" // GrantsAccounts is that state as a reader names it (`.`). const GrantsAccounts = GrantsModule + "." + AccountsState -// AccountsBucket is the bucket the state lives in. -var AccountsBucket = BucketName(GrantsModule, AccountsState) - -// AccountsSubject is the subject of one wanted account's key: `$KV.mesh-grants_accounts.`. -func AccountsSubject(user string) string { return "$KV." + AccountsBucket + "." + user } - // AccountIssuedEvent is what the vault says when it minted an account: AccountIssued, never a value or a hash. const AccountIssuedEvent = "account-issued" diff --git a/go/buscompose/agreement.go b/go/buscompose/agreement.go deleted file mode 100644 index d4083ca..0000000 --- a/go/buscompose/agreement.go +++ /dev/null @@ -1,125 +0,0 @@ -package buscompose - -import ( - "fmt" - "sort" - "strings" -) - -// Do the emitters and the consumers of a catalogue agree? -// -// **The check that was missing** (novox/hq 04-ISSUES/127). Every manifest was individually -// well-formed and every derivation individually correct, and no cross-module subscription in the -// mesh matched anything: a consumer's declaration derived into a namespace nobody publishes to. -// Nothing failed, because a subscription that matches nothing is not an error — it is silence. -// -// The comparison has to be over the whole catalogue, because the two halves live in different -// manifests, and it cannot simply demand that every consumed event have a live emitter: a module -// may be installed long before the one whose events it wants. So the rule is narrower and still -// catches this: **where the emitter is present, it must emit what the consumer asked for.** - -// AConsumer is one module's interest in another's events, as this check needs it. -type AConsumer struct { - Module string - Consumes []string -} - -// AnEmitter is one module's events. -type AnEmitter struct { - Module string - Emits []string -} - -// Disagreements are the consumed events whose emitter is in the catalogue and does not emit them. -// -// Returned as sentences rather than as structs: every one of them is read by a person deciding -// whether a manifest or a catalogue is wrong, and a pair of names without the reason is a puzzle. -func Disagreements(emitters []AnEmitter, consumers []AConsumer, seats []DeclaredSeat) []string { - emits := map[string]map[string]bool{} - for _, e := range emitters { - if emits[e.Module] == nil { - emits[e.Module] = map[string]bool{} - } - for _, name := range e.Emits { - emits[e.Module][name] = true - } - } - // A seat's events are published by its holder under the seat's name, so a consumer naming the - // seat is naming something real even though no module declares it as its own. - for _, s := range seats { - if len(s.Emits) == 0 { - continue - } - if emits[s.Name] == nil { - emits[s.Name] = map[string]bool{} - } - for _, name := range s.Emits { - emits[s.Name][name] = true - } - } - - var out []string - for _, c := range consumers { - for _, pattern := range c.Consumes { - emitter, event, named := strings.Cut(pattern, ".") - // Every event from everyone, or every event from one module: both are deliberate and - // neither names a particular event to check. - if !named || emitter == "*" || emitter == catalogueTheRest || event == catalogueTheRest { - continue - } - known, present := emits[emitter] - if !present { - // Not installed here, which is ordinary: a module lives in its own repository and - // may be registered later. Nothing to compare, so nothing to say. - continue - } - if matchesAny(event, known) { - continue - } - out = append(out, fmt.Sprintf( - "%s consumes %q and %s emits %s — so that subscription would match nothing, and "+ - "nothing would report it", - c.Module, pattern, emitter, listOf(known))) - } - } - sort.Strings(out) - return out -} - -// matchesAny says whether one of an emitter's event names satisfies a consumer's pattern. -func matchesAny(pattern string, emitted map[string]bool) bool { - want := strings.Split(pattern, ".") - for name := range emitted { - if matches(want, strings.Split(name, ".")) { - return true - } - } - return false -} - -func matches(pattern, name []string) bool { - for i, part := range pattern { - if part == catalogueTheRest { - return i < len(name) - } - if i >= len(name) { - return false - } - if part != "*" && part != name[i] { - return false - } - } - return len(pattern) == len(name) -} - -func listOf(names map[string]bool) string { - if len(names) == 0 { - return "nothing" - } - out := make([]string, 0, len(names)) - for n := range names { - out = append(out, n) - } - sort.Strings(out) - return strings.Join(out, ", ") -} diff --git a/go/buscompose/announced.go b/go/buscompose/announced.go deleted file mode 100644 index bdd8c49..0000000 --- a/go/buscompose/announced.go +++ /dev/null @@ -1,74 +0,0 @@ -package buscompose - -import ( - "slices" - "sort" - "strings" - - "git.novox.be/novox/mesh-sdk/go/announce" -) - -// Who may read a module's state is what the module announced (novox/hq ADR 0495 clauses 2 and 3, ADR 0513 §3; issue -// 1790). A module says on the bus which states it keeps and which others may read (announce.Subject); the composer is -// given the last word of each (Records.Announced) and grants a reader's `reads: .` only when the owner -// announced that state readable. An owner that has announced nothing yet is read as before, from the reader's `reads` -// (the transition, until every module announces), and is named as not yet announced. - -// ReadsAnnouncedBy splits one reader module's `reads` (`.`) 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, ".") - s, said := announced[owner] - switch { - case reader != "" && !ReaderMay(reader, r): - refused = append(refused, r) - case reader != "" && namedReader(reader, r): - // A state the composer names its readers for is theirs whatever its owner announced. - granted = append(granted, r) - case reader != "" && ok && said && s.Keeps(name): - if s.ReadableBy(name, reader) { - granted = append(granted, r) - } else { - refused = append(refused, r) - } - case !ok || !said || s.Readable(name): - granted = append(granted, r) - default: - refused = append(refused, r) - } - } - return granted, refused -} - -// NotAnnounced are the owners some module reads the state of and that have announced nothing yet, sorted: read from the -// readers' `reads` as before, until they announce. -func NotAnnounced(r Records) []string { - seen := map[string]bool{} - var out []string - for _, ds := range r.Assigned { - for _, d := range ds { - for _, read := range append(append([]string(nil), d.Reads...), d.ReadsRefused...) { - owner, _, ok := strings.Cut(read, ".") - if !ok || seen[owner] { - continue - } - if _, said := r.Announced[owner]; !said { - seen[owner] = true - out = append(out, owner) - } - } - } - } - sort.Strings(out) - return out -} - -// namedReader says the composer names module among the readers of read (NamedReaders). -func namedReader(module, read string) bool { - return slices.Contains(NamedReaders[strings.TrimSpace(read)], module) -} diff --git a/go/buscompose/buildbacklog.go b/go/buscompose/buildbacklog.go deleted file mode 100644 index f62cdae..0000000 --- a/go/buscompose/buildbacklog.go +++ /dev/null @@ -1,47 +0,0 @@ -package buscompose - -import "git.novox.be/novox/mesh-sdk/go/delivery" - -// The view alone reads the build seats' queues (novox/hq ADR 0525 §2, issue 1887 A8a): the shared workers' counts and the -// oldest waiting ask, to show the backlog. It may not publish an ask: only the builder and the checker ask, by their seat use. -// The watchman is granted nothing here: a waiting ask has no pulse, a taken one is read from the build module's steps (issue -// 1908). Nothing is a wildcard over a stream or a worker. -const ( - // ViewModule is the module of the delivery view (mesh-delivery-view). - ViewModule = "mesh-delivery-view" - // BuilderModule is the module of the builder (mesh-delivery-builder). - BuilderModule = "mesh-delivery-builder" -) - -// BuildAskReads are the API subjects the builder requests when it takes over, at its start, a key naming an ask (issue -// 1910): each build seat's stream's information and its messages by sequence, to tell whether the ask is on its stream. -// Reads only: no consumer, no pull, no acknowledgement; the builder puts its asks by its seat use, as before. -func BuildAskReads() []string { - var out []string - for _, lang := range delivery.AskLanguages { - if stream, err := delivery.AskStream(lang); err == nil { - out = append(out, "$JS.API.STREAM.INFO."+stream, "$JS.API.STREAM.MSG.GET."+stream) - } - } - return out -} - -// BuildBacklogReads are the API subjects the view requests: the information of each build seat's stream and of each of its -// shared workers (delivery.AskStream, delivery.AskWorker), and the message of the stream by sequence. Reads only: no -// message is pulled, none acknowledged, none published. -func BuildBacklogReads() []string { - var out []string - for _, lang := range delivery.AskLanguages { - stream, err := delivery.AskStream(lang) - if err != nil { - continue - } - out = append(out, "$JS.API.STREAM.INFO."+stream, "$JS.API.STREAM.MSG.GET."+stream) - for _, kind := range delivery.AskKinds { - if w, err := delivery.AskWorker(lang, kind); err == nil { - out = append(out, "$JS.API.CONSUMER.INFO."+stream+"."+w) - } - } - } - return out -} diff --git a/go/buscompose/buscompose_test.go b/go/buscompose/buscompose_test.go deleted file mode 100644 index d31d1e6..0000000 --- a/go/buscompose/buscompose_test.go +++ /dev/null @@ -1,880 +0,0 @@ -package buscompose - -import ( - "encoding/json" - "maps" - "os" - "path/filepath" - "reflect" - "slices" - "strings" - "testing" - "time" - - "git.novox.be/novox/mesh-sdk/go/announce" - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" - "github.com/nats-io/nats.go/jetstream" -) - -// Issue 1126 B3a (novox/hq ADR 0590): the composed user lists and the server configuration are golden files this -// composer wrote (testdata/user-lists.json, testdata/composed.conf), first from the lists the ended controller composed -// with its user and the lease's grants taken out. A change to what any user is granted shows here as a diff to read; -// MESH_SDK_UPDATE_GOLDEN=1 writes them anew. -var updateGolden = os.Getenv("MESH_SDK_UPDATE_GOLDEN") == "1" - -// golden compares got with the golden file name, or writes it. -func golden(t *testing.T, name string, got []byte) { - t.Helper() - path := filepath.Join("testdata", name) - if updateGolden { - if err := os.WriteFile(path, got, 0o644); err != nil { - t.Fatal(err) - } - return - } - want, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - if string(got) != string(want) { - t.Errorf("%s differs from what is composed now; read the diff and, if it is meant, write it with MESH_SDK_UPDATE_GOLDEN=1", path) - } -} - -const SignersBucketName = "mesh-vault_signers" - -func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) { - // ADR 0567 §3, §9: a node reads every key of the seat's placement and its candidates (it composes with the whole - // proposal laid over), and none of its open proposals; it writes neither. - reads := strings.Join(NodeComposeReads("laptop"), " ") - for bucket, granted := range map[string]bool{delivery.PlacementBucket: true, delivery.PlacementCandidatesBucket: true, - delivery.PlacementProposalsBucket: false} { - if strings.Contains(reads, "$JS.API.DIRECT.GET.KV_"+bucket+".>") != granted || strings.Contains(reads, "$KV."+bucket+".laptop") { - t.Errorf("a node is granted %s: %v, want %v", bucket, !granted, granted) - } - } - // It hears the creation of those two buckets, and of no other. - if got := NodeComposeAdvisories(); len(got) != 2 || got[0] != "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_placement" || - got[1] != "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_candidates" { - t.Errorf("a node hears the creation of %v", got) - } - type list struct { - Name, List string - Missing []string - Err string - Memberships json.RawMessage - } - var lists []list - for _, f := range UserListFixtures() { - l, missing, err := ComposedFor(f) - row := list{Name: f.Name, List: l, Missing: missing} - if err != nil { - row.Err = err.Error() - } - if row.Memberships, err = json.Marshal(MembershipsFor(f)); err != nil { - t.Fatal(err) - } - // B3a/AC1: no list holds a user for the ended controller. - if strings.Contains(l, `user: "controller"`) { - t.Errorf("%s: the list holds the controller's user", f.Name) - } - lists = append(lists, row) - } - raw, err := json.MarshalIndent(lists, "", " ") - if err != nil { - t.Fatal(err) - } - golden(t, "user-lists.json", append(raw, '\n')) - // An asker on a seat pulled by kind hears what that seat's workers gave up on, and no other stream's dead letters - // (novox/hq issue 734: a check ask given up during a bus reload left its try running for ever). - t.Run("an asker reads its seat's dead letters", func(t *testing.T) { - build := Seat{Name: "node-build-go", Scope: "node", Accepts: []string{"delivery", "check", "recheck"}, PulledByKind: true} - perms, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "mesh-delivery-checker", Uses: []Seat{build}}) - if err != nil { - t.Fatal(err) - } - for subject, want := range map[string]bool{ - "$JS.API.CONSUMER.CREATE.DEAD_LETTERS.x1.mesh.events.dead.SEAT_NODE_BUILD_GO.>": true, - "$JS.API.CONSUMER.CREATE.DEAD_LETTERS.x1.mesh.events.dead.SEAT_NODE_BUILD_GO.w.next": true, - "$JS.API.CONSUMER.DELETE.DEAD_LETTERS.x1": true, - "$JS.API.CONSUMER.MSG.NEXT.DEAD_LETTERS.x1": true, - "$JS.API.CONSUMER.CREATE.DEAD_LETTERS.x1.mesh.events.dead.EVENTS.>": false, - "$JS.API.CONSUMER.CREATE.DEAD_LETTERS.x1": false, - "$JS.API.STREAM.PURGE.DEAD_LETTERS": false, - "$JS.FC.DEAD_LETTERS.x1": false, - } { - if got := MayPublish(perms, subject); got != want { - t.Errorf("may publish %s: %v, want %v", subject, got, want) - } - } - plain, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "shop", - Uses: []Seat{{Name: "telegram-sender", Accepts: []string{"send"}}}}) - if err != nil { - t.Fatal(err) - } - if MayPublish(plain, "$JS.API.CONSUMER.DELETE.DEAD_LETTERS.x1") { - t.Error("a user of a seat not pulled by kind reads DEAD_LETTERS") - } - }) - // ADR 0563 §1, §3, §6: each node's engine alone answers its own node's node-engine verbs and says its events, any - // runtime may call them, and it asks the operator under node-engine- alone. - t.Run("a node's engine holds its node seat and asks in its own name", func(t *testing.T) { - router := Seat{Name: AsksSeat, Accepts: []string{"ask", "cancel"}, Emits: []string{"decided"}, - ByCaller: []string{"ask", "cancel", "decided"}, Records: []string{"messenger_asks"}} - users, err := Users(Records{Nodes: []string{"one", "two"}, Assigned: map[string][]Declared{ - "one": {{Module: "messenger", Holds: []Seat{router}, Invokes: []string{"seat:" + EngineSeat + ".root-free"}}}, - "two": {{Module: RuntimeModule}, {Module: "shop", Invokes: []string{"*"}}}}}) - if err != nil { - t.Fatal(err) - } - for _, u := range users { - perms, err := PermissionsFor(u) - if err != nil { - t.Fatal(err) - } - answers := MaySubscribe(perms, "mesh.seat."+EngineSeat+".tool.root-free.one") || - MaySubscribe(perms, "mesh.seat."+EngineSeat+".call.root-free.one.messenger~one") - if engineOne := u.Kind == KindNode && u.Node == "one"; answers != engineOne { - t.Errorf("%s answers one's root-free: %v", u.Username(), answers) - } - switch { - case u.Kind == KindNode && u.Node == "one": - for subject, want := range map[string]bool{ - "mesh.seat.operator-channel.accept.ask.node-engine-one": true, - "mesh.seat.operator-channel.accept.cancel.node-engine-one": true, - "mesh.seat.operator-channel.accept.ask.node-engine-two": false, - "$JS.API.DIRECT.GET.KV_messenger_asks.$KV.messenger_asks.node-engine-one.hand-over-1": true, - "$JS.API.DIRECT.GET.KV_messenger_asks.$KV.messenger_asks.node-engine-two.hand-over-1": false, - "mesh.seat." + EngineSeat + ".event.done.one": true, - "mesh.seat." + EngineSeat + ".event.done.two": false, - "$SRV.INFO": true, - } { - if got := MayPublish(perms, subject); got != want { - t.Errorf("node one may publish %s: %v, want %v", subject, got, want) - } - } - if !MaySubscribe(perms, "mesh.seat.operator-channel.event.decided.node-engine-one") || - MaySubscribe(perms, "mesh.seat.operator-channel.event.decided.node-engine-two") || - !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()) - } - } - } - }) - // Issue 1126 (ADR 0550 §5): a node's engine asks the vault's `own` in its own name while the vault serves it, and no - // other node's. - t.Run("a node asks the vault its own secrets", func(t *testing.T) { - one := Principal{Kind: KindNode, Node: "one", AsksItsOwnSecrets: true} - perms, err := PermissionsFor(one) - if err != nil { - t.Fatal(err) - } - if !MayPublish(perms, VaultOwnCall(one)) || MayPublish(perms, VaultOwnCall(Principal{Kind: KindNode, Node: "two"})) { - t.Errorf("node one asks own as %v, and as two %v", MayPublish(perms, VaultOwnCall(one)), - MayPublish(perms, VaultOwnCall(Principal{Kind: KindNode, Node: "two"}))) - } - if plain, _ := PermissionsFor(Principal{Kind: KindNode, Node: "one"}); MayPublish(plain, VaultOwnCall(one)) { - t.Error("a node asks own while the vault does not serve it") - } - if !slices.Contains(perms.Subscribe, VaultSecretChanged) { - t.Errorf("node one does not hear the vault's secret-changed: %v", perms.Subscribe) - } - // L3 s6 (ADR 0550 §2): while the vault serves `sign`, a node asks it in its own name alone, and reads the vault's - // `signers`, never writing it; the ended controller's lease it reads no more (ADR 0590). - signs, _ := PermissionsFor(Principal{Kind: KindNode, Node: "one", AsksTheVaultToSign: true}) - signersGet := "$JS.API.DIRECT.GET.KV_" + SignersBucketName + ".$KV." + SignersBucketName + ".key" - for subject, want := range map[string]bool{VaultSignCall(one): true, signersGet: true, - "$JS.API.DIRECT.GET.KV_mesh-controller_lease.$KV.mesh-controller_lease.holder": false, - VaultSignCall(Principal{Kind: KindNode, Node: "two"}): false, "$KV." + SignersBucketName + ".key": false} { - if MayPublish(signs, subject) != want { - t.Errorf("a node asking sign may publish %s: %v, want %v", subject, !want, want) - } - } - if MayPublish(perms, VaultSignCall(one)) || MayPublish(perms, signersGet) { - t.Error("a node asks sign or reads signers while the vault does not serve sign") - } - // And what a module on another node serves from its settings, that key alone (mailu's smtp domain, issue 1126). - users, err := Users(Records{Nodes: []string{"one", "two"}, Assigned: map[string][]Declared{ - "two": {{Module: "mailer", Served: []string{"domain", "not.a.token"}}}}}) - if err != nil { - t.Fatal(err) - } - get := func(key string) string { - return "$JS.API.DIRECT.GET.KV_" + ResolvedBucket + ".$KV." + ResolvedBucket + "." + key - } - for _, u := range users { - if u.Kind != KindNode { - continue - } - perms, err := PermissionsFor(u) - if err != nil { - t.Fatal(err) - } - if u.Node == "one" && (!reflect.DeepEqual(u.ServedReads, []string{"two.mailer.domain"}) || !MayPublish(perms, get("two.mailer.domain")) || - MayPublish(perms, get("two.mailer.sitename"))) { - t.Errorf("node one reads %v of two's resolved values", u.ServedReads) - } - if u.Node == "two" && len(u.ServedReads) != 0 { - t.Errorf("node two is granted its own served keys apart: %v", u.ServedReads) - } - } - }) - // 774/S39 (ADR 0583 §1): while a node rotates, both its logins are users with the node's permissions and its one - // inbox, each calling in its own name; every node reads its own node-logins key alone; with no rotation the list - // is the one above (AC2: the controller's golden lists, unchanged). - t.Run("a node rotating has two logins of one node", func(t *testing.T) { - vault := Seat{Name: VaultSeat, Serves: []string{"rotate-node", "node-account-confirm", "sign", "own"}} - since := time.Date(2026, 11, 2, 1, 0, 0, 0, time.UTC) - users, err := Users(Records{Nodes: []string{"ace", "g14"}, - Assigned: map[string][]Declared{"ace": {{Module: "mesh-vault", Holds: []Seat{vault}}}}, - NodeLogins: map[string]delivery.NodeLogin{ - "g14": {Node: "g14", Login: "node.g14", Rotating: "node.g14--b", Stage: delivery.RotationBegun, Since: since}, - // A node that rotated before and rotates no more logs in as its second login alone. - "ace": {Node: "ace", Login: "node.ace--b", Since: since}}}) - if err != nil { - t.Fatal(err) - } - perms := map[string]Permissions{} - for _, u := range users { - if u.Kind == KindNode { - p, err := PermissionsFor(u) - if err != nil { - t.Fatal(err) - } - perms[u.Username()] = p - } - } - if len(perms) != 3 || perms["node.g14"].Publish == nil || perms["node.g14--b"].Publish == nil || perms["node.ace--b"].Publish == nil { - t.Fatalf("the node users are %v", slices.Sorted(maps.Keys(perms))) - } - // Equal, but for the caller each names in its calls (the bus's proof of which login called, ADR 0583 §3). - own := func(p Permissions, login string) Permissions { - swap := func(in []string) []string { - out := make([]string, len(in)) - for i, s := range in { - out[i] = strings.ReplaceAll(s, CallerToken(login), "") - } - return out - } - return Permissions{Publish: swap(p.Publish), PublishDeny: swap(p.PublishDeny), Subscribe: swap(p.Subscribe), - AllowResponses: p.AllowResponses} - } - if a, b := own(perms["node.g14"], "node.g14"), own(perms["node.g14--b"], "node.g14--b"); !reflect.DeepEqual(a, b) { - t.Errorf("the two logins' permissions differ:\n%v\n%v", a, b) - } - for login, p := range perms { - node, _ := delivery.LoginNode(login) - if !slices.Contains(p.Subscribe, "_INBOX.node."+node+".>") || slices.Contains(p.Subscribe, "_INBOX."+login+".>") && login != "node."+node { - t.Errorf("%s's inbox: %v", login, p.Subscribe) - } - } - // AC3: g14 reads node-logins key g14 and not ace, and writes neither; it confirms in its own login's name. - get := func(key string) string { - return "$JS.API.DIRECT.GET.KV_" + delivery.NodeLoginsBucket + ".$KV." + delivery.NodeLoginsBucket + "." + key - } - confirm := func(login string) string { - return "mesh.seat.mesh-vault.call.node-account-confirm." + CallerToken(login) - } - for subject, want := range map[string]bool{get("g14"): true, get("ace"): false, "$KV." + delivery.NodeLoginsBucket + ".g14": false, - confirm("node.g14--b"): true, confirm("node.g14"): false} { - if MayPublish(perms["node.g14--b"], subject) != want { - t.Errorf("node.g14--b may publish %s: %v, want %v", subject, !want, want) - } - } - if plain, _ := PermissionsFor(Principal{Kind: KindNode, Node: "g14"}); MayPublish(plain, get("g14")) { - t.Error("a node reads node-logins while the vault does not rotate") - } - // And nothing but the grants part names node-logins among its reads. - if !ReaderMay(GrantsModule, delivery.NodeLoginsRead) || ReaderMay("shop", delivery.NodeLoginsRead) { - t.Error("node-logins' named readers") - } - }) - // 774/S40/AC1 (ADR 0587 §4, §6, ADR 0590): an enrolling node's user may publish the redeem call in its own name and - // nothing else, and subscribe its own inbox alone. - t.Run("an enrolling node redeems at the seat alone", func(t *testing.T) { - holder := Seat{Name: catalogue.EnrolmentSeat, Serves: []string{"issue", "redeem", "confirm", "revoke", "tokens", "again"}} - { - users, err := Users(Records{Nodes: []string{"ace"}, Enrolling: []string{"lab"}, - Assigned: map[string][]Declared{"ace": {{Module: "mesh-enrolment", Holds: []Seat{holder}}}}}) - if err != nil { - t.Fatal(err) - } - var lab *Principal - for i := range users { - if users[i].Username() == "enrol.lab" { - lab = &users[i] - } - } - if lab == nil { - t.Fatal("no user enrol.lab") - } - perms, err := PermissionsFor(*lab) - if err != nil { - t.Fatal(err) - } - want := []string{"mesh.seat.mesh-enrolment.call.redeem.enrol~lab"} - if !reflect.DeepEqual(perms.Publish, want) || !reflect.DeepEqual(perms.Subscribe, []string{"_INBOX.enrol.lab.>"}) || - perms.AllowResponses { - t.Errorf("enrol.lab publishes %v, subscribes %v, answers %v", perms.Publish, perms.Subscribe, perms.AllowResponses) - } - } - // No holder serving redeem: the user may enrol nowhere, refused by name. - if _, err := PermissionsFor(Principal{Kind: KindEnrolment, Node: "lab"}); err == nil { - t.Error("an enrolment user that may enrol nowhere was composed") - } - }) - // 774/S41/AC1 (ADR 0587 §1-§3, ADR 0561 §5): mesh-enrolment writes its bucket (with per-key expiry) and calls the - // vault's enrolment-token, node-account and sign in its own name; no other module or runtime may call those two, - // however wide its grant; and its bucket is read by the grants part alone besides it. - t.Run("mesh-enrolment alone asks the vault to enrol", func(t *testing.T) { - vault := Seat{Name: VaultSeat, Serves: []string{"sign", "enrolment-token", "node-account"}} - holder := Seat{Name: catalogue.EnrolmentSeat, Serves: []string{"issue", "redeem", "confirm", "revoke", "tokens", "again"}} - calls := []string{"seat:mesh-vault.enrolment-token", "seat:mesh-vault.node-account", "seat:mesh-vault.sign"} - users, err := Users(Records{Nodes: []string{"ace"}, Assigned: map[string][]Declared{"ace": { - {Module: "mesh-vault", Holds: []Seat{vault}}, - // One process with a bus account of its own (ADR 0587 §1): never carried by the tool runner. - {Module: "mesh-enrolment", RunsAs: "mesh-enrolment", Holds: []Seat{holder}, Invokes: calls, - State: []Bucket{{Module: "mesh-enrolment", Name: "enrolments", KeyTTL: true}}}, - {Module: "shop", Invokes: calls[:2]}, - {Module: RuntimeModule}, {Module: "agent-tools", Invokes: []string{"*"}}}}}) - if err != nil { - t.Fatal(err) - } - call := func(verb, user string) string { return "mesh.seat.mesh-vault.call." + verb + "." + CallerToken(user) } - for _, u := range users { - perms, err := PermissionsFor(u) - if err != nil { - t.Fatal(err) - } - name := u.Username() - for _, verb := range []string{"enrolment-token", "node-account"} { - enrols := name == "ace.mesh-enrolment" - if MayPublish(perms, call(verb, name)) != enrols || (!enrols && MayPublish(perms, "mesh.seat.mesh-vault.tool."+verb)) { - t.Errorf("%s may call %s: %v", name, verb, !enrols) - } - } - if name == "ace.mesh-enrolment" && (!MayPublish(perms, call("sign", name)) || - !MayPublish(perms, "$KV."+delivery.EnrolmentsBucket+".node.lab") || !MayPublish(perms, "$KV."+delivery.EnrolmentsBucket+".address.10.0.0.7")) { - t.Errorf("mesh-enrolment does not sign or write its bucket: %v", perms.Publish) - } - if name != "ace.mesh-enrolment" && MayPublish(perms, "$KV."+delivery.EnrolmentsBucket+".node.lab") { - t.Errorf("%s writes the enrolments", name) - } - } - if !ReaderMay(GrantsModule, delivery.EnrolmentsRead) || ReaderMay("shop", delivery.EnrolmentsRead) { - t.Error("the enrolments' named readers") - } - }) - t.Run("the golden server configuration", func(t *testing.T) { - seat := Seat{Name: "telegram-sender", Accepts: []string{"send"}, Emits: []string{"delivered", "failed"}} - got, err := Compose( - Server{ClientPort: 4222, MonitoringPort: 8222, StoreDir: "/data", - TLSCert: "/tls/tls.crt", TLSKey: "/tls/tls.key", TLSCA: "/tls/ca.crt"}, - []Principal{ - {Kind: KindEnrolment, Node: "one", Redeems: true, PasswordHash: "$2a$11$eeeeeeeeeeeeeeeeeeeeee"}, - {Kind: KindNode, Node: "one", PasswordHash: "$2a$11$nnnnnnnnnnnnnnnnnnnnnn"}, - {Kind: KindModule, Node: "one", Module: "telegram", Holds: []Seat{seat}, - Serves: []string{"status"}, PasswordHash: "$2a$11$tttttttttttttttttttttt"}, - {Kind: KindModule, Node: "two", Module: "shop", Uses: []Seat{seat}, - Emits: []string{"order.placed"}, PasswordHash: "$2a$11$ssssssssssssssssssssss"}, - {Kind: KindModule, Node: "two", Module: "audit", - Consumes: []string{"shop.order.placed"}, PasswordHash: "$2a$11$aaaaaaaaaaaaaaaaaaaaaa"}, - {Kind: KindModule, Node: "one", Module: WatchmanModule, PasswordHash: "$2a$11$wwwwwwwwwwwwwwwwwwwwww"}, - {Kind: KindModule, Node: "one", Module: "nats", SnapshotsTheBus: true, - Serves: []string{"nats_streams"}, PasswordHash: "$2a$11$bbbbbbbbbbbbbbbbbbbbbb"}, - }) - if err != nil { - t.Fatal(err) - } - golden(t, "composed.conf", []byte(got)) - }) - // 774/AC2 (novox/hq ADR 0561 §2): a wanted account round-trips through the grants part's state `accounts`, keyed by - // its user, at the subject `$KV.mesh-grants_accounts.`, read by the vault alone; and the vault's account-issued - // names the account, never a value. - t.Run("a wanted account is kept by its user and read by the vault alone", func(t *testing.T) { - for _, w := range []WantedAccount{ - {User: "ace.mesh-build-go", Kind: KindModule, Node: "ace", Module: "mesh-build-go", URL: "nats://novox.mesh:4222", - Fingerprint: "ab", Claims: []ClaimedSeat{{Seat: "node-build-go", Scope: "node", Serves: []string{"build"}}}}, - {User: "novox.mesh-mail.send", Kind: KindProcess, Node: "novox", Module: "mesh-mail", Process: "send", - URL: "nats://novox.mesh:4222"}, - {User: "provisioner.g14", Kind: KindProvisioner, Node: "g14", Module: "mesh-provisioner-client", - URL: "nats://novox.mesh:4222", Fingerprint: "ab"}, - } { - if err := w.Valid(); err != nil { - t.Fatalf("%s: %v", w.User, err) - } - raw, err := json.Marshal(w) - if err != nil { - t.Fatal(err) - } - back, err := ReadWantedAccount(w.User, raw) - if err != nil || !back.Same(w) { - t.Errorf("%s does not round-trip: %+v, %v", w.User, back, err) - } - if got := AccountsSubject(w.User); got != "$KV.mesh-grants_accounts."+w.User { - t.Errorf("%s is kept at %s", w.User, got) - } - if _, err := ReadWantedAccount("someone.else", raw); err == nil { - t.Errorf("%s was read under another user's key", w.User) - } - } - for _, bad := range []WantedAccount{ - {User: "ace.x", Kind: KindModule, Node: "ace", Module: "y", URL: "nats://a:1"}, - {User: "ace.x.p", Kind: KindProcess, Node: "ace", Module: "x", URL: "nats://a:1"}, - {User: "node.ace", Kind: KindNode, Node: "ace", URL: "nats://a:1"}, - {User: "controller", Kind: "controller", URL: "nats://a:1"}, - {User: "ace.x", Kind: KindModule, Node: "ace", Module: "x"}, - } { - if bad.Valid() == nil { - t.Errorf("%+v is taken as a wanted account", bad) - } - } - if ReaderMay("mesh-delivery-assigner", GrantsAccounts) || !ReaderMay(VaultSeat, GrantsAccounts) { - t.Errorf("%s is read by %v alone, want the vault's", GrantsAccounts, NamedReaders[GrantsAccounts]) - } - e := AccountIssued{User: "ace.x", Kind: KindModule, Node: "ace", Module: "x", Generation: 1} - raw, _ := json.Marshal(e) - if strings.Contains(string(raw), "password") || strings.Contains(string(raw), "hash") || - AccountIssuedSubject != "mesh.mod.mesh-vault.event.account-issued" { - t.Errorf("account-issued says %s at %s", raw, AccountIssuedSubject) - } - }) - // 774/AC3 (novox/hq ADR 0561 §2): the account the vault seals is, byte for byte, what the controller hands for each of - // its three kinds: testdata/controller-credentials.json was written by the controller's own code (its "how"). - t.Run("a held account is the controller's shape byte for byte", func(t *testing.T) { - raw, err := os.ReadFile(filepath.Join("testdata", "controller-credentials.json")) - if err != nil { - t.Fatal(err) - } - var captured struct { - Rows []struct{ Name, Held string } - } - if err := json.Unmarshal(raw, &captured); err != nil { - t.Fatal(err) - } - vault := ClaimedSeat{Seat: "mesh-vault", Scope: "mesh", Serves: []string{"get", "accept", "secret-ask", "issued", - "rotate", "move-in", "node-key", "own", "move-asks"}} - controller := ClaimedSeat{Seat: "mesh-controller", Scope: "mesh", Serves: strings.Split("tools,calls,status,nodes,"+ - "node,placement-export,modules,seats,builds,plans,delivery-plan,delivery-order,delivery-check,deliver,"+ - "delivery-stop,delivery-backups-again,delivery-walks,plan,assign,unassign,pin,unpin,push,rotate,give,issued,"+ - "secret-move,secret-ask,issue,token,settings,command,queue,cancel,clear,replay,try,kill,pause,resume,hand-act,"+ - "drill,warranted,hand-acts,durations,conditions,healers,doctor,upgrade,bus,retire,cleanup,judge-directories,"+ - "root-free,dead-letters,data,build,artifacts,held,collect,move-placement,move-config,mirrors,images", ",")} - at := "nats://10.0.0.1:4222" - wanted := map[string]struct { - w WantedAccount - password string - }{ - "module": {WantedAccount{User: "laptop.cap-bot", Kind: KindModule, Node: "laptop", Module: "cap-bot", URL: at, - Fingerprint: "ab:cd", Claims: []ClaimedSeat{vault, {Seat: "node-service-manager", Scope: "node", - Serves: []string{"status"}}, controller, {Seat: "no-such-seat", Scope: "node"}}}, "pw-module"}, - "module-no-claims-no-fingerprint": {WantedAccount{User: "laptop.cap-plain", Kind: KindModule, Node: "laptop", - Module: "cap-plain", URL: at}, "pw-plain"}, - "provisioner": {WantedAccount{User: "provisioner.laptop", Kind: KindProvisioner, Node: "laptop", Module: "nats", - URL: at, Fingerprint: "ab:cd"}, "pw-prov"}, - "process": {WantedAccount{User: "laptop.mesh-mail.send", Kind: KindProcess, Node: "laptop", Module: "mesh-mail", - Process: "send", URL: at, Fingerprint: "ab:cd"}, "pw-proc"}, - } - if len(captured.Rows) != len(wanted) { - t.Fatalf("%d rows captured, %d wanted", len(captured.Rows), len(wanted)) - } - for _, row := range captured.Rows { - c, ok := wanted[row.Name] - if !ok { - t.Fatalf("no wanted account for the captured %s", row.Name) - } - got, err := HeldCredential(c.w, c.password) - if err != nil || string(got) != row.Held { - t.Errorf("%s:\n got %s (%v)\nwant %s", row.Name, got, err, row.Held) - } - } - if _, err := HeldCredential(wanted["module"].w, ""); err == nil { - t.Error("an account was held with no password") - } - }) - // 774 subtask 8 (novox/hq ADR 0561 §6): a module's consumer is created as the grants part creates it, and every read is - // judged for its reader. - t.Run("every read is judged for its reader; a consumer is created as the grants part creates it", func(t *testing.T) { - announced := map[string]announce.States{} - // Issue 774 (sweep item 2): Users judges every read for its reader again, whatever split the caller made: a - // reader-less split refused the vault the grants part's accounts it is named for. - announced["mesh-grants"] = announce.States{Module: "mesh-grants", States: []announce.State{{Name: "accounts", Readers: []string{VaultSeat}}}} - announced["disk-load"] = announce.States{Module: "disk-load", States: []announce.State{{Name: "readings"}}} - judged, err := Users(Records{Nodes: []string{"a"}, Announced: announced, Assigned: map[string][]Declared{"a": { - {Module: VaultSeat, ReadsRefused: []string{GrantsAccounts}}, - {Module: "restic", ReadsRefused: []string{"disk-load.readings"}}, - {Module: "messenger", Reads: []string{GrantsAccounts}}}}}) - if err != nil { - t.Fatal(err) - } - reads := map[string]string{} - for _, u := range judged { - if u.Kind == KindModule { - reads[u.Module] = strings.Join(u.Reads, ",") - } - } - if reads[VaultSeat] != GrantsAccounts || reads["restic"] != "" || reads["messenger"] != "" { - t.Errorf("the reads granted for their readers: %v", reads) - } - // AccountsEntry is the very lines ComposeAccounts writes for a user, so a writer of kept users repeats none. - filled, _ := WithPasswords(judged, map[string]string{judged[0].Username(): "$2a$11$h"}) - list, err := ComposeAccounts(filled[:1]) - perms, perr := PermissionsFor(filled[0]) - if err != nil || perr != nil || !strings.Contains(list, " users = [\n"+AccountsEntry(filled[0].Username(), "$2a$11$h", perms)+" ]\n") { - t.Errorf("the accounts entry is not ComposeAccounts' own: %v %v\n%s", err, perr, list) - } - c, _ := ConsumerFor(Principal{Kind: KindModule, Node: "a", Module: "m", Consumes: []string{"x.happened"}}) - cfg := ConsumerConfig(c) - if cfg.Durable != "a_m" || cfg.AckPolicy != jetstream.AckExplicitPolicy || cfg.AckWait != 30*time.Second || - cfg.MaxDeliver != 5 || cfg.DeliverPolicy != jetstream.DeliverNewPolicy || len(cfg.FilterSubjects) != 2 || - cfg.Description == "" { - t.Errorf("a module's consumer is created as %+v", cfg) - } - // The grants part is granted the create of each account's consumer by its stream and name, and no other. - users, err := Users(Records{Nodes: []string{"a"}, Assigned: map[string][]Declared{"a": { - {Module: GrantsModule}, {Module: "m", Consumes: []string{"x.happened"}}, {Module: "quiet"}}}}) - if err != nil { - t.Fatal(err) - } - for _, u := range users { - if u.Module != GrantsModule || u.Kind != KindModule { - continue - } - perms, err := PermissionsFor(u) - if err != nil { - t.Fatal(err) - } - if !MayPublish(perms, "$JS.API.CONSUMER.CREATE.EVENTS.a_m") || MayPublish(perms, "$JS.API.CONSUMER.CREATE.EVENTS.a_quiet") || - MayPublish(perms, "$JS.API.CONSUMER.DELETE.EVENTS.a_m") || MayPublish(perms, "$JS.API.CONSUMER.CREATE.EVENTS.a_m.mesh.mod.x.event.happened") { - 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 { - t.Errorf("a process's queue consumer is created as %+v", q) - } - }) -} - -// healthChecks are the health holders' credential cases (ADR 0564 clauses 2, 5, 8; Limits 2, 4, 8; issue 1952 S5). -func healthChecks(t *testing.T) { - t.Helper() - announced := map[string]announce.States{ - "mesh-healers": {Module: "mesh-healers", States: []announce.State{{Name: "attempts", Readable: true}, {Name: "would", Readable: true}}}} - for _, c := range []struct { - reader, read string - may bool - }{ - {"mesh-conditions", "mesh-healers.attempts", true}, {"mesh-conditions", HealersWould, false}, {"mesh-delivery-view", HealersWould, false}, - // ADR 0577 clause 1: the node-engine's data items are disk-load's alone to read. - {"disk-load", delivery.EngineDataRead, true}, {"restic", delivery.EngineDataRead, false}, - } { - granted, _ := ReadsAnnouncedBy(c.reader, []string{c.read}, announced) - if (len(granted) == 1) != c.may { - t.Errorf("%s reads %s: %v, want %v", c.reader, c.read, granted, c.may) - } - } - bucket := func(m, n string) Bucket { return Bucket{Module: m, Name: n} } - users, err := Users(Records{Nodes: []string{"a"}, Assigned: map[string][]Declared{"a": { - {Module: "mesh-conditions", State: []Bucket{bucket("mesh-conditions", "conditions"), bucket("mesh-conditions", "takeover")}, - Reads: []string{"mesh-self-check.findings", "mesh-healers.attempts"}}, - {Module: "mesh-self-check", State: []Bucket{bucket("mesh-self-check", "findings")}, Reads: []string{"restic.good"}}, - // Issue 1955 L7: the grants part keeps the hearings it withholds; the self-check reads them. - {Module: GrantsModule, State: []Bucket{bucket(GrantsModule, GrantsWithheldState)}}, - {Module: "mesh-healers", State: []Bucket{bucket("mesh-healers", "findings"), bucket("mesh-healers", "attempts"), bucket("mesh-healers", "would")}}, - {Module: WatchmanModule, State: []Bucket{bucket(WatchmanModule, "findings")}, Reads: []string{delivery.NodeHealthRead}}, - {Module: "mesh-hand-acts", State: []Bucket{bucket("mesh-hand-acts", "warrants")}, Logs: []Log{{Module: "mesh-hand-acts", Name: "acts"}}}, - // ADR 0570 clauses 1, 8 (issue 1954 L6-P5): mesh-provisioner keeps provisions and plans; the vault and a provider - // read them. - {Module: "mesh-provisioner", State: []Bucket{bucket("mesh-provisioner", "provisions"), bucket("mesh-provisioner", "plans"), - bucket("mesh-provisioner", "registry")}}, - {Module: "mesh-vault", Reads: []string{"mesh-provisioner.provisions", "mesh-provisioner.plans"}}, - {Module: "postgres", Reads: []string{"mesh-provisioner.provisions", "mesh-provisioner.plans"}}, - // The node's provisioner client reads its machine's plans and records no needs (ADR 0570 clause 2; its V3). - {Module: "mesh-provisioner-client", ProvisionsNode: true}, - // ADR 0571 clauses 2, 4 (issue 1955): the seat mesh-artifact-sweep says artifacts-gone; the assigner keeps seat-holders. - {Module: "artifact-store-tools", Holds: []Seat{{Name: "mesh-artifact-sweep", Emits: []string{"artifacts-gone"}}}, - Reads: []string{delivery.SeatHoldersRead}}, - {Module: delivery.AssignerModule, State: []Bucket{bucket(delivery.AssignerModule, delivery.SeatHoldersState)}}, - // ADR 0571 clause 3: disk-load keeps the readings; the backup holder reads them. - // ADR 0577 clause 1: and it reads its own node's keys of the node-engine's data items, which restic may not. - {Module: "disk-load", State: []Bucket{bucket("disk-load", "readings")}, Reads: []string{delivery.EngineDataRead}}, - {Module: "restic", Reads: []string{"disk-load.readings", delivery.EngineDataRead}, State: []Bucket{bucket("restic", "good")}}, - // ADR 0571 clauses 1, 9: the indexer reads the builder's log through its own consumer; the catalogue module asks - // the builder's tool. - {Module: delivery.BuilderModule, Logs: []Log{{Module: delivery.BuilderModule, Name: delivery.BuilderLog, MaxAgeDays: 30}}}, - {Module: delivery.IndexerModule, Reads: []string{delivery.BuildLogRead}}, - {Module: "mesh-catalog", Invokes: []string{delivery.BuilderModule + "." + delivery.BuildsSinceTool}}, - {Module: "telegram", Holds: []Seat{{Name: "telegram-sender", Accepts: []string{"send"}}}, - Processes: []ProcessPrincipal{{Process: "w", Queues: []QueueOwner{{Process: "w", Stream: "M_WORK"}, {Process: "w", Stream: "KV_x"}}}}}, - {Module: "nats", SnapshotsTheBus: true, Processes: []ProcessPrincipal{{Process: "nats-tools", - Removes: []string{catalogue.RemovesEngineActs, catalogue.RemovesControllerLeftovers}}, {Process: "other"}}}, - {Module: "shop", Processes: []ProcessPrincipal{{Process: "nats-tools", Removes: []string{catalogue.RemovesEngineActs}}}}, - }}}) - if err != nil { - t.Fatal(err) - } - may := map[string][]string{ - "a.mesh-conditions": {"$KV.mesh-conditions_conditions.machine.g14.silent"}, - "a.mesh-provisioner": {"$KV.mesh-provisioner_provisions.a.keycloak.postgres-database", "$KV.mesh-provisioner_plans.a.keycloak"}, - "a.mesh-vault": {"$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.a.keycloak.postgres-database", - "$JS.API.DIRECT.GET.KV_mesh-provisioner_plans.$KV.mesh-provisioner_plans.a.keycloak"}, - "a.mesh-hand-acts": {"$KV.mesh-hand-acts_warrants.mesh-config.ask-1", "mesh.log.mesh-hand-acts.acts.act-1"}, - "a.disk-load": {"$KV.disk-load_readings.a.pg.data", "$JS.API.DIRECT.GET.KV_mesh-host_data.$KV.mesh-host_data.a.pg", - "$JS.API.CONSUMER.CREATE.KV_mesh-host_data.c1.$KV.mesh-host_data.a.*"}, - "provisioner.a": {"$JS.API.DIRECT.GET.KV_mesh-provisioner_plans.$KV.mesh-provisioner_plans.a.keycloak", - "$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_plans.c1.$KV.mesh-provisioner_plans.a.keycloak", - // Every machine's plans, as the client V3 watches them (issue 774, 2026-10-30). - "$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_plans.c1.$KV.mesh-provisioner_plans.>", - "$JS.API.DIRECT.GET.KV_mesh-provisioner_plans.$KV.mesh-provisioner_plans.b.keycloak", - // The needs, for the client before V3, until the lease write (review round 3). - "$KV." + ProvisionerNeedsBucket + ".a.keycloak.db.x"}, - // ADR 0571 clause 3: restic's bundle writes each item's last good night as its own state; the self-check reads it. - "a.mesh-self-check": {"$JS.API.DIRECT.GET.KV_restic_good.$KV.restic_good.a.pg.data"}, - "a.mesh-grants": {"$KV.mesh-grants_withheld.reader.mail.a"}, - "a.restic": {"$JS.API.DIRECT.GET.KV_disk-load_readings.$KV.disk-load_readings.a.pg.data", "$KV.restic_good.a.pg.data", - // ADR 0571 clause 7: its own membership from the grants part's state. - "$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.a.restic"}, - "a.mesh-delivery-indexer": {"$JS.API.CONSUMER.CREATE.LOG_mesh-delivery-builder_build-log.r1.mesh.log.mesh-delivery-builder.build-log.letta", - "$JS.API.CONSUMER.MSG.NEXT.LOG_mesh-delivery-builder_build-log.r1", "$JS.API.CONSUMER.DELETE.LOG_mesh-delivery-builder_build-log.r1", - "$JS.API.DIRECT.GET.LOG_mesh-delivery-builder_build-log.mesh.log.mesh-delivery-builder.build-log.letta"}, - "a.mesh-delivery-builder": {"mesh.log.mesh-delivery-builder.build-log.letta"}, - "a.mesh-catalog": {"mesh.mod.mesh-delivery-builder.tool.builder_builds_since"}, - WatchmanModule: {"$JS.API.CONSUMER.CREATE.DEAD_LETTER_NOTICES." + WatchmanModule, "$JS.API.CONSUMER.MSG.NEXT.DEAD_LETTER_NOTICES." + WatchmanModule, - "$JS.API.CONSUMER.INFO.DEAD_LETTER_NOTICES.controller", "$JS.API.STREAM.MSG.GET.DEAD_LETTERS"}, - WatchmanModule + " sources": {"$JS.API.STREAM.MSG.GET.EVENTS", "$JS.API.STREAM.INFO.SEAT_TELEGRAM_SENDER", - "$JS.API.STREAM.MSG.GET.M_WORK", "$JS.API.STREAM.INFO.EVENTS", "mesh.events.dead.EVENTS.a_m"}, - "a.nats.nats-tools": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$JS.API.CONSUMER.INFO.DEAD_LETTER_NOTICES.controller", - "$KV.mesh-host_acts.g14.a1", "$JS.API.STREAM.INFO.KV_mesh-host_acts", - // Research 072 row 33: the ASSIGNMENTS stream is read and removed at the lease write. - "$JS.API.STREAM.INFO.ASSIGNMENTS", "$JS.API.STREAM.DELETE.ASSIGNMENTS", - // Issue 1126 B5 (ADR 0590 clause 6): every leftover of the ended controller, each by its name, read and deleted. - "$JS.API.STREAM.INFO.KV_mesh-controller_lease", "$JS.API.STREAM.DELETE.KV_mesh-controller_lease", - "$JS.API.STREAM.DELETE.KV_mesh-controller_rollouts", "$JS.API.STREAM.DELETE.KV_mesh-controller_states-announced", - "$JS.API.STREAM.DELETE.CONTROL", "$JS.API.STREAM.DELETE.OBJ_declaration-files-a", - "$JS.API.CONSUMER.DELETE.EVENTS.controller", "$JS.API.CONSUMER.DELETE.EVENTS.controller-merges", - "$JS.API.CONSUMER.DELETE.EVENTS.novox_mesh-delivery", "$JS.API.CONSUMER.DELETE.CONTROL.controller", - // And the consumers no part composes that the controller made before it ended: mesh-catalog's of the - // controller's built-before, and each node's engine's of restic's backed-up (issue 1126). - "$JS.API.CONSUMER.INFO.EVENTS.novox_mesh-catalog", "$JS.API.CONSUMER.DELETE.EVENTS.novox_mesh-catalog", - "$JS.API.CONSUMER.INFO.EVENTS.a_mesh-host", "$JS.API.CONSUMER.DELETE.EVENTS.a_mesh-host", - "$JS.API.CONSUMER.INFO.CONTROL.controller"}, - } - mayNot := map[string][]string{ - // The keeper writes no findings and no attempts; no observer writes the keeper's buckets (clause 2). - "a.mesh-conditions": {"$KV.mesh-self-check_findings.x", "$KV.mesh-healers_attempts.x", "$KV.mesh-controller_conditions.x"}, - "a.mesh-self-check": {"$KV.mesh-conditions_conditions.x", "$KV.mesh-conditions_takeover.switch", "$KV.restic_good.a.pg.data", - "$KV.mesh-grants_withheld.reader.mail.a", "$SRV.INFO", "$SRV.PING.node-tools", - "$JS.API.DIRECT.GET.KV_mesh-grants_withheld.$KV.mesh-grants_withheld.reader.mail.a"}, - // Only mesh-provisioner writes provisions and plans, and it writes none of the grants part's states (ADR 0570 - // clauses 1, 8). - "a.mesh-provisioner": {"$KV.mesh-grants_accounts.a.m", "$KV.mesh-grants_user-list.sealed"}, - "a.mesh-vault": {"$KV.mesh-provisioner_provisions.a.keycloak.postgres-database", "$KV.mesh-provisioner_plans.a.keycloak", - "mesh.seat.mesh-artifact-sweep.event.artifacts-gone"}, - // Each writes its own state alone, and these users are composed. - "a.disk-load": {"$KV.restic_good.a.pg.data", "$KV.mesh-host_data.a.pg", - "$JS.API.DIRECT.GET.KV_mesh-host_data.$KV.mesh-host_data.b.pg", "$JS.API.CONSUMER.CREATE.KV_mesh-host_data.c1.$KV.mesh-host_data.b.*"}, - "a.mesh-delivery-builder": {"$KV.disk-load_readings.a.pg.data"}, - // It reads every machine's plans (a provider's consumers are bound there, ADR 0373) but writes none, nor another - // machine's needs, which it keeps for the client before V3 until the lease write. - "provisioner.a": {"$KV.mesh-provisioner_plans.a.keycloak", "$KV." + ProvisionerNeedsBucket + ".b.keycloak.db.x"}, - // Only the assigner writes seat-holders. - "a.artifact-store-tools": {"$KV.mesh-delivery-assigner_seat-holders.mesh-broker"}, - // Only disk-load writes its readings. - "a.restic": {"$KV.disk-load_readings.a.pg.data", "$JS.API.DIRECT.GET.KV_mesh-host_data.$KV.mesh-host_data.a.pg"}, - "a.postgres": {"$KV.mesh-provisioner_provisions.a.keycloak.postgres-database", "$KV.mesh-provisioner_plans.a.keycloak"}, - // Nor mesh-hand-acts' log and warrants, which it alone writes (ADR 0565 clause 10). - "a.mesh-healers": {"$KV.mesh-conditions_conditions.x", "$KV.mesh-conditions_takeover.switch", - "$KV.mesh-hand-acts_warrants.x", "mesh.log.mesh-hand-acts.acts.x"}, - // The watchman never deletes or changes the controller's consumer (clause 5). - // Its reads and deletes of a message are on the streams a dead letter comes from, named, never a bucket's - // (the lead, 2026-10-29). - WatchmanModule + " sources": {"$JS.API.STREAM.MSG.GET.KV_mesh-vault_bus-accounts", "$JS.API.STREAM.MSG.GET.KV_x", - "$JS.API.STREAM.MSG.DELETE.KV_mesh-host_acts", "$JS.API.STREAM.MSG.GET.OBJ_mesh-grants-user-list", "$JS.API.STREAM.MSG.GET.OTHER", - // In L4 it neither delivers again nor drops (clause 5, the lead 2026-10-29). - "$JS.API.STREAM.MSG.DELETE.EVENTS", "$JS.API.STREAM.MSG.DELETE.DEAD_LETTERS", "$JS.API.CONSUMER.CREATE.DEAD_LETTERS.x", - "mesh.again.a_m.x"}, - WatchmanModule: {"$KV.mesh-conditions_conditions.x", "$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", - "$JS.API.CONSUMER.CREATE.DEAD_LETTER_NOTICES.controller", "$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES." + WatchmanModule}, - // The bus module's tools remove the one consumer and the acts' keys, nothing else (Limits 4); its other accounts - // remove nothing; an account is granted the removals its bus declares and no other (the catalogue refuses them - // outside the bus module). - "a.nats.nats-tools": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.other", "$JS.API.CONSUMER.DELETE.EVENTS.a_m", - "$KV.mesh-conditions_conditions.x", "$JS.API.STREAM.DELETE.KV_mesh-host_acts", "$JS.API.STREAM.PURGE.KV_mesh-host_acts", - "$JS.API.STREAM.DELETE.EVENTS", "$JS.API.STREAM.PURGE.ASSIGNMENTS", "$JS.API.STREAM.DELETE.ASSIGNMENTS_X", - // It updates nothing, purges nothing, deletes no stream but the leftovers' and writes no key of them (ADR 0590). - "$JS.API.STREAM.UPDATE.KV_mesh-host_acts", "$JS.API.STREAM.UPDATE.KV_mesh-controller_calls", "$JS.API.STREAM.UPDATE.EVENTS", - "$JS.API.STREAM.UPDATE.KV_mesh-controller_lease", "$JS.API.STREAM.PURGE.KV_mesh-controller_lease", - "$JS.API.STREAM.DELETE.KV_mesh-controller_other", "$JS.API.STREAM.DELETE.OBJ_declaration-files-zz", - "$JS.API.CONSUMER.DELETE.EVENTS.a_mesh-delivery", "$KV.mesh-controller_lease.holder"}, - "a.nats.other": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$KV.mesh-host_acts.g14.a1", - "$JS.API.STREAM.UPDATE.KV_mesh-controller_lease"}, - "a.nats": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$KV.mesh-host_acts.g14.a1"}, - "a.shop.nats-tools": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$KV.mesh-conditions_conditions.x", "$JS.API.STREAM.DELETE.ASSIGNMENTS"}, - } - // ADR 0571 clause 1: a read naming another module's log is issued to the reader as a log it reads, never writes. - if m := MembershipFor("a", Declared{Module: delivery.IndexerModule, Reads: []string{delivery.BuildLogRead, "x.y"}}, - Placements{Logs: map[string]bool{delivery.BuildLogRead: true}}); len(m.Logs) != 1 || m.Logs[0].Writes || - m.Logs[0].Stream != "LOG_mesh-delivery-builder_build-log" || m.Logs[0].Name != delivery.BuildLogRead { - t.Errorf("the indexer's membership issues the logs %+v", m.Logs) - } - // ADR 0565 clauses 6, 7 (issue 1953 L5-S4): a node's engine writes its own packages key and no other node's; only the - // assigner writes its policies. - for _, u := range users { - perms, err := PermissionsFor(u) - if err != nil { - continue - } - // ADR 0570 clause 6: a node's engine reads its own node's provision keys, and no other node's, and writes none. - if u.Kind == KindNode && (!MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.a.keycloak.db") || - MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.b.keycloak.db") || - MayPublish(perms, "$KV.mesh-provisioner_provisions.a.keycloak.db")) { - t.Errorf("node %s reads the provisions %v", u.Node, perms.Publish) - } - // Issue 774: a node's engine writes its own health report's key and no other node's. - if u.Kind == KindNode && (!MayPublish(perms, "$KV.mesh-host_health.a") || MayPublish(perms, "$KV.mesh-host_health.b")) { - t.Errorf("node %s writes the health reports %v", u.Node, perms.Publish) - } - if u.Kind == KindModule && u.Module == WatchmanModule && (!MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.b") || - MayPublish(perms, "$KV.mesh-host_health.b")) { - t.Errorf("the watchman reads the health reports %v, %v", MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.b"), perms.Publish) - } - // ADR 0565 clause 6, ADR 0590: and the ended controller's mirror it reads no more. - if u.Kind == KindNode && (!MayPublish(perms, "$KV.mesh-host_packages.a") || MayPublish(perms, "$KV.mesh-host_packages.b") || - MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-controller_declared-packages.$KV.mesh-controller_declared-packages.a")) { - t.Errorf("node %s writes the packages keys %v", u.Node, perms.Publish) - } - if u.Kind == KindModule && MayPublish(perms, "$KV.mesh-delivery-assigner_policies.x") { - t.Errorf("%s writes the assigner's policies", u.Username()) - } - // ADR 0571 clause 10 (issue 774, 774/S5/AC2): nobody hears memberships on mesh.assignment; they are read from - // the grants part's state. - if MaySubscribe(perms, "mesh.assignment.a.restic") { - t.Errorf("%s hears mesh.assignment", u.Username()) - } - } - // Nor the runtime carrying the node's modules, which reads each one's membership from the state. - if perms, err := PermissionsFor(Principal{Kind: KindNodeTools, Node: "a", Module: RuntimeModule, - Carries: []Declared{{Module: "restic"}}}); err != nil || MaySubscribe(perms, "mesh.assignment.a.restic") || - !MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.a.restic") { - t.Errorf("the runtime hears mesh.assignment or reads no membership: %v", err) - } - // 1953 S-age (ADR 0565 §10): a log that says an age keeps its entries that long and no longer; one that says none, as - // long as the log (ADR 0297). - if aged, kept := (Log{Module: "mesh-hand-acts", Name: "acts", MaxAgeDays: 400}).Config(), (Log{Module: "m", Name: "l"}).Config(); aged.MaxAge != 400*24*time.Hour || kept.MaxAge != 0 || !aged.DenyDelete { - t.Errorf("an aged log's stream keeps %s, an unaged one %s", aged.MaxAge, kept.MaxAge) - } - // The tools account's secret is the module's own broker-, 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) - } - } - } -} diff --git a/go/buscompose/callers.go b/go/buscompose/callers.go deleted file mode 100644 index 5af25dd..0000000 --- a/go/buscompose/callers.go +++ /dev/null @@ -1,117 +0,0 @@ -package buscompose - -import ( - "regexp" - "strings" -) - -// A tool call names its caller (novox/hq issue 365, by ADR 0259 §3's precedent for asks). -// -// mesh.mod..call.[.]. -// mesh.seat..call.[.]. -// -// The last token is the bus user that published the call, and each user is granted these subjects with its own -// name there and no other — the caller is a fact the server enforces, and the tool runtime hands it to the -// module from the subject a call arrived on (mesh-tools node-tools internal/bus caller.go holds the same shape). -// -// **A kind of its own, `call`, not the `tool` subject with a token appended.** Every grant to call a tool is a -// wildcard over the `tool` kind — `.tool..*` for the instance on any machine, `mesh.mod.*.tool.>` for every -// tool — and either would match a `tool` subject with any caller appended, so a caller could name another. -// Under `call` nothing is granted but the caller-named subjects. No stream's filter covers it: a tool call is -// never persisted (design 25 §3). -// -// **Derived from the `tool` grants, in one place** (callerNamed): wherever a principal may call or answer a -// tool, it may call it in its own name, or answer it naming any caller — so no kind of principal is left -// unable to call in its own name, and a new grant to call is one in both shapes by construction. -// -// The `tool` grants stay beside these for one release, so every caller and every runtime moves without a gap: -// their retirement is hq issue 464. -const CallKind = "call" - -var userName = regexp.MustCompile(`^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$`) - -// CallerToken is a bus user's name as the last token of a call it publishes: each dot written `~`, which no part -// of a user's name may hold (safeSubject), so the token names that user and no other. "" for a name that is not -// a user's. -func CallerToken(user string) string { - if !userName.MatchString(user) { - return "" - } - return strings.ReplaceAll(user, ".", "~") -} - -// toolGrant splits a grant on the `tool` kind — `mesh.mod..tool.` or `mesh.seat..tool.`, any -// part possibly a wildcard — at the kind; ok is false for any other subject. -func toolGrant(subject string) (head, rest string, ok bool) { - parts := strings.SplitN(subject, ".", 5) - if len(parts) != 5 || parts[0] != "mesh" || (parts[1] != "mod" && parts[1] != "seat") || parts[3] != "tool" || - parts[2] == "" || parts[4] == "" { - return "", "", false - } - return parts[0] + "." + parts[1] + "." + parts[2], parts[4], true -} - -// CalledSubject is where a call to a tool subject goes naming its caller; "" when the subject is not a tool's -// or the user is not a bus user. -func CalledSubject(subject, user string) string { - head, rest, ok := toolGrant(subject) - token := CallerToken(user) - if !ok || token == "" || strings.ContainsAny(rest, "*>") { - return "" - } - return head + "." + CallKind + "." + rest + "." + token -} - -// CalledPattern is what a holder answering a tool subject also subscribes, to hear the calls that name their -// caller: the same address under the `call` kind, any caller last. "" for a subject that is not a tool's. -func CalledPattern(subject string) string { - head, rest, ok := toolGrant(subject) - if !ok || strings.ContainsAny(rest, "*>") { - return "" - } - return head + "." + CallKind + "." + rest + ".*" -} - -// calledPublish is a grant to call on the `tool` kind, as the same grant in the caller's own name: its last -// token the caller's, and nothing that reaches past it. A `>` is every tail a call carries — the tool alone or -// the tool and the machine — so it becomes both, each ending in the caller. -func calledPublish(grant, token string) []string { - head, rest, ok := toolGrant(grant) - if !ok || token == "" { - return nil - } - base := head + "." + CallKind + "." - switch { - case rest == ">": - return []string{base + "*." + token, base + "*.*." + token} - case strings.HasSuffix(rest, ".>"): - return []string{base + strings.TrimSuffix(rest, ">") + "*." + token} - } - return []string{base + rest + "." + token} -} - -// calledSubscribe is a grant to answer on the `tool` kind, as the same grant for the calls naming any caller. -func calledSubscribe(grant string) []string { - head, rest, ok := toolGrant(grant) - if !ok { - return nil - } - base := head + "." + CallKind + "." - if strings.HasSuffix(rest, ">") { - return []string{base + rest} - } - return []string{base + rest + ".*"} -} - -// callerNamed adds, beside a principal's grants on the `tool` kind, the same grants under `call`: to publish in -// its own name, to subscribe naming anybody. -func callerNamed(user string, pub, sub []string) ([]string, []string) { - token := CallerToken(user) - for _, g := range pub { - pub = append(pub, calledPublish(g, token)...) - } - for _, g := range sub { - sub = append(sub, calledSubscribe(g)...) - } - return unique(pub), unique(sub) -} diff --git a/go/buscompose/cancelled.go b/go/buscompose/cancelled.go deleted file mode 100644 index f7fa097..0000000 --- a/go/buscompose/cancelled.go +++ /dev/null @@ -1,11 +0,0 @@ -package buscompose - -import "strings" - -// A seat's cancelled set (novox/hq ADR 0219) is a bucket the mesh no longer raises or grants (R4g, ADR 0525 clause 6): the -// controller asks no work queue, so nothing is cancelled. A bus that still holds one is not reported as state nothing declares. - -// IsCancelledSet says a bucket is a seat's cancelled set rather than a module's state. -func IsCancelledSet(bucket string) bool { - return strings.HasPrefix(bucket, "SEAT_") && strings.HasSuffix(bucket, "_cancelled") -} diff --git a/go/buscompose/composed.go b/go/buscompose/composed.go deleted file mode 100644 index 9dd1285..0000000 --- a/go/buscompose/composed.go +++ /dev/null @@ -1,71 +0,0 @@ -package buscompose - -import ( - "fmt" - - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -// The compositions' buckets (novox/hq ADR 0539 clauses 2 and 4): one per node, `composed-`, holding the -// compositions the ended controller made for that node, which only that node's node-engine reads. Nothing writes them -// since the controller ended (ADR 0590); a node composes its own modules (ADR 0547 §2). - -// ComposedReads is what a node's node-engine publishes to follow its wants and compositions (ADR 0539 clauses 1, 2 and -// 9): every key of its own composition bucket, and its own wants (`want..` and `want..node`) in the -// assigner's bucket; nothing of another node's, nor the bucket of digests. -func ComposedReads(node string) ([]string, error) { - bucket, err := delivery.ComposedBucketFor(node) - if err != nil { - return nil, err - } - if !safeSubject.MatchString(node) { - return nil, fmt.Errorf("%q cannot be part of a subject: a permission is a subject pattern, and this would widen it", node) - } - return append(readGrants(bucket, ""), readGrants(delivery.AssignerWantsBucket, "want."+node+".>")...), nil -} - -// VerbsTheNodeAgentAsks are the seat verbs a node's node-engine calls of its own machine while it applies a want (ADR -// 0539 clause 16, ADR 0429 §14): its backup holder backs a data module up before it moves, and says when it was. Asked -// of its own machine only, `mesh.seat..tool..`. -var VerbsTheNodeAgentAsks = []SeatVerb{{Seat: "node-backup", Verb: "now"}, {Seat: "node-backup", Verb: "backed-up"}} - -// NodeAgentAsks are the subjects of VerbsTheNodeAgentAsks for one node. -func NodeAgentAsks(node string) ([]string, error) { - if !safeSubject.MatchString(node) { - return nil, fmt.Errorf("%q cannot be part of a subject: a permission is a subject pattern, and this would widen it", node) - } - var out []string - for _, v := range VerbsTheNodeAgentAsks { - out = append(out, "mesh.seat."+v.Seat+".tool."+v.Verb+"."+node) - } - return out, nil -} - -// NodeAgentHears are the events a node's node-engine subscribes while it applies a want: the backup holder's word that a -// backup is taken (`backed-up`), from whichever module holds node-backup on its machine (restic today). -var NodeAgentHears = []string{"mesh.mod." + BackupHolderModule + ".event.backed-up"} - -// BackupHolderModule is the module that holds node-backup on every node, restic: the one whose word a node-engine hears. -// A second holder of the seat is a change of this grant. -const BackupHolderModule = "restic" - -// DeclarationsStream is the stream of each node's last declaration, `mesh.node..declare` (issue 107). Release 6 -// sends none (ADR 0539 clauses 5 and 11): the stream is kept, with each node's consumer, only so a node away during the -// switch finds its last declaration and with it the new node-engine; the controller deletes it once every node's have -// names the new node-engine, and from then on neither asserts it nor any node's consumer on it; nor makes it again once -// it is gone from the bus for any reason, since a stream made anew would hold no node's last declaration (issue 1945). -const DeclarationsStream = "NODES" - -// RaiseAfterDeclarations is Raise once the declarations' stream is retired: the mesh's streams and consumers but the -// declarations' stream, and no node's consumer of a declaration. -func RaiseAfterDeclarations(r Raiser) error { - for _, s := range MeshStreams() { - if s.Name == DeclarationsStream { - continue - } - if err := r.EnsureStream(s); err != nil { - return fmt.Errorf("asserting stream %s: %w", s.Name, err) - } - } - return AssertMeshConsumers(r) -} diff --git a/go/buscompose/create.go b/go/buscompose/create.go deleted file mode 100644 index 0147716..0000000 --- a/go/buscompose/create.go +++ /dev/null @@ -1,115 +0,0 @@ -package buscompose - -import ( - "time" - - "github.com/nats-io/nats.go/jetstream" -) - -// The streams a manifest declares, as the server is asked to create them (novox/hq ADR 0547 §5, issue 1790): the -// grants part creates each one the bus does not hold yet, with what the controller asserts at its start, so the two -// make the same stream. Pure. Only a create is ever asked with these: a stream the bus holds is never updated by the -// grants part (CreateStreamGrants grants no update). - -// BucketStreamConfig is a module's bucket as the stream the controller's CreateOrUpdateKeyValue makes of its options -// (mesh-controller internal/broker BucketConfig, with no marker age held): the client library's key-value shape -// (rollup, no delete, direct reads, discard new, a duplicate window of two minutes or the age when shorter) with the -// mesh's caps, file storage and one replica. -func BucketStreamConfig(b Bucket) jetstream.StreamConfig { - history := b.History - if history < 1 { - history = 1 - } - age := time.Duration(b.TTLSeconds) * time.Second - dupes := 2 * time.Minute - if age > 0 && age < dupes { - dupes = age - } - cfg := jetstream.StreamConfig{ - Name: "KV_" + b.Bucket(), - Description: b.Why(), - Subjects: []string{"$KV." + b.Bucket() + ".>"}, - MaxMsgsPerSubject: int64(history), - MaxBytes: StateMaxBytes, - MaxAge: age, - MaxMsgSize: StateMaxValueBytes, - Storage: jetstream.FileStorage, - Replicas: 1, - AllowRollup: true, - DenyDelete: true, - Duplicates: dupes, - MaxMsgs: -1, - MaxConsumers: -1, - AllowDirect: true, - Discard: jetstream.DiscardNew, - } - if b.KeyTTL { - cfg.AllowMsgTTL, cfg.SubjectDeleteMarkerTTL = true, KeyTTLMarkerTTL - } - return cfg -} - -// ModuleStreamConfig is a module's own stream as the controller asserts it (mesh-controller internal/broker -// StreamConfigFor of ModuleStream.Stream). -func ModuleStreamConfig(m ModuleStream) jetstream.StreamConfig { - s := m.Stream() - cfg := jetstream.StreamConfig{ - Name: s.Name, - Subjects: s.Subjects, - Retention: jetstream.LimitsPolicy, - MaxAge: time.Duration(s.MaxAge) * time.Second, - MaxMsgsPerSubject: int64(s.MaxMsgsPerSubject), - Description: s.Why, - AllowDirect: s.Direct, - AllowMsgTTL: s.AllowMsgTTL, - } - if s.Retention == RetentionWorkQueue { - cfg.Retention = jetstream.WorkQueuePolicy - } - if s.Restricted || s.WorkQueue { - cfg.Metadata = map[string]string{} - if s.Restricted { - cfg.Metadata[RestrictedMetadata] = "true" - } - if s.WorkQueue { - cfg.Metadata[WorkQueueMetadata] = "true" - } - } - if s.MaxBytes > 0 { - cfg.MaxBytes = s.MaxBytes - } - if s.DiscardNew { - cfg.Discard = jetstream.DiscardNew - } - if s.DuplicatesSeconds > 0 { - cfg.Duplicates = time.Duration(s.DuplicatesSeconds) * time.Second - } - return cfg -} - -// ConsumerConfig is a pull consumer as the controller's EnsureConsumer creates it (mesh-controller internal/broker -// JetStream.EnsureConsumer, a consumer not there yet): its durable name, explicit acks, its ack wait, max deliver and max -// ack pending, its description, one filter or several, and from the stream's end when it is made FromNow. The grants part -// creates a module's and a process's consumers with it from the lease write (novox/hq ADR 0561 §6): a create alone, -// never an update of one the bus holds. A push consumer or a queue group is the controller's own and never made here. -func ConsumerConfig(c Consumer) jetstream.ConsumerConfig { - cfg := jetstream.ConsumerConfig{ - Durable: c.Name, - AckPolicy: jetstream.AckExplicitPolicy, - AckWait: time.Duration(c.AckWaitSeconds) * time.Second, - MaxDeliver: c.MaxDeliver, - MaxAckPending: c.MaxAckPending, - Description: c.Why, - } - switch len(c.Filters) { - case 0: - case 1: - cfg.FilterSubject = c.Filters[0] - default: - cfg.FilterSubjects = c.Filters - } - if c.FromNow { - cfg.DeliverPolicy = jetstream.DeliverNewPolicy - } - return cfg -} diff --git a/go/buscompose/create_test.go b/go/buscompose/create_test.go deleted file mode 100644 index 68cd04b..0000000 --- a/go/buscompose/create_test.go +++ /dev/null @@ -1,46 +0,0 @@ -package buscompose - -import ( - "testing" - "time" - - "github.com/nats-io/nats.go/jetstream" -) - -// Defends issue 1790 (ADR 0547 §5): the grants part creates a declared bucket and stream as the controller asserts them. -func TestDeclaredStreamsAreCreatedAsTheControllerAssertsThem(t *testing.T) { - plain := BucketStreamConfig(Bucket{Module: "mesh-vault", Name: "bus-accounts"}) - keyed := BucketStreamConfig(Bucket{Module: "m", Name: "presence", History: 5, TTLSeconds: 60, KeyTTL: true}) - queue := ModuleStreamConfig(ModuleStream{Module: "b", Name: "ASKS", Events: []string{"ask"}, MaxAgeDays: 7, MaxMiB: 32, - WorkQueue: true, Duplicates: 1}) - for _, row := range []struct { - what string - got, want any - }{ - {"bucket name", plain.Name, "KV_mesh-vault_bus-accounts"}, - {"bucket subjects", plain.Subjects[0], "$KV.mesh-vault_bus-accounts.>"}, - {"bucket history defaults to one", plain.MaxMsgsPerSubject, int64(1)}, - {"bucket cap", plain.MaxBytes, int64(StateMaxBytes)}, - {"bucket value cap", plain.MaxMsgSize, int32(StateMaxValueBytes)}, - {"bucket duplicate window", plain.Duplicates, 2 * time.Minute}, - {"bucket discards new", plain.Discard, jetstream.DiscardNew}, - {"bucket direct and rollup, no delete", plain.AllowDirect && plain.AllowRollup && plain.DenyDelete, true}, - {"bucket without key ttl", plain.AllowMsgTTL, false}, - {"history", keyed.MaxMsgsPerSubject, int64(5)}, - {"age", keyed.MaxAge, time.Minute}, - {"duplicate window under the age", keyed.Duplicates, time.Minute}, - {"key ttl markers", keyed.SubjectDeleteMarkerTTL, KeyTTLMarkerTTL}, - {"key ttl", keyed.AllowMsgTTL, true}, - {"queue retention", queue.Retention, jetstream.WorkQueuePolicy}, - {"queue subjects", queue.Subjects[0], "mesh.stream.b.event.ask"}, - {"queue age", queue.MaxAge, 7 * 24 * time.Hour}, - {"queue cap", queue.MaxBytes, int64(32 << 20)}, - {"queue discards new", queue.Discard, jetstream.DiscardNew}, - {"queue duplicates", queue.Duplicates, time.Hour}, - {"queue metadata", queue.Metadata[WorkQueueMetadata] + queue.Metadata[RestrictedMetadata], "truetrue"}, - } { - if row.got != row.want { - t.Errorf("%s: %v, want %v", row.what, row.got, row.want) - } - } -} diff --git a/go/buscompose/creates_streams_test.go b/go/buscompose/creates_streams_test.go deleted file mode 100644 index efc3efb..0000000 --- a/go/buscompose/creates_streams_test.go +++ /dev/null @@ -1,155 +0,0 @@ -package buscompose - -import "testing" - -// Defends issue 1790 and novox/hq ADR 0547 §5 (the operator, 2026-10-26 08:54): the grants part may create the stream -// of each bucket, log and stream a placed module's manifest declares, by its own name, and read it back; nothing -// wider, nothing updated or deleted, and no other principal so. Held byte-equal in the controller's copy. And ADR 0547 -// clause 7 (issue 1945, the operator's second exception): the grants part writes its own object bucket of sealed user -// lists, and the node-engine of the node holding mesh-broker alone reads its one key and an object of it by name. -func TestTheGrantsPartCreatesOnlyDeclaredStreams(t *testing.T) { - records := Records{ - Nodes: []string{"novox", "ace"}, - Assigned: map[string][]Declared{"ace": {{Module: "plex"}}, "novox": { - {Module: "nats", SnapshotsTheBus: true}, - // mesh-sdk's copy alone (ADR 0547 clause 9, named readers): the vault's hashes are read by the grants part - // alone, whatever another module's reads say; the controller's copy grants any module that declares it. - {Module: "mesh-vault", State: []Bucket{{Module: "mesh-vault", Name: "bus-accounts"}}}, - {Module: "spy", Serves: []string{"spy_x"}, Reads: []string{VaultBusAccounts}}, - {Module: "mesh-delivery-builder", - State: []Bucket{{Module: "mesh-delivery-builder", Name: "steps", History: 5}}, - Logs: []Log{{Module: "mesh-delivery-builder", Name: "builds"}}, - 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}}}, - } - users, err := Users(records) - if err != nil { - t.Fatal(err) - } - 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 { - t.Fatalf("%s: %v", u.Username(), err) - } - perms[u.Username()] = p - continue - } - if u.Kind != KindModule { - continue - } - p, err := PermissionsFor(u) - if err != nil { - t.Fatalf("%s: %v", u.Username(), err) - } - perms[u.Module] = p - } - for _, row := range []struct { - who, subject string - may bool - }{ - {GrantsModule, "$JS.API.STREAM.CREATE.KV_mesh-delivery-builder_steps", true}, - {GrantsModule, "$JS.API.STREAM.INFO.KV_mesh-delivery-builder_steps", true}, - {GrantsModule, "$JS.API.STREAM.CREATE.LOG_mesh-delivery-builder_builds", true}, - {GrantsModule, "$JS.API.STREAM.CREATE.BUILDASKS", true}, - {GrantsModule, "$JS.API.STREAM.CREATE.KV_mesh-grants_assertions", true}, - {GrantsModule, "$JS.API.STREAM.UPDATE.KV_mesh-delivery-builder_steps", false}, - {GrantsModule, "$JS.API.STREAM.DELETE.KV_mesh-delivery-builder_steps", false}, - {GrantsModule, "$JS.API.STREAM.PURGE.KV_mesh-delivery-builder_steps", false}, - {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. - {GrantsModule, "$JS.API.STREAM.CREATE.KV_mesh-host_acts", true}, - {"node.ace", "$JS.API.STREAM.CREATE.KV_mesh-host_acts", false}, - {"node.ace", "$KV.mesh-host_acts.ace.act.hand-over-1", true}, - {"node.ace", "$KV.mesh-host_acts.ace.open.hand-over.abc", true}, - {"node.ace", "$KV.mesh-host_acts.novox.act.hand-over-1", false}, - {"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}, - {GrantsModule, "$JS.API.STREAM.PURGE.OBJ_mesh-grants-user-list", true}, - {GrantsModule, "$O.declaration-files-novox.C.abc", false}, - {GrantsModule, "$JS.API.STREAM.DELETE.OBJ_mesh-grants-user-list", false}, - {"mesh-delivery-builder", "$O.mesh-grants-user-list.C.abc", false}, - // The bus node's engine reads the one key and an object by name; nothing of the bucket's list, no write. - {"node.novox", "$JS.API.DIRECT.GET.KV_mesh-grants_user-list.$KV.mesh-grants_user-list.sealed", true}, - {"node.novox", "$JS.API.CONSUMER.CREATE.KV_mesh-grants_user-list.abc.$KV.mesh-grants_user-list.sealed", true}, - {"node.novox", "$JS.API.DIRECT.GET.OBJ_mesh-grants-user-list.$O.mesh-grants-user-list.M.abc", true}, - {"node.novox", "$JS.API.CONSUMER.CREATE.OBJ_mesh-grants-user-list.abc.$O.mesh-grants-user-list.C.abc", true}, - {"node.novox", "$JS.API.DIRECT.GET.KV_mesh-grants_user-list.$KV.mesh-grants_user-list.list", false}, - {"node.novox", "$JS.API.STREAM.INFO.OBJ_mesh-grants-user-list", false}, - {"node.novox", "$JS.API.STREAM.MSG.GET.OBJ_mesh-grants-user-list", false}, - {"node.novox", "$O.mesh-grants-user-list.C.abc", false}, - {"node.ace", "$JS.API.DIRECT.GET.KV_mesh-grants_user-list.$KV.mesh-grants_user-list.sealed", false}, - {"node.ace", "$JS.API.DIRECT.GET.OBJ_mesh-grants-user-list.$O.mesh-grants-user-list.M.abc", false}, - {GrantsModule, "$JS.API.DIRECT.GET.KV_mesh-vault_bus-accounts.$KV.mesh-vault_bus-accounts.x", true}, - {"spy", "$JS.API.DIRECT.GET.KV_mesh-vault_bus-accounts.$KV.mesh-vault_bus-accounts.x", false}, - {"novox.node-tools", "$JS.API.DIRECT.GET.KV_mesh-vault_bus-accounts.$KV.mesh-vault_bus-accounts.x", false}, - } { - if got := MayPublish(perms[row.who], row.subject); got != row.may { - t.Errorf("%s may publish %s: %v, want %v", row.who, row.subject, got, row.may) - } - } - // Issue 1790 (2026-10-26): a module hears the bus create each bucket it may bind, its own and those it reads, so a - // state missing at its start is bound when it appears; no other bucket's, and no other advisory. - for _, row := range []struct { - who, subject string - may bool - }{ - {"mesh-delivery-builder", "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-builder_steps", true}, - {GrantsModule, "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-vault_bus-accounts", true}, - {"mesh-delivery-builder", "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-grants_assertions", false}, - {"mesh-delivery-builder", "$JS.EVENT.ADVISORY.STREAM.CREATED.LOG_mesh-delivery-builder_builds", false}, - {"mesh-delivery-builder", "$JS.EVENT.ADVISORY.STREAM.DELETED.KV_mesh-delivery-builder_steps", false}, - } { - if got := MaySubscribe(perms[row.who], row.subject); got != row.may { - t.Errorf("%s may hear %s: %v, want %v", row.who, row.subject, got, row.may) - } - } - // A name that is no plain token is never granted, and a module other than the grants part is never granted one. - if got := CreateStreamGrants([]string{"*", ">", "KV_a.b", "KV_a_b"}); len(got) != 2 { - t.Errorf("grants for wildcard names: %v", got) - } - other, err := PermissionsFor(Principal{Kind: KindModule, Node: "novox", Module: "shop", CreatesStreams: []string{"KV_shop_x"}}) - if err != nil { - t.Fatal(err) - } - if MayPublish(other, "$JS.API.STREAM.CREATE.KV_shop_x") { - t.Error("a module other than the grants part was granted a create") - } - // The writers table refuses the grants part an update, and a create of a stream it was not given. - g := Principal{Kind: KindModule, Node: "novox", Module: GrantsModule, CreatesStreams: []string{"KV_a_b"}} - if err := CheckWriters(g, []string{"$JS.API.STREAM.CREATE.KV_a_b"}); err != nil { - t.Errorf("the declared create refused: %v", err) - } - for _, refused := range []string{"$JS.API.STREAM.UPDATE.KV_a_b", "$JS.API.STREAM.CREATE.KV_a_c", "$JS.API.STREAM.CREATE.*"} { - if CheckWriters(g, []string{refused}) == nil { - t.Errorf("%s was not refused", refused) - } - } -} diff --git a/go/buscompose/declaration_files.go b/go/buscompose/declaration_files.go index af20264..4adef01 100644 --- a/go/buscompose/declaration_files.go +++ b/go/buscompose/declaration_files.go @@ -1,111 +1,8 @@ package buscompose import ( - "context" - "fmt" - "time" - "git.novox.be/novox/mesh-sdk/go/declaration" - "github.com/nats-io/nats.go/jetstream" ) -// The object buckets a declaration's large files travel in (novox/hq ADR 0486, issues 1696 and 1697): **one bucket for each -// node**, `declaration-files-`. -// -// **The controller alone writes them, and a node-engine reads an object by its name from its own bucket** (ADR 0486 §2): its -// account may neither list the bucket nor write it, no account may touch another node's, and no other account has any access. -// Why a bucket each: a subject pattern cannot tell a name from a wildcard. A grant of `...M.*` matches a publish to the literal -// subject `...M.*` too (probed on nats-server 2.11.17 by the review of this issue: a node created a consumer filtered on -// `$O..C.*` and read every object of the bucket), so in one shared bucket a node could read every other node's files. -// With a bucket each, the same trick reads only the node's own files, which are the files of its own declaration. -// -// A bucket of the bus's object store is a stream, OBJ_, whose messages are the objects' metadata -// (`$O..M.`) and chunks (`$O..C.`). - // DeclarationFilesBucket is the prefix of the buckets' names, which mesh-sdk fixes for the node-engine and the controller alike. const DeclarationFilesBucket = declaration.FileBucketPrefix - -// DeclarationFilesBucketOf is the bucket a node's files are in: mesh-sdk's FileBucketFor, the one place the name is composed. A -// node name it refuses (one that could carry a subject wildcard or separator into a grant) is an error, never a bucket. -func DeclarationFilesBucketOf(node string) (string, error) { return declaration.FileBucketFor(node) } - -// DeclarationFilesStreamOf is the stream under a node's bucket: mesh-sdk's FileStreamFor. -func DeclarationFilesStreamOf(node string) (string, error) { return declaration.FileStreamFor(node) } - -// DeclarationFilesMaxBytes is a bucket's size: the objects are immutable and regenerable, and one node's hold well under a hundred -// megabytes. -const DeclarationFilesMaxBytes = 1 << 30 - -// DeclarationFilesKeptFor is how long an object stays after no node's latest declaration names it (ADR 0486 §3). -const DeclarationFilesKeptFor = 24 * time.Hour - -// DeclarationFilesWrites is the one pattern the controller may publish to write a node's bucket: every object's metadata and -// chunks. The server's API for the stream is the controller's `$JS.API.>`. -func DeclarationFilesWrites(node string) (string, error) { - bucket, err := DeclarationFilesBucketOf(node) - if err != nil { - return "", err - } - return "$O." + bucket + ".>", nil -} - -// DeclarationFilesReads is what a node-engine's account may publish to read an object of its own bucket by its name, and nothing -// more. The set is the one node-engine's own call (mesh-host internal/link FileObject) makes, for B the node's bucket and S its -// stream (issue 1697, notes 4 and 5): -// -// - the object's metadata, by a direct get of the last message of the one subject that names it. **Only the suffixed -// form**: the bare `$JS.API.DIRECT.GET.S` takes a sequence number and would read any message of the stream. -// - the ordered consumer on that object's chunks: `$JS.API.CONSUMER.CREATE.S..$O.B.C.`. **Not `S.>`**: -// a consumer filtered on `$O.B.M.>` would read every object's name. -// - the flow-control replies of that consumer, without which a large object stalls at the flow-control window. -// - the consumer's deletion, best effort: the consumer's inactivity threshold removes it if this is refused. -// -// **Never granted:** STREAM.INFO (a `subjects_filter` of `>` lists every name), STREAM.MSG.GET (any message by its sequence), a -// publish to `$O.B.>` (a write), and any subject of another node's stream. A literal `*` in a subject matches the grant's `*`: it -// reaches the objects of this node's own bucket and no other (above). -func DeclarationFilesReads(node string) ([]string, error) { - bucket, err := DeclarationFilesBucketOf(node) - if err != nil { - return nil, err - } - stream, err := DeclarationFilesStreamOf(node) - if err != nil { - return nil, err - } - return []string{ - "$JS.API.DIRECT.GET." + stream + ".$O." + bucket + ".M.*", - "$JS.API.CONSUMER.CREATE." + stream + ".*.$O." + bucket + ".C.*", - "$JS.API.CONSUMER.DELETE." + stream + ".*", - "$JS.FC." + stream + ".>", - }, nil -} - -// EnsureDeclarationFiles is the same on a JetStream the caller holds. -func EnsureDeclarationFiles(ctx context.Context, js jetstream.JetStream, nodes ...string) error { - for _, node := range nodes { - cfg, err := DeclarationFilesConfig(node) - if err != nil { - return err - } - if _, err := js.CreateOrUpdateObjectStore(ctx, cfg); err != nil { - return fmt.Errorf("asserting the object bucket %s: %w", cfg.Bucket, err) - } - } - return nil -} - -// DeclarationFilesConfig is a node's bucket as the controller asserts it. -func DeclarationFilesConfig(node string) (jetstream.ObjectStoreConfig, error) { - bucket, err := DeclarationFilesBucketOf(node) - if err != nil { - return jetstream.ObjectStoreConfig{}, err - } - return jetstream.ObjectStoreConfig{ - Bucket: bucket, - Description: "the contents of the files over 64 KiB of the declaration of " + node + ", each under the hex of its sha256 " + - "digest (novox/hq ADR 0486): written by the controller alone, read by the node-engine of that node by name, removed " + - "a day after the node's latest declaration no longer names it", - MaxBytes: DeclarationFilesMaxBytes, - Storage: jetstream.FileStorage, - }, nil -} diff --git a/go/buscompose/derived.go b/go/buscompose/derived.go index a727e84..1885a93 100644 --- a/go/buscompose/derived.go +++ b/go/buscompose/derived.go @@ -1,21 +1,5 @@ package buscompose -import ( - "fmt" - "sort" - "strings" -) - -// Streams and consumers derived from what modules declare. -// -// The mesh's own four exist before any module does (streams.go). Everything here is the other -// half: a seat's stream comes into being when the module declaring it is **registered**, and a -// consumer when a module is **assigned** — which is why ADR 0116's task 1.4 had to be narrowed to -// the foundation set. Neither has happened at genesis. -// -// All of it is a pure function of declarations. The controller is still the only writer; this is -// only what it writes. - // A Consumer is a durable subscription the controller creates on a module's behalf. A module // declares what it reacts to, never how delivery works, so it does not name these and cannot // misconfigure them. @@ -68,314 +52,8 @@ type Consumer struct { Resettable string } -// seatStreamName is the stream holding a seat's inbound work. Named after the seat rather than -// the module holding it, because the holder can change and the queued work must not care — which -// is the whole reason a caller addresses a seat instead of a module. -func seatStreamName(seat string) string { return "SEAT_" + upperSnake(seat) } - -// SeatStreams is one work queue per declared seat, created when the declaring module is -// registered rather than when it is assigned. -// -// **The stream exists before anyone holds the seat, and that is the point.** Work queues until a -// holder appears, so installing the telegram module a week after something started sending to it -// flushes the backlog instead of having lost it. A stream created at assignment would make "the -// holder is not here yet" mean "your messages are gone". -func SeatStreams(seats []DeclaredSeat) []Stream { - sorted := append([]DeclaredSeat(nil), seats...) - sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name }) - - var out []Stream - for _, s := range sorted { - if len(s.Accepts) == 0 { - // A seat that only emits and serves needs no stream: its events ride EVENTS and its - // tools are core request/reply, which is never persisted. - continue - } - retain := s.RetainSeconds - if retain == 0 { - retain = 7 * 24 * 60 * 60 - } - out = append(out, Stream{ - Name: seatStreamName(s.Name), - Subjects: []string{"mesh.seat." + s.Name + ".accept.>"}, - Retention: RetentionWorkQueue, - MaxAge: retain, - Why: fmt.Sprintf("work submitted to the %s seat; one holder consumes it, and it "+ - "queues while nobody does", s.Name), - }) - } - return out -} - -// A DeclaredSeat is a seat as the catalogue knows it. Mirrored here rather than imported so this -// package stays free of the catalogue's own types — the same reason the host mirrors the -// contracts instead of importing the sdk. -type DeclaredSeat struct { - Name string - Accepts []string - // Emits are the verbs the seat's holder publishes under the seat's own name. An event about a - // role belongs here rather than in the holder's namespace, because the name then outlives - // whoever fills it (novox/hq ADR 0121, 04-ISSUES/127). - Emits []string - // Serves are the verbs the holder answers, request and reply. - Serves []string - RetainSeconds int - // PulledByKind is Seat's: a worker per accepted kind, derived from the holders' seat traffic, not here. - PulledByKind bool -} - -// ConsumerFor is the durable consumer a module's declarations imply, or false when it subscribes -// to nothing and needs none. -// -// One per module, with every consumed subject as a filter, because its ack permission is derived -// from its name: a module with a consumer per event would need an ack permission per consumer, -// and the permission list would stop being derivable from the declaration. -func ConsumerFor(p Principal) (Consumer, bool) { - // A module that reacts to anything — a module's events or a role's (novox/hq ADR 0121). Watching - // a role was missing here, so the one module that does it got no consumer at all: it started, - // connected, and its graph stayed empty with nothing anywhere reporting why. - // And one that hears its own answers on a seat it uses (novox/hq ADR 0259 §3): an asker's warrants. - hearsItsOwn := len(SeatTrafficOf(p.Module, nil, p.Uses, nil).Subscribe) > 0 - hearsAStream := false - for _, h := range p.Hears { - if len(h.Filters) > 0 { - hearsAStream = true - } - } - if p.Kind != KindModule || (len(p.Consumes) == 0 && len(p.Watches) == 0 && !hearsItsOwn && !hearsAStream) { - return Consumer{}, false - } - perms, err := PermissionsFor(p) - if err != nil { - return Consumer{}, false - } - // Events, wherever they live: a module's own namespace, and the namespace of any role it watches - // (novox/hq ADR 0121). Tool subjects and inboxes are subscribed directly and are not a consumer's - // business, which is why this is a filter and not the whole list. - var filters []string - for _, s := range perms.Subscribe { - // Not a module stream's, which its own consumer on that stream hears (novox/hq ADR 0363). - if strings.Contains(s, ".event.") && !strings.HasPrefix(s, moduleStreamPrefix) { - filters = append(filters, s) - } - } - // **A module that hears only a module's stream still has one here** (novox/hq ADR 0363 §7): what it - // gives up on there is delivered again under `mesh.again..` on EVENTS, and only this filters it. - if len(filters) == 0 && !hearsAStream { - return Consumer{}, false - } - sort.Strings(filters) - // And the events given up on and delivered again to this consumer alone (novox/hq issue 330). - filters = append(filters, AgainFilter(consumerDurable(p))) - return Consumer{ - Name: consumerDurable(p), - Stream: consumerStream(p), - Filters: filters, - AckWaitSeconds: 30, - MaxDeliver: 5, - // From now, as the controller's own consumers have been since issue 248: a module made on a stream that - // keeps a week replays none of it. What a part needs of the present it reads from its owner - // (novox/hq ADR 0443 §1, §3). A consumer that exists keeps its place whatever this says. - FromNow: true, - Why: "what " + p.Module + " declared it consumes; after max-deliver it gives an event up, and the " + - "controller keeps it in DEAD_LETTERS until a person delivers it again or drops it", - }, true -} - // ModuleConsumer is one module's durable consumer, with the module and node it is for. type ModuleConsumer struct { Node, Module string Consumer Consumer } - -// ConsumersOf is every module's durable consumer the composed users imply: each module user's, and -// each module a runtime carries — a carried module with no account of its own is no user (novox/hq -// issue 195), and its consumer is still the controller's to make, since the runtime reads it on the -// module's behalf (ADR 0198). One per module and node, whichever of the two named it first. -func ConsumersOf(users []Principal) []ModuleConsumer { - var out []ModuleConsumer - seen := map[string]bool{} - add := func(p Principal) { - key := p.Node + "/" + p.Module - if seen[key] { - return - } - c, needed := ConsumerFor(p) - // **A module with a work-queue consumer of its own needs no other reason to be visited here** - // (novox/hq ADR 0436 §1, §2, §6): mesh-mail's sender consumes and watches nothing of its own, so - // ConsumerFor alone would have found nothing needed and skipped it before its one queue consumer - // was ever composed. - if !needed && len(p.Queues) == 0 { - return - } - seen[key] = true - if needed { - out = append(out, ModuleConsumer{Node: p.Node, Module: p.Module, Consumer: c}) - // And one on each module stream it hears (novox/hq ADR 0363 §5). - for _, h := range p.Hears { - if len(h.Filters) == 0 { - continue - } - out = append(out, ModuleConsumer{Node: p.Node, Module: p.Module, Consumer: heardConsumer(p, h)}) - } - } - // And the one durable consumer of each of its own work-queue streams, for the one process its - // bus.queues names (novox/hq ADR 0436 §1, §2, §6). - for _, q := range p.Queues { - out = append(out, ModuleConsumer{Node: p.Node, Module: p.Module, Consumer: QueueConsumer(p, q)}) - } - } - for _, p := range users { - if p.Kind == KindModule { - add(p) - } - } - for _, p := range users { - if p.Kind != KindNodeTools { - continue - } - for _, d := range p.Carries { - // **A carried module's work-queue consumer is still the controller's to make** (novox/hq ADR - // 0436 §1, §2, §6), the same reason its ordinary consumer is (ADR 0198): mesh-mail has no - // account of its own (NoAccount) and is carried here, but its process does, and the consumer - // it reads through must exist and survive PruneHeard whether or not the module itself speaks. - add(Principal{Kind: KindModule, Node: p.Node, Module: d.Module, Emits: d.Emits, - Consumes: d.Consumes, Serves: d.Serves, Holds: d.Holds, Uses: d.Uses, Watches: d.Watches, - Streams: d.Streams, Hears: ComposedHears(d), Queues: d.Queues}) - } - } - return out -} - -// AccountConsumers are the durable consumers an account needs (novox/hq ADR 0561 §6): of ConsumersOf, each module's on -// EVENTS and each process's on its own work queue, never one on a module stream it hears (those the controller makes with -// the stream's grant). The grants part creates them from the lease write, with these configurations. -func AccountConsumers(users []Principal) []ModuleConsumer { - queues := map[string]bool{} - for _, p := range users { - for _, q := range p.Queues { - queues[q.Stream] = true - } - for _, d := range p.Carries { - for _, q := range d.Queues { - queues[q.Stream] = true - } - } - } - var out []ModuleConsumer - for _, mc := range ConsumersOf(users) { - if mc.Consumer.Stream == consumerStream(Principal{Kind: KindModule}) || queues[mc.Consumer.Stream] { - out = append(out, mc) - } - } - return out -} - -// HolderConsumerFor is the worker a seat's holders share on that seat's work queue. -// -// **One worker for every holder, and each holder pulls one ask when it is idle** (novox/hq ADR -// 0190). The seat is *authority* — who may be the telegram sender — and the worker is *delivery*, -// kept separate so that relaxing one changes nothing about the other: a node-scoped seat has a -// holder per machine, and all of them take from this one consumer, so the work is shared without -// any holder knowing about the others. Pulled rather than pushed because a push consumer hands the -// next ask to whichever subscriber the server picks, busy or not, and a pulled one is asked for by -// a holder that has just become free. Which is also what ends the race issue 186 describes — asks -// delivered behind the one being worked, expiring unacknowledged and dropped after the fifth -// redelivery: nothing is delivered that nobody asked for. A long build keeps its own ask alive -// (stillWorking); the ack wait is for a holder that died. -func HolderConsumerFor(node, module string, seat DeclaredSeat) (Consumer, bool) { - if len(seat.Accepts) == 0 || seat.PulledByKind { - // A seat pulled by kind has a worker per kind, which its holders' seat traffic derives (SeatTrafficObjects): one - // worker filtered on every accept would overlap them on the work queue. - return Consumer{}, false - } - return Consumer{ - Name: "SEAT_" + upperSnake(seat.Name) + "_worker", - Stream: seatStreamName(seat.Name), - Filters: []string{"mesh.seat." + seat.Name + ".accept.>"}, - AckWaitSeconds: 60, - MaxDeliver: 5, - // As many in flight as there are holders working, which pulling bounds by itself: a holder - // fetches one and fetches again only after it acknowledged. The server's default stands. - Why: fmt.Sprintf("%s on %s holds %s; every holder pulls one ask at a time from this worker "+ - "and acknowledges after the work is done, so a crash mid-work redelivers rather than "+ - "loses and an idle holder is the one that takes the next ask", module, node, seat.Name), - }, true -} - -// NodeConsumer is the durable consumer a node reads its own declaration through. -// -// **Derived from a node existing, and created by the controller, because a host cannot create it.** -// A host's account may subscribe its own declaration subject and publish its own ack subject, and -// reaches no part of the JetStream API — which is correct (the controller is the only writer of -// consumer definitions, design 25 §3) and means the consumer must be waiting before the host binds -// to it. Named after the node, because the node's ack grant is `$JS.ACK.NODES..>` and a -// consumer named anything else is one the host cannot acknowledge a delivery from. -// -// **No max-deliver, and a long ack wait.** A declaration is settled only after the node has applied -// it and reported, which is minutes on a machine pulling images; and a declaration the mesh cannot -// get a node to accept is not one to give up on, because the stream keeps only the newest per node -// anyway — so there is exactly one message per node to redeliver, for as long as that node is away. -func NodeConsumer(node string) Consumer { - return Consumer{ - Name: node, - Stream: "NODES", - Filters: []string{"mesh.node." + node + ".declare"}, - Push: true, - AckWaitSeconds: 300, - Why: "how " + node + " hears what it should be; last-per-subject, so a node that was away " + - "gets exactly the current declaration and nothing older", - } -} - -// AssertNodeConsumers brings every known node's declaration consumer into being. -// -// Asserted on start as well as created at enrolment, for the reason the streams are: a mesh raised -// from a restored backup, or one whose bus was recreated, has node records and no consumers, and a -// node whose consumer is missing hears nothing while everything else about it looks correct. -func AssertNodeConsumers(e Ensurer, nodes []string) error { - for _, n := range nodes { - if err := e.EnsureConsumer(NodeConsumer(n)); err != nil { - return fmt.Errorf("asserting how %s hears its declaration: %w", n, err) - } - } - return nil -} - -// AllOverlaps reports subject filters claimed by more than one stream, across the mesh's own and -// every derived one. -// -// NATS refuses an overlapping stream rather than merging it (verified against nats-server 2.10: -// "subjects overlap with an existing stream"), so this is not a subtle divergence — it is a -// registration that fails. Catching it here names both streams, before a half-applied mesh does. -func AllOverlaps(seats []DeclaredSeat) []string { - all := append(MeshStreams(), SeatStreams(seats)...) - seen := map[string]string{} - var clashes []string - for _, s := range all { - for _, subject := range s.Subjects { - if first, ok := seen[subject]; ok { - clashes = append(clashes, fmt.Sprintf("%s and %s both claim %s", first, s.Name, subject)) - continue - } - seen[subject] = s.Name - } - } - sort.Strings(clashes) - return clashes -} - -// upperSnake makes a stream name from a seat name. NATS stream names may not contain a dot, -// a space or a wildcard, and a hyphen is legal but reads badly beside the mesh's own. -func upperSnake(s string) string { - out := []rune(s) - for i, r := range out { - switch { - case r >= 'a' && r <= 'z': - out[i] = r - 32 - case r == '-' || r == '.': - out[i] = '_' - } - } - return string(out) -} diff --git a/go/buscompose/doc.go b/go/buscompose/doc.go index fcd2338..42db725 100644 --- a/go/buscompose/doc.go +++ b/go/buscompose/doc.go @@ -1,12 +1,5 @@ -// Package buscompose is the bus's composer: who may say what on the bus, as the user list and the server configuration -// the bus loads, derived from the mesh's records (Users, WithPasswords, ComposeAccounts, Compose), with the grants, -// streams, state and logs it composes them from. -// -// **A copy of the controller's internal/broker, composer half** (issue 1377, novox/hq ADR 0547 §5), taken at -// mesh-controller 7cd4d18b with the grants part's membership grant of f67d1553 (ADR 0547 §5) and its grant to create the streams manifests declare (issue 1790): the bus client (jetstream.go), the broker's address from the environment (broker.go, onnats.go -// but its one constant) and the two methods on that client are left out; the lease's two subjects are copied in -// (nats.go). The controller keeps its own copy, unchanged, until it loses its lease at ADR 0547's third milestone, so -// for that time two composers state who may say what and must say the same: TestTheCopiedComposerComposesAsTheControllers -// holds this copy to lists the controller's own code wrote from the same fixtures. A change to what is granted is made -// here and, while the controller composes, in the controller's copy too, never in one alone. +// Package buscompose is the bus's names and shapes every part reads (novox/hq ADR 0593 §1): the mesh's streams and +// dead letters, a module's log, stream and buckets as the bus names them, the account a part asks for, a principal's +// shape, and the grants part's memberships key. The composer of the bus's user list, which reads these and makes every +// user's permissions, is the grants part's own (mesh-delivery modules/mesh-grants internal/buscompose). package buscompose diff --git a/go/buscompose/engine.go b/go/buscompose/engine.go index 6426db7..1f52f8c 100644 --- a/go/buscompose/engine.go +++ b/go/buscompose/engine.go @@ -1,18 +1,5 @@ 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 -// claims it. Its verbs and events are the catalogue's (catalogue.EngineVerbs), read here rather than copied. - -// EngineSeat is the seat. -const EngineSeat = catalogue.EngineSeat - // EngineModule is the node-engine's manifest's module name, and EngineActsState its state the acts asked through the // node seat and its own asks are kept in (ADR 0563 §2, §3: "the engine's own state"): the bucket mesh-host_acts, keys // `.act.`, `.open..` and `.ask.`, each written by that node's engine alone. @@ -23,60 +10,3 @@ 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 `.` 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..tool.>`, nothing is given away: no other -// principal may subscribe this seat's subjects of this node, and a caller is still granted each verb by name. -func engineGrants(p Principal) (pub, sub []string) { - sub = append(sub, "mesh.seat."+EngineSeat+".tool.*."+p.Node) - pub = append(pub, "mesh.seat."+EngineSeat+".event.*."+p.Node) - sub = append(sub, announcing(EngineSeat)...) - pub = append(pub, discovering()...) - tp, ts := seatTrafficGrants(SeatTrafficOf(asks.EngineAsker(p.Node), nil, p.Uses, nil)) - // And its acts and asks, under its own node's keys alone; never the bucket's creation, which is the grants part's. - 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 `` 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...) -} diff --git a/go/buscompose/fixtures_test.go b/go/buscompose/fixtures_test.go deleted file mode 100644 index bfeda2f..0000000 --- a/go/buscompose/fixtures_test.go +++ /dev/null @@ -1,119 +0,0 @@ -package buscompose - -// The fixtures the copied bus composer is held to (issue 1377, ADR 0547 §5): the same source is compiled into the -// controller's internal/broker to write the expected lists, and into mesh-sdk's buscompose to compare against them. - -// UserListFixture is one mesh's records, and the hashes its accounts carry. -type UserListFixture struct { - Name string - Records Records - // Hashless are users given no hash, so they are left out and named. - Hashless []string -} - -func fixtureTelegramSeat() Seat { - return Seat{Name: "telegram-sender", Accepts: []string{"send"}, Emits: []string{"delivered", "failed"}} -} - -// UserListFixtures are the meshes, from the controller's own tests' records outward. -func UserListFixtures() []UserListFixture { - return []UserListFixture{ - {Name: "empty"}, - {Name: "the controller's someRecords", Records: Records{ - Nodes: []string{"two", "one"}, - Assigned: map[string][]Declared{ - "one": {{Module: "telegram", Serves: []string{"status"}}}, - "two": {{Module: "shop", Emits: []string{"order.placed"}}}, - }, - Enrolling: []string{"three"}, - People: map[string][]string{"ada": {"mesh-catalog.catalog_tools"}}, - }}, - {Name: "seats, consumers, people and a hashless user", Hashless: []string{"two.audit"}, Records: Records{ - Nodes: []string{"one", "two"}, - Assigned: map[string][]Declared{ - "one": { - {Module: "telegram", Holds: []Seat{fixtureTelegramSeat()}, Serves: []string{"status"}}, - {Module: "nats", SnapshotsTheBus: true, Serves: []string{"nats_streams"}}, - {Module: "mesh-watchman"}, - {Module: "mesh-grants", Emits: []string{"membership-changed"}}, - }, - "two": { - {Module: "shop", Uses: []Seat{fixtureTelegramSeat()}, Emits: []string{"order.placed"}}, - {Module: "audit", Consumes: []string{"shop.order.placed"}}, - }, - }, - People: map[string][]string{"ada": {"*"}, "bob": {"shop.status"}}, - Interchangeable: map[string]bool{"shop": true}, - }}, - {Name: "state, logs, streams, queues and processes", Records: Records{ - Nodes: []string{"ace", "novox"}, - Assigned: map[string][]Declared{ - "novox": { - {Module: "mesh-delivery-builder", Emits: []string{"built"}, - State: []Bucket{{Module: "mesh-delivery-builder", Name: "steps", History: 5}, - {Module: "mesh-delivery-builder", Name: "presence", PerMachine: true, KeyTTL: true, TTLSeconds: 60}}, - Logs: []Log{{Module: "mesh-delivery-builder", Name: "builds", MaxMiB: 64}}, - Streams: []ModuleStream{{Module: "mesh-delivery-builder", Name: "asks", Events: []string{"ask"}, MaxAgeDays: 7, MaxMiB: 32, WorkQueue: true}}, - Queues: []QueueOwner{{Process: "worker", Stream: "asks", MaxDeliver: 5}}, - Processes: []ProcessPrincipal{{Process: "worker", Emits: []string{"built"}, - Queues: []QueueOwner{{Process: "worker", Stream: "asks", MaxDeliver: 5}}, - State: []string{"steps"}, Invokes: []string{"gitea.gitea_commit"}}}, - Invokes: []string{"gitea.gitea_commit"}, - }, - {Module: "mesh-delivery-view", Reads: []string{"mesh-delivery-builder.steps"}, - KeyedReads: []KeyedRead{{Read: "mesh-delivery-builder.presence", Key: "novox"}}}, - // The grants part, which may create every stream declared here (ADR 0547 §5, issue 1790). - {Module: "mesh-grants", Emits: []string{"membership-changed"}, - State: []Bucket{{Module: "mesh-grants", Name: "assertions", History: 5}}}, - }, - "ace": { - {Module: "plex", Serves: []string{"plex_status"}, Checks: []string{"plex.plex_status"}}, - {Module: "silent", NoAccount: true}, - }, - }, - RootFree: map[string]bool{"ace": true}, - }}, - } -} - -// HashOf is the stand-in hash a fixture's user carries. -func HashOf(user string) string { - h := "$2a$11$" - for len(h) < 29 { - h += user - } - return h[:29] + "fixturefixturefixturefixture00" -} - -// ComposedFor is a fixture's user list as the composer writes it, and the users left out. -func ComposedFor(f UserListFixture) (string, []string, error) { - users, err := Users(f.Records) - if err != nil { - return "", nil, err - } - hashes := map[string]string{} - skip := map[string]bool{} - for _, u := range f.Hashless { - skip[u] = true - } - for _, u := range users { - if !skip[u.Username()] { - hashes[u.Username()] = HashOf(u.Username()) - } - } - filled, missing := WithPasswords(users, hashes) - list, err := ComposeAccounts(filled) - return list, missing, err -} - -// MembershipsFor is every membership a fixture's records issue, by `.`, as the composer makes them. -func MembershipsFor(f UserListFixture) map[string]Membership { - where := PlacementsOf(f.Records, f.Records.Interchangeable) - out := map[string]Membership{} - for node, ds := range f.Records.Assigned { - for _, d := range ds { - out[node+"."+d.Module] = MembershipFor(node, d, where) - } - } - return out -} diff --git a/go/buscompose/health_grants.go b/go/buscompose/health_grants.go index 29036e9..a06f64d 100644 --- a/go/buscompose/health_grants.go +++ b/go/buscompose/health_grants.go @@ -3,27 +3,12 @@ package buscompose import ( "fmt" "slices" - "sort" "strings" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/health" ) -// **The health holders' grants** (novox/hq ADR 0564 clauses 2, 5 and 8, Limits 2, 4 and 8; issue 1952 S5; ADR 0590). -// -// - Nobody reads mesh-healers' `would` (clause 8): what the healers would have done reaches no condition. Every -// observer's `findings`, the healers' `attempts`, the wants and the haves are read through `reads` as any announced -// state is; and no module writes another's state, so an observer can never write the keeper's buckets (clause 2). -// - The watchman makes its own consumer on DEAD_LETTER_NOTICES, by its own name, pulled, and reads whether the ended -// controller's consumer `controller` still stands; it may neither delete nor change that consumer (clause 5). -// - The bus module's tools run on an account of their own, declared by their bundle's `bus` with `removes` -// (catalogue.BusAccounts), which may remove only keys of the node-engine's acts bucket (Limits 4) and the ended -// controller's leftovers (ADR 0590 clause 6), never the backup's account `broker`, which only reads (ADR 0235). - -// HealersWould is what mesh-healers would have done while a controller was placed: read by nobody (ADR 0564 clause 8). -var HealersWould = health.HealersModule + "." + health.WouldState - // ControllerLeftoverConsumers are the ended controller's durable consumers left on the bus, one leftover of the module // mesh-delivery placed nowhere (issue 1126, milestone section 3), and mesh-catalog's of the controller's built-before, // which its manifest no longer consumes and no part composes; each `.`. @@ -92,55 +77,6 @@ func BusRemovalGrants(removes, nodes []string) ([]string, error) { return out, nil } -// removesLeftover is an account declared to remove the ended controller's leftovers deleting one of its streams: the -// writers table's one exemption from stream definitions being the grants part's (ADR 0590 clause 6). -func removesLeftover(p Principal, pattern string) bool { - name, ok := strings.CutPrefix(pattern, "$JS.API.STREAM.DELETE.") - return ok && p.Kind == KindProcess && slices.Contains(p.Removes, catalogue.RemovesControllerLeftovers) && - slices.Contains(ControllerLeftoverStreams(p.FilesFor), name) -} - -// removesEngineActs is an account declared to remove keys of the node-engine's acts bucket: the writers table's one -// exemption from a node's engine being the only writer of its keys, for removal (ADR 0564 Limits 4). -func removesEngineActs(p Principal, pattern string) bool { - return p.Kind == KindProcess && slices.Contains(p.Removes, catalogue.RemovesEngineActs) && pattern == "$KV."+EngineActsBucket+".>" -} - -// WatchmanDeadLetterConsumer is the watchman's own consumer on DEAD_LETTER_NOTICES, made only once the lease names no -// controller and `controller` is gone (ADR 0564 clause 5). -const WatchmanDeadLetterConsumer = WatchmanModule - -// WatchmanDeadLetterGrants are what the watchman may publish for the dead letters in L4 (ADR 0564 clause 5), at least -// privilege: its own pulled consumer on DEAD_LETTER_NOTICES (made, read, pulled, acknowledged); whether `controller` -// still stands (read only); a read of DEAD_LETTERS (its information and one kept message); a copy kept -// (`mesh.events.dead.>`); and on each stream a notice can name (sources: EVENTS, the seats' work queues and the -// modules' own), its information, for a source's age (ADR 0376), and the message given up on, each stream by its name. -// Never a wildcard over the streams and never a bucket's or an object store's stream: a read of any message would read -// every bucket (the lead, 2026-10-29, on issue 1952 S5). Delivering again and dropping stay refused in L4, so it may -// neither delete a message nor publish `mesh.again.>`. -func WatchmanDeadLetterGrants(sources []string) []string { - own := DeadLetterNoticesStream + "." + WatchmanDeadLetterConsumer - out := []string{ - "$JS.API.STREAM.INFO." + DeadLetterNoticesStream, - "$JS.API.CONSUMER.INFO." + DeadLetterNoticesStream + "." + ControllerName, - "$JS.API.CONSUMER.CREATE." + own, - "$JS.API.CONSUMER.INFO." + own, - "$JS.API.CONSUMER.MSG.NEXT." + own, - "$JS.ACK." + own + ".>", - "$JS.API.STREAM.INFO." + DeadLettersStream, - "$JS.API.STREAM.MSG.GET." + DeadLettersStream, - deadLetterPrefix + ">", - // And the provisioner clients' reports, the newest per subject read at its start (S21, ADR 0362 §3). - "$JS.API.STREAM.INFO." + ProvAppliedStream, - "$JS.API.STREAM.MSG.GET." + ProvAppliedStream, - "$JS.API.DIRECT.GET." + ProvAppliedStream + ".>", - } - for _, s := range DeadLetterSources(sources) { - out = append(out, "$JS.API.STREAM.INFO."+s, "$JS.API.STREAM.MSG.GET."+s) - } - return out -} - // WatchmanHears are the core subjects the watchman subscribes, with no durable consumer (ADR 0564 clauses 4, 5 and // Limits 3, 11): the power seat's words (S1, ADR 0211), the providers' failing and recovered words (S8), each tool // runner's beat (S11), the self-check's checked (S10), a consumer deleted and the bus module's slow consumers and closed @@ -161,52 +97,3 @@ func WatchmanHears() []string { deadLetterPrefix + ">", } } - -// DeadLetterSources are the streams of names a dead letter may come from, sorted, none twice: each a plain name, never a -// bucket's (KV_) or an object store's (OBJ_) stream, the dead letters' own two streams, or a pattern. -func DeadLetterSources(names []string) []string { - seen := map[string]bool{} - var out []string - for _, n := range names { - switch { - case seen[n], !safeSubject.MatchString(n), strings.HasPrefix(n, "KV_"), strings.HasPrefix(n, "OBJ_"), - n == DeadLettersStream, n == DeadLetterNoticesStream: - continue - } - seen[n] = true - out = append(out, n) - } - sort.Strings(out) - return out -} - -// deadLetterSourcesOf are the streams the records' consumers give up on: EVENTS, the work queue of every seat that -// accepts work, and every module's and process's own work queue. -func deadLetterSourcesOf(r Records) []string { - out := []string{EventsStream} - for _, mods := range r.Assigned { - for _, d := range mods { - for _, s := range append(append([]Seat(nil), d.Holds...), d.Uses...) { - if len(s.Accepts) > 0 { - out = append(out, seatStreamName(s.Name)) - } - } - for _, q := range d.Queues { - out = append(out, q.Stream) - } - for _, pr := range d.Processes { - for _, q := range pr.Queues { - out = append(out, q.Stream) - } - } - } - } - return DeadLetterSources(out) -} - -// keepsDeadLetters is the watchman keeping the dead letters: a second writer of the copies beside the controller, which -// keeps them until its lease is handed away and the watchman after (ADR 0564 clause 5). Delivering again stays the -// controller's in L4. -func keepsDeadLetters(p Principal, pattern string) bool { - return p.Kind == KindModule && p.Module == WatchmanModule && pattern == deadLetterPrefix+">" -} diff --git a/go/buscompose/log.go b/go/buscompose/log.go index d179b2b..ef26963 100644 --- a/go/buscompose/log.go +++ b/go/buscompose/log.go @@ -2,27 +2,11 @@ package buscompose import ( "fmt" - "sort" - "strings" "time" "github.com/nats-io/nats.go/jetstream" ) -// A module's log on the bus (novox/hq ADR 0297): its record of operations, each entry appended under -// a key and kept as long as the log is. -// -// A log follows a bucket in every respect (ADR 0201): the module names it locally, the mesh derives -// its stream and subjects, the controller creates it from the catalogue on every raise and from -// registration, never a module, and **no path of the mesh removes one** — a log whose declaration -// is gone is reported, as a bucket is. Only its owner appends to it. Another module reads it when its manifest's -// `reads` names it, `.` (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. - // The mesh's caps on a log: what an entry's value may weigh, what a message on the log's stream may // weigh — the value and its headers, which the server counts in a message's size, so a value of the // full size still fits with the expected-last-sequence header an append carries — and what a log holds @@ -31,7 +15,6 @@ const ( LogMaxEntryBytes = 256 * 1024 LogMaxMessageBytes = LogMaxEntryBytes + 4*1024 LogDefaultMiB = 1024 - LogMostMiB = 8192 ) // A Log is one module's declared log as the bus holds it. @@ -102,141 +85,3 @@ func (l Log) Config() jetstream.StreamConfig { Replicas: 1, } } - -// logsIssuedFor is every log a module's code may reach, as its membership lists them: its own. -func logsIssuedFor(d Declared) []LogIssued { - var out []LogIssued - for _, l := range d.Logs { - if !safeSubject.MatchString(d.Module) || !safeSubject.MatchString(l.Name) { - continue - } - out = append(out, LogIssued{Name: l.Name, Stream: LogStreamName(d.Module, l.Name), - Subject: LogSubject(d.Module, l.Name), Writes: true}) - } - return out -} - -// logGrants is what a principal publishes to reach the logs its module keeps: for each, appending -// under the log's subjects, binding to its stream, and reading it directly. Nothing more — the -// runtime reads a log by direct gets alone and makes no consumer on it — and nothing of any other -// module's log, because a log is its owner's alone. Replies come on the principal's inbox, as for a -// bucket. -func logGrants(module string, names []string) []string { - if !safeSubject.MatchString(module) { - return nil - } - var out []string - for _, name := range names { - if !safeSubject.MatchString(name) { - continue - } - stream := LogStreamName(module, name) - out = append(out, - LogSubject(module, name)+".>", - "$JS.API.STREAM.INFO."+stream, - "$JS.API.DIRECT.GET."+stream, - "$JS.API.DIRECT.GET."+stream+".>") - } - return out -} - -// DeclaredLogs is every log a module placed anywhere declares, as a read names it: `.`. -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 `.` 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)) - for _, l := range logs { - out = append(out, l.Name) - } - return out -} - -// A LogAsserter is the part of a JetStream connection log assertion needs. It has no way to remove a -// log, by design: nothing the mesh runs asks for one. -type LogAsserter interface { - // EnsureLog creates the log's stream if absent and brings its configuration to match if present, - // never deleting or recreating it. - EnsureLog(l Log) error - // LogStreams is every log stream on the server: every stream named with LogStreamPrefix. - LogStreams() ([]string, error) -} - -// RaiseLogs asserts every declared log and answers the log streams on the server that nothing -// declares any more. -// -// **Those are reported, never removed** (novox/hq ADR 0297 §2, ADR 0201 §15–16): a log is a module's -// record, and a manifest edited, a module renamed or unassigned is an ordinary day's work that must -// not take a record with it. Removing one is a person's act, outside the mesh. -func RaiseLogs(a LogAsserter, logs []Log) (undeclared []string, err error) { - sorted := append([]Log(nil), logs...) - sort.Slice(sorted, func(i, j int) bool { return sorted[i].Stream() < sorted[j].Stream() }) - declared := map[string]bool{} - for _, l := range sorted { - if err := a.EnsureLog(l); err != nil { - return nil, fmt.Errorf("asserting %s's log %q: %w", l.Module, l.Name, err) - } - declared[l.Stream()] = true - } - names, err := a.LogStreams() - if err != nil { - return nil, fmt.Errorf("listing the bus's logs: %w", err) - } - for _, n := range names { - if strings.HasPrefix(n, LogStreamPrefix) && !declared[n] { - undeclared = append(undeclared, n) - } - } - sort.Strings(undeclared) - return undeclared, nil -} diff --git a/go/buscompose/membership.go b/go/buscompose/membership.go deleted file mode 100644 index 8601788..0000000 --- a/go/buscompose/membership.go +++ /dev/null @@ -1,178 +0,0 @@ -package buscompose - -import ( - "slices" - "sort" - "strings" -) - -// What the mesh issues an assignment to serve and to reach (novox/hq ADR 0160). -// -// A module's code names its tools and its events; **where they land is the mesh's to decide**, and -// it decided it twice — once in the runtime, once here, by one rule compiled into both. Now the -// controller composes a membership for every module on every machine and publishes it to a subject -// only that assignment reads; the runtime serves exactly what the membership says, and the account's -// grant is the same composition read the other way. The shape issued today is the shape the mesh -// already had, so nothing moves when a membership first arrives; only who decides it moves. - -// Placements is where every module runs, for deciding which instance answers for the module. -type Placements struct { - // Nodes is each module's machines. - Nodes map[string][]string - // Interchangeable is each module whose definition says its instances are the same anywhere, - // so the module's plain subject is issued to all of them in one queue. - 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, `.` (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 -// plain subject: when it is the only instance, or when the definition says instances are -// interchangeable. A stateful module on two machines gets only its machines' subjects, so a call -// that names none reaches nothing rather than the wrong store. -func (p Placements) AnswersForTheModule(module string) bool { - return len(p.Nodes[module]) <= 1 || p.Interchangeable[module] -} - -// MembershipFor composes one assignment's membership from what it declared and where everything -// runs. The subjects are the ones PermissionsFor grants, derived here once more only until the -// grant itself is read from the membership — which is the next step, not this one. -func MembershipFor(node string, d Declared, where Placements) Membership { - own := "mesh.mod." + d.Module - m := Membership{ - Node: node, Module: d.Module, - Emits: own + ".event.{event}", - Tools: own + ".tool.tools", - } - // This machine's address always; the module's when this instance answers for the module. - m.Serves = append(m.Serves, Served{Subject: own + ".tool.{tool}." + node}) - if where.AnswersForTheModule(d.Module) { - m.Serves = append(m.Serves, Served{Subject: own + ".tool.{tool}", Queue: "serve." + d.Module}) - } - for _, s := range d.Holds { - for _, verb := range s.Serves { - m.Seats = append(m.Seats, SeatServed{Seat: s.Name, Verb: verb, Subject: seatToolSubject(s, verb, node)}) - } - } - m.State = stateIssuedFor(d, node) - m.Logs = append(logsIssuedFor(d), readLogsIssuedFor(withReadLogs(d, where.Logs))...) - m.Streams = streamsIssuedFor(d) - m.Hears = HeardIssuedFor(node, d) - m.Waits = waitsIssuedFor(d) - t := SeatTrafficOf(d.Module, d.Holds, d.Uses, d.Watches) - for _, s := range append(append([]Seat{}, d.Uses...), d.Watches...) { - if !s.Kinded { - continue - } - for _, k := range where.Kinds { - if k.Seat == s.Name && !kindListed(t.Kinds, k) { - t.Kinds = append(t.Kinds, k) - } - } - } - if len(t.Publish)+len(t.Subscribe)+len(t.Answers)+len(t.Workers)+len(t.Records)+len(t.Kinds) > 0 { - m.SeatTraffic = &t - } - if len(d.Invokes) > 0 { - m.Reaches = map[string][]string{} - for _, t := range d.Invokes { - if t == "*" || strings.HasPrefix(t, "seat:") { - continue // every tool, or a role's: addressed by name, not resolved per instance - } - module, tool, ok := strings.Cut(t, ".") - if !ok { - continue - } - var reach []string - if where.AnswersForTheModule(module) { - reach = append(reach, "mesh.mod."+module+".tool."+tool) - } - nodes := append([]string{}, where.Nodes[module]...) - sort.Strings(nodes) - for _, n := range nodes { - reach = append(reach, "mesh.mod."+module+".tool."+tool+"."+n) - } - m.Reaches[t] = reach - } - } - return m -} - -// PlacementsOf reads where everything runs from the records the bus's accounts are composed from. -func PlacementsOf(r Records, interchangeable map[string]bool) Placements { - p := Placements{Nodes: map[string][]string{}, Interchangeable: interchangeable, Logs: DeclaredLogs(r)} - for node, declared := range r.Assigned { - for _, d := range declared { - p.Nodes[d.Module] = append(p.Nodes[d.Module], node) - } - } - for _, nodes := range p.Nodes { - sort.Strings(nodes) - } - // Where the router runs: a verified sender is believed only while its machine is root-free too. A router - // placed nowhere, or on more than one machine, frees nothing. - var routerNodes []string - for node, declared := range r.Assigned { - for _, d := range declared { - for _, s := range d.Holds { - if s.Name == routerSeat && !slices.Contains(routerNodes, node) { - routerNodes = append(routerNodes, node) - } - } - } - } - routerFree := len(routerNodes) == 1 && r.RootFree[routerNodes[0]] - for node, declared := range r.Assigned { - for _, d := range declared { - for _, s := range d.Holds { - if s.Kinded && s.Kind != "" { - p.Kinds = append(p.Kinds, KindHeld{Seat: s.Name, Kind: s.Kind, Module: d.Module, Node: node, - Capabilities: placedCapabilities(s.Capabilities, d.RunsAs, routerFree && r.RootFree[node])}) - } - } - } - } - sort.Slice(p.Kinds, func(i, j int) bool { - a, b := p.Kinds[i], p.Kinds[j] - if a.Seat != b.Seat { - return a.Seat < b.Seat - } - if a.Kind != b.Kind { - return a.Kind < b.Kind - } - return a.Node < b.Node - }) - return p -} - -// routerSeat is the seat the router of asks holds (novox/hq ADR 0259 §3). -const routerSeat = "operator-channel" - -// placedCapabilities is what a kind's claim promises, as far as its placement lets the router believe it -// (novox/hq ADR 0259 §8): `verified-sender` only from a holder that runs as an account of its own, on a bus -// account of its own — never one the machine's runtime carries as the operator's account — and only while -// its machine and the router's were root-free when composed (rootFree; the review of 2026-10-09, H3). The -// membership carrying it is composed at a push, so it can outlive a pass that later fails: the router asks -// the controller's root-free verb again before it honours an approval, and that is the check that holds. -func placedCapabilities(declared []string, runsAs string, rootFree bool) []string { - var out []string - for _, c := range declared { - if c == "verified-sender" && (runsAs == "" || !rootFree) { - continue - } - out = append(out, c) - } - return out -} - -func kindListed(list []KindHeld, k KindHeld) bool { - for _, x := range list { - if x.Seat == k.Seat && x.Kind == k.Kind && x.Module == k.Module && x.Node == k.Node { - return true - } - } - return false -} diff --git a/go/buscompose/modstreams.go b/go/buscompose/modstreams.go index 956d2ed..8505473 100644 --- a/go/buscompose/modstreams.go +++ b/go/buscompose/modstreams.go @@ -3,18 +3,8 @@ package buscompose import ( "fmt" "sort" - "strings" ) -// A module's own stream on the bus (novox/hq ADR 0363). -// -// A module declares a stream as it declares a log: named locally, created by the controller from the -// catalogue on every raise and every send, never removed by the mesh. Its events are published on -// `mesh.stream..event.`, a subject space the shared EVENTS stream (`mesh.mod.*.event.>`) -// does not take — the bus refuses two streams claiming one subject, so a module's stream cannot share -// EVENTS' — and a consumer hears it through a durable consumer of its own on that stream, named as on -// EVENTS. Pure; the controller's raise asserts what is composed here. - // moduleStreamPrefix starts every subject a module's own stream takes. const moduleStreamPrefix = "mesh.stream." @@ -24,20 +14,6 @@ func ModuleStreamSubject(module, event string) string { return moduleStreamPrefix + module + ".event." + event } -// OnAModuleStream says a subject is one a module's own stream takes. -func OnAModuleStream(subject string) bool { return strings.HasPrefix(subject, moduleStreamPrefix) } - -// WirePattern is a local event pattern as the bus spells it: `**` is `>`. -func WirePattern(local string) string { - parts := strings.Split(local, ".") - for i, p := range parts { - if p == "**" { - parts[i] = ">" - } - } - return strings.Join(parts, ".") -} - // A ModuleStream is one module's declared stream as the bus holds it. type ModuleStream struct { Module string @@ -120,33 +96,6 @@ type QueueOwner struct { MaxDeliver int } -// QueueConsumer is the durable consumer the one process a module's bus.queues names is granted on its own -// work-queue stream (novox/hq ADR 0436 §1, §2, §6): no filter, since it is the stream's only consumer — -// unlike heardConsumer, which is one of possibly several hearing a module's ordinary stream. -func QueueConsumer(p Principal, q QueueOwner) Consumer { - return Consumer{ - Name: p.Node + "_" + p.Module + "_" + q.Process, - Stream: q.Stream, - AckWaitSeconds: 30, - MaxDeliver: q.MaxDeliver, - Why: fmt.Sprintf("%s's process %s is the one consumer %s's bus.queues names for its work-queue "+ - "stream %s (novox/hq ADR 0436 §6); after max-deliver %d it gives a message up, and the "+ - "controller keeps it in %s, offered only drop", p.Module, q.Process, p.Module, q.Stream, - q.MaxDeliver, DeadLettersStream), - } -} - -// ModuleStreams is every declared module stream as the controller asserts it, in name order. -func ModuleStreams(declared []ModuleStream) []Stream { - sorted := append([]ModuleStream(nil), declared...) - sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name }) - out := make([]Stream, 0, len(sorted)) - for _, s := range sorted { - out = append(out, s.Stream()) - } - return out -} - // Heard is a module's stream another module hears, and the subjects it hears of it: its `consumes` // within the stream and, for a restricted stream, within what the provider's trusted config value grants it // (ADR 0363 §5). Composed only with at least one subject. @@ -158,186 +107,3 @@ type Heard struct { // is heard only by a module on an account of its own (ADR 0376 §1, ComposedHears). Restricted bool } - -// ComposedHears is what of a module's hearing the controller composes (novox/hq ADR 0376 §1): every stream it -// hears, but a restricted one only for a module that runs in a runtime of its own on a bus account of its own -// (`runs-as`, ADR 0259). **The tool runner never hears a restricted stream**: a module it carries reaches the bus -// through the tool runner's account, which every agent session on that machine reaches the bus as too, so a -// consumer, its grants or its bind word composed there would hand the stream to any session. A module that -// declares no `runs-as` and keeps its own container credential gets none either: only `runs-as` is an account -// the mesh keeps from agents. -func ComposedHears(d Declared) []Heard { - if d.RunsAs != "" { - return d.Hears - } - var out []Heard - for _, h := range d.Hears { - if !h.Restricted { - out = append(out, h) - } - } - return out -} - -// Withheld is a restricted stream a grant gives a module that the controller composes nothing for, because the -// module has no account of its own (novox/hq ADR 0376 §2): said in `doctor` and in the controller's log, never -// silent. -type Withheld struct { - Node, Module, Stream, Emitter string -} - -// Said is a withheld hearing in the operator's words: the module, the stream and the remedy. -func (w Withheld) Said() string { - return fmt.Sprintf("%s on %s is granted part of %s's restricted stream %s, and hears none of it: it is carried "+ - "by the machine's tool runner, whose bus account every agent session there shares, so nothing is composed "+ - "for it. Declare runs-as in %s's manifest, so it runs as an account of its own (novox/hq ADR 0376)", - w.Module, w.Node, w.Emitter, w.Stream, w.Module) -} - -// WithheldOf is every granted hearing of a restricted stream composed for nothing, by machine and module. -func WithheldOf(r Records) []Withheld { - var out []Withheld - for _, node := range sortedCopy(r.Nodes) { - for _, d := range r.Assigned[node] { - if d.RunsAs != "" { - continue - } - for _, h := range d.Hears { - if h.Restricted && len(h.Filters) > 0 { - out = append(out, Withheld{Node: node, Module: d.Module, Stream: h.Stream, Emitter: h.Emitter}) - } - } - } - } - return out -} - -// heardConsumer is the durable consumer a module hears a module's stream through: named as its consumer -// on EVENTS, on that stream, with the subjects it was granted as its filters. -func heardConsumer(p Principal, h Heard) Consumer { - filters := append([]string(nil), h.Filters...) - sort.Strings(filters) - return Consumer{ - Name: consumerDurable(p), - Stream: h.Stream, - Filters: filters, - AckWaitSeconds: 30, - MaxDeliver: 5, - // From now, like a module's consumer on EVENTS (novox/hq ADR 0443 §1). - FromNow: true, - Why: "what " + p.Module + " consumes of " + h.Emitter + "'s stream " + h.Stream + " and is granted " + - "(novox/hq ADR 0363); after max-deliver it gives an event up, and the controller keeps it in " + - "DEAD_LETTERS until a person delivers it again or drops it", - } -} - -// heardGrants are what a principal publishes to read and acknowledge the consumers it hears module streams -// through: asking about each, pulling from it, acknowledging it — its own name on each stream, no other's. -func heardGrants(p Principal, hears []Heard) []string { - var out []string - durable := consumerDurable(p) - for _, h := range hears { - if !safeSubject.MatchString(h.Stream) || len(h.Filters) == 0 { - continue - } - out = append(out, - "$JS.API.CONSUMER.INFO."+h.Stream+"."+durable, - "$JS.API.CONSUMER.MSG.NEXT."+h.Stream+"."+durable, - "$JS.ACK."+h.Stream+"."+durable+".>") - } - return out -} - -// ownStreamGrants are the subjects a module publishes its own streams' events on, and no other's. -func ownStreamGrants(module string, streams []ModuleStream) []string { - var out []string - for _, s := range streams { - if s.Module != module { - continue - } - out = append(out, s.Subjects()...) - } - return out -} - -// streamsIssuedFor is every stream of its own a module's membership names. -func streamsIssuedFor(d Declared) []StreamIssued { - var out []StreamIssued - for _, s := range d.Streams { - if s.Module != d.Module || !safeSubject.MatchString(d.Module) { - continue - } - out = append(out, StreamIssued{Stream: s.Name, Events: append([]string(nil), s.Events...), - Subject: ModuleStreamSubject(d.Module, "{event}")}) - } - return out -} - -// HeardIssuedFor is every module stream an assignment hears, as its membership names it. -func HeardIssuedFor(node string, d Declared) []HeardIssued { - var out []HeardIssued - // A restricted stream's bind word only for a module of its own account (novox/hq ADR 0376 §1). - for _, h := range ComposedHears(d) { - if len(h.Filters) == 0 { - continue - } - out = append(out, HeardIssued{Stream: h.Stream, Consumer: node + "_" + d.Module}) - } - sort.Slice(out, func(i, j int) bool { return out[i].Stream < out[j].Stream }) - return out -} - -// Pruner is the part of a JetStream connection that removes a module's consumer on a module's stream it -// may no longer hear (ADR 0363 §6). Optional: what only records what it is asked to assert has none. -type Pruner interface { - ConsumerNames(stream string) ([]string, error) - RemoveConsumer(stream, name string) error -} - -// PruneHeard removes, from each module stream, every durable consumer the composition no longer asks for, -// and answers what it removed as `/`. A consumer that loses its grant loses what it reads -// through, whatever its runtime still holds; a stream that does not exist yet has nothing to remove. -func PruneHeard(p Pruner, streams []ModuleStream, wanted []Consumer) ([]string, error) { - keep := map[string]bool{} - for _, c := range wanted { - keep[c.Stream+"/"+c.Name] = true - } - var removed []string - for _, s := range streams { - names, err := p.ConsumerNames(s.Name) - if err != nil { - return removed, fmt.Errorf("listing the consumers on %s: %w", s.Name, err) - } - for _, n := range names { - if keep[s.Name+"/"+n] { - continue - } - if err := p.RemoveConsumer(s.Name, n); err != nil { - return removed, fmt.Errorf("removing %s's consumer on %s, which is no longer granted: %w", n, s.Name, err) - } - removed = append(removed, s.Name+"/"+n) - } - } - return removed, nil -} - -// EnsureHeard asserts a consumer on a module's stream (ADR 0363 §5, §6). **On a restricted stream, a consumer -// the bus will not bring to match is removed**: a pull consumer delivers to whoever pulls it, so one left with -// its wider filters after a narrowing was refused would keep handing over what the grant no longer names, -// whatever the user list now says. The next assertion makes it again with the granted filters. The refusal is -// returned either way, saying what was done, so the send says it and raises it. -func EnsureHeard(r Raiser, c Consumer, restricted bool) error { - err := r.EnsureConsumer(c) - if err == nil || !restricted { - return err - } - p, prunes := r.(Pruner) - if !prunes { - return fmt.Errorf("%w; it could not be removed here, and may still deliver more than it is granted", err) - } - if rerr := p.RemoveConsumer(c.Stream, c.Name); rerr != nil { - return fmt.Errorf("%w; REMOVING IT FAILED TOO, so it may still deliver more than it is granted: %v", err, rerr) - } - return fmt.Errorf("%w; REMOVED, so it delivers nothing it is no longer granted: the next assertion makes it "+ - "again with its granted filters (novox/hq ADR 0363 §6)", err) -} diff --git a/go/buscompose/names_test.go b/go/buscompose/names_test.go new file mode 100644 index 0000000..dd76d2a --- /dev/null +++ b/go/buscompose/names_test.go @@ -0,0 +1,21 @@ +package buscompose + +import ( + "testing" + + "git.novox.be/novox/mesh-sdk/go/membership" +) + +// The names this package keeps for the grants part's composer (mesh-grants internal/buscompose, ADR 0593 §1) agree with +// what the runtime reads: the memberships bucket, and a log's stream and subject. +func TestTheBusNamesAgree(t *testing.T) { + if GrantsMembershipsBucket != membership.GrantsBucket { + t.Fatalf("the memberships bucket is %q here and %q in membership", GrantsMembershipsBucket, membership.GrantsBucket) + } + if MembershipKey("ace", "netcheck") != "ace.netcheck" { + t.Fatal("a membership's key is not .") + } + if LogStreamName("mesh-delivery-builder", "build-log") == "" || LogSubject("mesh-delivery-builder", "build-log") == "" { + t.Fatal("a log has no stream or subject") + } +} diff --git a/go/buscompose/nats.go b/go/buscompose/nats.go index 77c52e4..72cd670 100644 --- a/go/buscompose/nats.go +++ b/go/buscompose/nats.go @@ -13,16 +13,8 @@ package buscompose import ( - "errors" - "fmt" "regexp" - "slices" - "sort" - "strings" "time" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" ) // A Kind is what a principal is, which decides the shape of its authority rather than its @@ -54,13 +46,6 @@ const ( KindProcess Kind = "process" ) -// RuntimeModule is the module that IS the node's tool runtime (novox/hq ADR 0175). Where it is -// assigned, the mesh composes one runtime principal for the machine in place of that module's own, -// and the per-module containers that served tools until then stop being the way tools reach a node. -// Mirrored in the catalogue package, which the agreement test holds to the same string; one -// constant, so a rename is one edit and the two packages cannot drift. -const RuntimeModule = "node-tools" - // Seat is a role on the bus as a principal relates to it: the subjects it accepts, and those it // emits (novox/hq ADR 0118, design 29 §5). type Seat struct { @@ -257,138 +242,6 @@ type Principal struct { PasswordHash string } -// SeatVerb is one verb of one seat, on every machine holding it. -type SeatVerb struct{ Seat, Verb string } - -// VerbsTheSelfCheckAsks are the seat verbs the controller's self-check and watchdogs call (novox/hq -// to-be 45 §4): D8 reads every machine's ban list. **Named one by one, and the test that holds them -// to the probe registry is the reason they cannot drift** — a probe that calls a verb its grant does -// not name is refused by the bus on every run (found live on 2026-10-06: D8 timed out on each -// machine, refused). Asked of any machine (`.*`), read-only verbs, nothing else of the seat. -// -// D13 reads every machine's backup holder: what it measured of the data declared there (novox/hq ADR -// 0233). -var VerbsTheSelfCheckAsks = []SeatVerb{{Seat: "node-intrusion-prevention", Verb: "banned"}, - {Seat: "node-backup", Verb: "backed-up"}} - -// VerbsTheBusStepAsks are the seat verbs the bus's planned step calls (novox/hq to-be 45 §8, ADR 0236): -// the bus machine's backup holder takes the bus's snapshot now, before the bus is replaced (ADR 0235's -// dump), and says when it is taken (`backed-up`, granted with the self-check's). The one verb of -// the controller's grant that acts: through the step a person starts, and through the backups a delivery plan -// takes before it asks its first tier (novox/hq ADR 0404). -var VerbsTheBusStepAsks = []SeatVerb{{Seat: "node-backup", Verb: "now"}} - -// VerbsTheVaultAsksForASecret are the seat verbs the vault's holder calls for a secret typed at a desk (novox/hq ADR -// 0259 §10, ADR 0349 clause 9): the operator's desk opens a prompt that does not show what is typed, and answers it -// sealed to the vault's call. -var VerbsTheVaultAsksForASecret = []SeatVerb{{Seat: "node-launcher", Verb: "secret"}} - -// DeskOnly are the subjects the vault's holder alone may publish in its own name, however wide another's grant (the -// review of 2026-10-09, M4): the desk's hidden prompt, on its seat's subjects and on any holder's own module subjects. A -// grant of every tool — the runtime's, which carries every agent's calls, or a person's `*` — would otherwise reach it, -// and the prompt says the vault asks: only the bus makes that true. -func DeskOnly() []string { - var out []string - for _, v := range VerbsTheVaultAsksForASecret { - for _, base := range []string{"mesh.seat." + v.Seat + ".tool." + v.Verb, "mesh.mod.*.tool." + v.Seat + "." + v.Verb} { - out = append(out, base, base+".*") - } - // And where a call names its caller (novox/hq issue 365): any machine, any caller. - for _, base := range []string{"mesh.seat." + v.Seat + "." + CallKind + "." + v.Verb, "mesh.mod.*." + CallKind + "." + v.Seat + "." + v.Verb} { - out = append(out, base+".>") - } - } - return out -} - -// VerbsTheEnrolmentAsksTheVault are the vault's verbs the seat mesh-enrolment's holder alone calls (novox/hq ADR 0587 -// §1, §3; ADR 0561 §5): a token and its hash made and dropped, a joining node's account minted and retired. -var VerbsTheEnrolmentAsksTheVault = []SeatVerb{{Seat: VaultSeat, Verb: delivery.EnrolmentTokenVerb}, - {Seat: VaultSeat, Verb: delivery.NodeAccountVerb}} - -// EnrolmentOnly are the subjects the holder of mesh-enrolment alone may publish, however wide another's grant: the -// vault's two enrolment verbs, by the tool's and the caller-named shape (Principal.VaultEnrols denies them). -func EnrolmentOnly() []string { - var out []string - for _, v := range VerbsTheEnrolmentAsksTheVault { - out = append(out, "mesh.seat."+v.Seat+".tool."+v.Verb, "mesh.seat."+v.Seat+".tool."+v.Verb+".>", - "mesh.seat."+v.Seat+"."+CallKind+"."+v.Verb+".>") - } - return out -} - -// MayPublish says whether permissions let a principal publish one subject: an allow covers it and no deny does. -func MayPublish(perms Permissions, subject string) bool { - for _, d := range perms.PublishDeny { - if SubjectsOverlap(d, subject) { - return false - } - } - for _, a := range perms.Publish { - if SubjectsOverlap(a, subject) { - return true - } - } - return false -} - -// MaySubscribe says whether a principal with these permissions may subscribe to (and so answer) a subject. -func MaySubscribe(perms Permissions, subject string) bool { - for _, a := range perms.Subscribe { - if SubjectsOverlap(a, subject) { - return true - } - } - return false -} - -// VaultMoveIn is the vault's verb a machine's node-engine asks in its own name to hand in a value sealed to its -// machine (design 57's V3, issue 676); granted as Principal.MovesIntoTheVault says. -var VaultMoveIn = SeatVerb{Seat: "mesh-vault", Verb: "move-in"} - -// VaultNodeKey is the vault's verb a machine's node-engine asks in its own name to state its sealing key (issue 786, -// novox/hq ADR 0372); granted as Principal.StatesItsKeyToTheVault says. -var VaultNodeKey = SeatVerb{Seat: "mesh-vault", Verb: "node-key"} - -// VaultOwn is the vault's verb a machine's node-engine asks in its own name for its modules' own secrets sealed to its -// key (issue 1126, ADR 0550 §5); granted as Principal.AsksItsOwnSecrets says. -var VaultOwn = SeatVerb{Seat: "mesh-vault", Verb: "own"} - -// VaultSign is the vault's verb a machine's node-engine asks in its own name for its own node's certificate (novox/hq -// ADR 0550 §2); granted as Principal.AsksTheVaultToSign says. -var VaultSign = SeatVerb{Seat: "mesh-vault", Verb: delivery.SignVerb} - -// VaultRotateNode is the vault's verb that begins a node's account rotation; while its holder serves it, every node -// principal reads its own node's key of node-logins (novox/hq ADR 0583 §1, §2). VaultNodeAccountConfirm is the verb a -// node-engine confirms a switch with, in its new login's name (§3). -var ( - VaultRotateNode = SeatVerb{Seat: "mesh-vault", Verb: delivery.RotateNodeVerb} - VaultNodeAccountConfirm = SeatVerb{Seat: "mesh-vault", Verb: delivery.NodeAccountConfirmVerb} -) - -// EnrolmentRedeem is the seat mesh-enrolment's verb a joining node-engine calls as enrol. (novox/hq ADR 0587 §4, -// §6). -var EnrolmentRedeem = SeatVerb{Seat: catalogue.EnrolmentSeat, Verb: catalogue.EnrolmentVerbRedeem} - -// EnrolmentRedeemCall is the one subject an enrolment user may publish once it enrols through the seat: redeem, in its -// own name. -func EnrolmentRedeemCall(p Principal) string { - return "mesh.seat." + EnrolmentRedeem.Seat + "." + CallKind + "." + EnrolmentRedeem.Verb + "." + CallerToken(p.Username()) -} - -// NodeLoginsReads is what a node's engine is granted of the vault's node-logins: its own node's key, read and watched, -// and no other (novox/hq ADR 0583 §1 insight). -func NodeLoginsReads(node string) []string { - if !safeSubject.MatchString(node) { - return nil - } - return readGrants(delivery.NodeLoginsBucket, node) -} - -// perMachineEvents are a node-scoped seat's events about the holder itself, whose last token is the -// holder's machine (novox/hq ADR 0219): `paused.`, the build agent saying whether it takes work. -var perMachineEvents = map[string]bool{"paused.*": true} - // enrolmentPrefix is the space every enrolling node's user and inbox live under, so the one place the // controller may answer an enrolment is derived from the same constant the user is named from. const enrolmentPrefix = "enrol" @@ -438,40 +291,6 @@ func (p Principal) Username() string { return "" } -// AskReportSubject is where the mesh asks one machine's node-engine to say again what it last applied -// (novox/hq to-be 45 §6): its answer is an ordinary report, on its own report subject. -func AskReportSubject(node string) string { return "mesh.node." + node + ".ask.report" } - -// AskHandOverSubject is where the controller's terminal asks one machine's node-engine to hand a directory it -// uses as found to the mesh (novox/hq issue 356, issue 339): a request on core NATS, answered once on the reply -// it carries. Only the controller is granted a publish here (the writers table holds it), but **that is not who -// the engine hears**: the bus lets any principal allowed to answer reply to a message it received, on the reply -// subject that message named, so a message can arrive here from any responder. The ask is therefore signed with -// the mesh's key (link.SignedHandOver), and the engine verifies it before reading anything out of it. -func AskHandOverSubject(node string) string { return "mesh.node." + node + ".ask.hand-over" } - -// AskSetuidSearchSubject is where the controller's terminal asks one machine's node-engine to throw its last -// search for setuid programs away and start a full one (novox/hq issue 361): a request answered once, signed as -// a hand-over is (link.SetuidSearchContext), for the same reason. -func AskSetuidSearchSubject(node string) string { return "mesh.node." + node + ".ask.setuid-search" } - -// AskJudgeDirectoriesSubject is where the controller asks one machine's node-engine to judge, read-only, which of -// its declared directories the walk that follows no link another account could make would refuse (novox/hq issue -// 358): a request answered once, signed as a hand-over is (link.JudgeDirectoriesContext), for the same reason. -func AskJudgeDirectoriesSubject(node string) string { - return "mesh.node." + node + ".ask.judge-directories" -} - -// AskMoveSecretSubject is where the controller asks one machine's node-engine to move one value sealed to it into -// the vault (design 57's V3, issue 676): a request answered once, signed as a hand-over is (link.MoveSecretContext), -// for the same reason. -func AskMoveSecretSubject(node string) string { return "mesh.node." + node + ".ask.move-secret" } - -// AskAccessActSubject is where the controller asks one machine's node-engine to run an act on one access's folder (novox/hq -// ADR 0328 §7): a request answered once, signed as a hand-over is (the node-engine's link.AccessActContext). The -// node-engine has subscribed to it since ADR 0325; the grant was missing, so each connect logged a permissions violation. -func AskAccessActSubject(node string) string { return "mesh.node." + node + ".ask.access-act" } - // inbox is a principal's own reply space. No user is ever granted a bare `_INBOX.>` (design 25 // §4): with one account, inbox privacy is the permission list or it is nothing, so each user's // inbox is derived from its own identity and its permissions name that prefix and no other. @@ -484,25 +303,6 @@ func (p Principal) inbox() string { return "_INBOX." + p.Username() + ".>" } -// Permissions is what a principal may publish and subscribe, and whether it may answer. -type Permissions struct { - Publish []string - // PublishDeny are subjects refused although an allow covers them: the vault's holder's alone (DeskOnly), - // denied to everybody whose grant is wide enough to reach them. The server's deny outranks its allow. - PublishDeny []string - Subscribe []string - // AllowResponses lets a principal reply to a request it received, on the reply subject that - // request carried, once. - // - // **This is what makes scoped inboxes possible at all**, and design 25 §4 did not say it. If - // every user's inbox is private to it, a module serving a tool cannot publish the answer — - // the answer goes to the *caller's* inbox, which the responder has no permission for. The two - // ways out are granting responders `_INBOX.>`, which is precisely the blanket grant §4 - // refuses, or this: the server itself permits one reply to the subject of a message the user - // actually received, and nothing else. The authority is bounded by having been asked. - AllowResponses bool -} - // ResponseTTL is how long the bus lets a principal answer a request it received. Its one answer has // to come inside this, and a seat's holder answers within link.AnswerWithin — inside it by design. // **A broker reloading its user list forgets every answer it was about to permit**, whatever this @@ -510,933 +310,6 @@ type Permissions struct { // refused, which is why a holder answers before it does what can reload it. const ResponseTTL = time.Minute -// BusSnapshotGrants is the whole authority of the bus's own module (novox/hq ADR 0235): the -// snapshot API and what it needs, and nothing that changes a stream. -// -// **Read-only, and the writers table proves it**: none of these overlaps a subject a write of the -// mesh's state is a publish to — not a stream's definition, not a bucket, not a message. A snapshot is -// the server reading its own blocks out to the asker, while it goes on taking writes; it neither -// pauses nor reconfigures the stream. Named one by one rather than `$JS.API.>`, which would be the -// controller's authority over every stream: -// -// - the stream names, and one stream's information (whether it is on disk at all — a memory stream -// cannot be snapshotted and is said as skipped); -// - the snapshot request itself, for any stream: the archive comes to the asker's own inbox; -// - the acknowledgement the server waits for past its window: a publish to the subject the server -// put on each chunk, `$JS.SNAPSHOT.ACK....`, which only the server's -// own internal subscription hears. -// -// Restoring is not here: a restore creates a stream, which is the controller's to define, and the -// mesh restores into a new store beside the live one, swapped in by a person (to-be 43). -var BusSnapshotGrants = struct{ Publish []string }{Publish: []string{ - "$JS.API.STREAM.NAMES", - "$JS.API.STREAM.INFO.*", - "$JS.API.STREAM.SNAPSHOT.*", - "$JS.SNAPSHOT.ACK.>", -}} - -// PermissionsFor derives a principal's authority. Pure, and the only place authority is decided: -// a permission that cannot be derived from a declaration is a permission nobody can explain. -func PermissionsFor(p Principal) (Permissions, error) { - for _, part := range []struct{ what, value string }{ - {"node", p.Node}, {"module", p.Module}, {"process", p.Process}, - } { - if part.value == "" { - continue - } - if !safeSubject.MatchString(part.value) { - return Permissions{}, fmt.Errorf( - "%q cannot be part of a subject: a permission is a subject pattern, and this would widen it", part.value) - } - } - - if p.Kind == KindModule && p.SnapshotsTheBus { - pub := append([]string(nil), BusSnapshotGrants.Publish...) - sort.Strings(pub) - if err := CheckWriters(p, pub); err != nil { - return Permissions{}, err - } - // Its own inbox for the answers and the archive's chunks, and nothing else: nothing is asked - // of it, so it answers nothing. - return Permissions{Publish: pub, Subscribe: []string{p.inbox()}}, nil - } - - if p.Kind == KindProcess && len(p.Removes) > 0 { - pub, err := BusRemovalGrants(p.Removes, p.FilesFor) - if err != nil { - return Permissions{}, err - } - sort.Strings(pub) - if err := CheckWriters(p, pub); err != nil { - return Permissions{}, err - } - // Its own inbox for the answers, and nothing else (Limits 4). - return Permissions{Publish: pub, Subscribe: []string{p.inbox()}}, nil - } - - var pub, sub []string - switch p.Kind { - case KindProvisioner: - var err error - if pub, sub, err = provisionerGrants(p); err != nil { - return Permissions{}, err - } - - case KindProcess: - var err error - if pub, sub, err = processGrants(p); err != nil { - return Permissions{}, err - } - - case KindPerson: - // Tools, and nothing else. Every subject a person may publish is a tool call; a person - // who could publish an event would be able to claim a module said something. - invoked, err := invokedSubjects(p.Invokes) - if err != nil { - return Permissions{}, err - } - pub = append(pub, invoked...) - // And may ask what answers (novox/hq ADR 0197): a question every service answers about - // itself, its replies to the asker's own inbox. - pub = append(pub, discovering()...) - - case KindEnrolment: - // A leaked token is useless for anything but enrolling: it cannot read a declaration, hear - // an event, or subscribe any inbox but the one its own token derives (design 25 §6). - // - // **The inbox was missing and the handshake could not have completed without it.** An - // enrolling node publishes its request and waits on an address it states in the payload; - // with nothing to subscribe it waits out its timeout against a mesh that answered. Its own - // and no wider: `_INBOX.enrol..>`, so what is sealed to one machine cannot be read by - // another enrolling beside it. - if p.Node == "" { - // Refused rather than composed into `_INBOX.enrol..>`, which is a subject with an empty - // token in it — and worse, one every nameless enrolment user would share. A shared - // enrolment inbox is one machine able to read the credentials sealed to another. - return Permissions{}, errors.New( - "an enrolment user names no node, so its inbox would be shared with every other " + - "enrolment: a token is issued for a node record, and that record's name is " + - "the token's id") - } - // **A node enrols through the seat mesh-enrolment alone** (novox/hq ADR 0587 §4, §6, ADR 0590): its user calls - // redeem in its own name, while the seat's holder serves it. - if !p.Redeems { - return Permissions{}, fmt.Errorf("the enrolment user of %s may enrol nowhere: no holder of %s serves %s", - p.Node, EnrolmentRedeem.Seat, EnrolmentRedeem.Verb) - } - pub = []string{EnrolmentRedeemCall(p)} - sub = []string{p.inbox()} - - case KindNode: - // A host publishes its own node's control traffic and subscribes its own declaration — - // and nothing of any other node's. - // And binding to its consumer, which asks the server about it (CONSUMER.INFO) — the one - // thing the host does that nothing granted. Found the first time a machine dialled a - // permissioned server: "this node cannot read its declarations" (2026-09-28). The ack and - // the inbox are granted below with every principal's. - pub = []string{ - "mesh.control." + p.Node + ".>", - "$JS.API.CONSUMER.INFO.NODES." + p.Node, - } - // The deliver subject carries the stream as well as the consumer's name, so what a - // subscriber is permitted has to carry it too (novox/hq 04-ISSUES/146). The bare name - // stays: an existing consumer keeps delivering where it always did until the controller's - // next assertion moves it, and a permission that only allowed the new shape would refuse - // every node in the mesh for exactly as long as that took. - sub = []string{"mesh.node." + p.Node + ".declare", - "_DELIVER." + p.Node, "_DELIVER." + p.Node + ".>", - // And the mesh asking it to say again what it last applied (novox/hq to-be 45 §6, the - // `report` verb healer H1 asks): its own machine's, on core NATS and off any stream. It - // answers through its report, the one thing it already says — no reply to anybody's inbox. - AskReportSubject(p.Node), - // And the controller's terminal asking it to hand a directory used as found to the mesh (novox/hq - // issue 356), which it answers on the request's reply: the one request a node is asked. - AskHandOverSubject(p.Node), - // And asking it for a fresh search for setuid programs (novox/hq issue 361), answered the same way. - AskSetuidSearchSubject(p.Node), - // And asking it to judge its declared directories, read-only (novox/hq issue 358), answered the same way. - AskJudgeDirectoriesSubject(p.Node), - // And asking it to move a value sealed to it into the vault (design 57's V3, issue 676), answered the - // same way. - AskMoveSecretSubject(p.Node), - // And asking it to run an act on an access's folder (novox/hq ADR 0328 §7), answered the same way. - AskAccessActSubject(p.Node)} - // The node-engine witnesses the core builds it places (novox/hq to-be 45 §8, ADR 0236; the - // contract is lease/witness.go): it asks its own machine's node tools PING, and, where the - // machine runs the controller, reads the lease's one key — read, never written. - pub = append(pub, WitnessSubjects(p)...) - // And the keys of its own node in the have bucket, which it alone writes (novox/hq ADR 0429 §1, issue 1136): - // what the node has of each module, read by the view and the watchman. Never the controller's. - haveKeys, err := NodeKeysGrants(delivery.HaveBucket, p.Node) - if err != nil { - return Permissions{}, err - } - pub = append(pub, haveKeys...) - // And every node's own facts, `.node`, its record among them (issue 1126, ADR 0555 §3): a node agent composing - // its own modules reads the other nodes' public records, which its firewall and resolver need (ADR 0547 §2). Read - // only: each node still writes its own keys alone. - pub = append(pub, readGrants(delivery.HaveBucket, "*."+delivery.HaveNodeWord)...) - // And what it composes its own modules from (ADR 0547 §2, ADR 0567 §9): the seat mesh-placement's placement and - // candidates, the indexer's modules and manifests, the builder's builds (the manifest of a build its want names), and mesh-config's resolved values of - // its own node alone. Read only. - pub = append(pub, NodeComposeReads(p.Node)...) - // And, of the other nodes' resolved values, the keys their modules serve from (issue 1126), each one key. - served, err := ServedReadGrants(p.ServedReads) - if err != nil { - return Permissions{}, err - } - pub = append(pub, served...) - // And an object of the declaration bucket by its name, and nothing of the bucket's list or writing - // (novox/hq ADR 0486 §2, issue 1697). - fileReads, err := DeclarationFilesReads(p.Node) - if err != nil { - return Permissions{}, err - } - pub = append(pub, fileReads...) - // And its own compositions and wants, which it follows (novox/hq ADR 0539 clauses 1, 2 and 9), and nothing of another - // node's. - composedReads, err := ComposedReads(p.Node) - if err != nil { - return Permissions{}, err - } - pub = append(pub, composedReads...) - // And its own machine's backup holder, before a data module moves (ADR 0539 clause 16). - backups, err := NodeAgentAsks(p.Node) - if err != nil { - return Permissions{}, err - } - pub = append(pub, backups...) - // And the backup holder's word that it backed the module up (ADR 0539 clause 16), whichever module holds - // node-backup: one event, by its name. - sub = append(sub, NodeAgentHears...) - // And the creation of the seat mesh-placement's two states it composes from (ADR 0567 §9), so a node agent started - // before they exist binds each when the bus says it was made, with no timer; no other bucket's. - sub = append(sub, NodeComposeAdvisories()...) - // And, while the vault serves it, the vault's move-in in its own name and nobody else's (issue 676): the - // values sealed to this machine, which only it opens, handed in sealed to the vault's key for the call. - if p.MovesIntoTheVault { - pub = append(pub, "mesh.seat."+VaultMoveIn.Seat+"."+CallKind+"."+VaultMoveIn.Verb+"."+CallerToken(p.Username())) - } - // And, while the vault serves it, its node-key in its own name (issue 786): the machine's sealing key, stated - // by the engine that holds it, which the vault takes from no other caller. - if p.StatesItsKeyToTheVault { - pub = append(pub, "mesh.seat."+VaultNodeKey.Seat+"."+CallKind+"."+VaultNodeKey.Verb+"."+CallerToken(p.Username())) - } - // And, while the vault serves it, its modules' own secrets in its own name (issue 1126): sealed to its key, for the - // compositions it makes itself. - if p.AsksItsOwnSecrets { - pub = append(pub, VaultOwnCall(p)) - // And the vault's word that a secret changed (names and generations, never a value), so the node composes again - // on it rather than at the next unrelated change (ADR 0547 §2). - sub = append(sub, VaultSecretChanged) - } - // And, while the vault serves `sign`, its own node's certificate in its own name, and the vault's public signing - // halves, read and watched (ADR 0550 §2). Read only. - if p.AsksTheVaultToSign { - pub = append(pub, VaultSignCall(p)) - pub = append(pub, readGrants(delivery.SignersBucket, "")...) - } - // And, while the vault rotates node accounts, its own node's key of node-logins, read and watched, and no other - // key (novox/hq ADR 0583 §1 insight): the login it dials as; and the confirmation of a switch in its own name (§3). - if p.RotatesItsLogin { - pub = append(pub, NodeLoginsReads(p.Node)...) - pub = append(pub, "mesh.seat."+VaultNodeAccountConfirm.Seat+"."+CallKind+"."+VaultNodeAccountConfirm.Verb+"."+ - CallerToken(p.Username())) - } - // And its node seat, node-engine, and its asks to the operator in its own name (novox/hq ADR 0563 §1, §3). - ep, es := engineGrants(p) - pub = append(pub, ep...) - sub = append(sub, es...) - // And, on the node holding mesh-broker, the grants part's sealed user list it follows from take-over (novox/hq ADR - // 0547 clause 7): one key, and an object by its name. - if p.FollowsGrantsUserList { - pub = append(pub, GrantsUserListFollows()...) - } - - case KindModule: - // 0. The watchman alone hears who connects and disconnects, and asks who is connected (ADR 0431 §2). - 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..`, reading the last one of each - // first so it publishes only what changed, and no stream it defines. - if p.Module == GrantsModule { - pub = append(pub, "mesh.assignment.*.*", "$JS.API.DIRECT.GET."+AssignmentsStream+".mesh.assignment.*.*") - // And creating each stream a manifest declares, by its own name (the operator, 2026-10-26 08:54; issue - // 1790): the create and the read of that one stream, never an update, a delete or a stream not declared. - pub = append(pub, CreateStreamGrants(p.CreatesStreams)...) - // And creating each durable consumer an account needs, by its stream and name (ADR 0561 §6). - pub = append(pub, CreateConsumerGrants(p.CreatesConsumers)...) - // And its own object bucket, where it writes the user list sealed to the bus node (ADR 0547 clause 7). - pub = append(pub, GrantsUserListWrites()...) - } - // The view alone reads the build seats' workers for the backlog of asks (issue 1887 A8a). - if p.Module == ViewModule { - pub = append(pub, BuildBacklogReads()...) - } - // The builder alone reads the build seats' streams by sequence, to put again at its start an ask a key names that no - // stream holds (issue 1910). - if p.Module == BuilderModule { - pub = append(pub, BuildAskReads()...) - } - // 1. Its own namespace: it publishes its events there and serves its tools there. Nothing - // else may publish into it, so an event's source is a fact the server enforces rather - // than a claim in the body (design 29 §2). - own := "mesh.mod." + p.Module - for _, e := range p.Emits { - pub = append(pub, own+".event."+e) - } - // And its own streams' events, on their own subjects (novox/hq ADR 0363 §2). - pub = append(pub, ownStreamGrants(p.Module, p.Streams)...) - // Every tool under its own name, not a list: the tools a module serves are what its code - // answers, and a second copy of that list in the manifest would be a second source of - // truth for the mesh to keep in step (2026-09-28: every module that served a tool was - // refused the subscription, because none had written the list twice). Nothing is given - // away — no other principal may subscribe this namespace, and a caller's authority is - // still granted per tool, by name, on the publish side. - sub = append(sub, own+".tool.>") - // It says what it serves (novox/hq ADR 0197): discovery for its own name and every seat it - // holds a verb of, answered by the runtime that serves them. - announced := []string{p.Module} - for _, s := range p.Holds { - if len(s.Serves) > 0 { - announced = append(announced, s.Name) - } - } - sub = append(sub, announcing(announced...)...) - // Its own membership (ADR 0160), from the grants part's state memberships, its own key (novox/hq ADR 0571 clause - // 7): its one read of it, since the assignments stream's direct get went with the stream (research 072 row 33), - // and no longer heard on its subject, where the grants part stops publishing at the lease write (ADR 0571 clause - // 10, issue 774). - pub = append(pub, readGrants(GrantsMembershipsBucket, MembershipKey(p.Node, p.Module))...) - - // 1b. The tools it calls, if its manifest says it calls any (novox/hq ADR 0152). The same - // grant a person gets and derived the same way, so "what may this module ask" is - // answered by the one list that answers it for everybody. Publish only: an answer - // arrives on its own inbox, which every principal has below. - invoked, err := invokedSubjects(p.Invokes) - if err != nil { - return Permissions{}, err - } - pub = append(pub, invoked...) - - // 2. What it consumes, by the emitter's own subject — an event is addressed to its - // emitter, because the emitter's identity is the meaning (ADR 0118). - for _, c := range p.Consumes { - subject, err := consumedSubject(c) - if err != nil { - return Permissions{}, err - } - sub = append(sub, subject) - } - - // 2a. What it hears of a module's own stream, as it was granted (novox/hq ADR 0363 §5): the subjects, - // and its own consumer on that stream, asked about, pulled from and acknowledged. - for _, h := range p.Hears { - if len(h.Filters) == 0 { - continue - } - sub = append(sub, h.Filters...) - } - pub = append(pub, heardGrants(p, p.Hears)...) - - // 2b. Events of a role it watches, under the seat's own namespace. Subscribe only: watching a - // role is hearing what it announced, not taking part in it. - for _, w := range p.Watches { - if w.Kinded { - continue // composed by SeatTrafficOf below - } - for _, e := range w.Emits { - sub = append(sub, seatSubject(w, "event", e)) - } - } - - // 2c. Its own consumer, which it **pulls**: the runtime asks for the next message and is - // answered on its own inbox, so what it needs is to ask about the consumer and to ask it - // for messages — its own consumer's name, and no other's. Pulled rather than pushed - // because that is the one shape a runtime's client binds without creating anything; the - // controller and the hosts are pushed to. Named here rather than through ConsumerFor, - // which asks for these permissions to build the consumer and would ask forever. A - // subject for a consumer that turns out not to exist grants nothing anybody can use. - pub = append(pub, - "$JS.API.CONSUMER.INFO."+consumerStream(p)+"."+consumerDurable(p), - "$JS.API.CONSUMER.MSG.NEXT."+consumerStream(p)+"."+consumerDurable(p)) - - // 3. Seats it holds: full participation. - for _, s := range p.Holds { - if s.isNewTraffic() { - // Composed by SeatTrafficOf below, worker and all; only its tools are served here. - for _, t := range s.Serves { - sub = append(sub, seatToolSubject(s, t, p.Node)) - } - continue - } - // Taking work from the role's queue: the worker consumer every holder shares (asked - // about, pulled from, acknowledged), on the seat's own stream (novox/hq ADR 0190). A - // holder pulls — asks the consumer for its next message, answered on its own inbox — - // so what it needs is MSG.NEXT on that worker and nothing delivered to it. The first - // machine to take work over the new bus was refused the asking (2026-09-28). - worker := "SEAT_" + upperSnake(s.Name) + "_worker" - stream := seatStreamName(s.Name) - pub = append(pub, - "$JS.API.CONSUMER.INFO."+stream+"."+worker, - "$JS.API.CONSUMER.MSG.NEXT."+stream+"."+worker, - "$JS.ACK."+stream+"."+worker+".>") - for _, a := range s.Accepts { - sub = append(sub, seatSubject(s, "accept", a)) - } - for _, e := range s.Emits { - // **A machine says its own state and no other's** (novox/hq ADR 0219): on a node-scoped - // seat, an event about the holder itself carries the machine as its last token, and - // each holder is granted its own machine's alone. - if s.Scope == "node" && p.Node != "" && perMachineEvents[e] { - pub = append(pub, seatSubject(s, "event", strings.TrimSuffix(e, "*")+p.Node)) - continue - } - pub = append(pub, seatSubject(s, "event", e)) - } - for _, t := range s.Serves { - sub = append(sub, seatToolSubject(s, t, p.Node)) - } - } - - // 4. Seats it uses: publish only, and only the accepts half. A caller cannot subscribe a - // seat's inbound subject and watch other modules' traffic, nor publish its outbound - // events and lie about outcomes (design 29 §2). - for _, s := range p.Uses { - for _, a := range plainVerbs(s, s.Accepts) { - pub = append(pub, seatSubject(s, "accept", a)) - } - for _, t := range s.Serves { - pub = append(pub, seatToolSubject(s, t, "*")) - } - } - - // 5. Its state, and the state of others it reads (novox/hq ADR 0201): every one read and - // watched, its own written too. - kept := stateAccess{Module: p.Module, Node: p.Node, Keeps: p.State, PerMachine: p.PerMachine, Reads: p.Reads, - KeyedReads: p.KeyedReads} - pub = append(pub, stateGrants(kept)...) - // And it hears each of those buckets created, so one not on the bus at its start is bound when it appears - // (novox/hq issue 1790; mesh-sdk process.AwaitBucket). - 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). - tp, ts := seatTrafficGrants(SeatTrafficOf(p.Module, p.Holds, p.Uses, p.Watches)) - pub = append(pub, tp...) - sub = append(sub, ts...) - - case KindNodeTools: - // **One process serves what every module on the machine would have served for itself** - // (novox/hq ADR 0175). Each carried module's whole tool namespace — the same grant that - // module's own principal has, for the same reason: the tools a module serves are what its - // code answers, and a list here would be a second copy of it. Each held seat's verbs on - // this node, as the holder's own principal would be granted them. - var serves []string - for _, d := range p.Carries { - if !safeSubject.MatchString(d.Module) { - return Permissions{}, fmt.Errorf( - "%q cannot be part of a subject: a permission is a subject pattern, and this would widen it", d.Module) - } - serves = append(serves, d.Module) - for _, s := range d.Holds { - serves = append(serves, s.Name) - } - own := "mesh.mod." + d.Module - sub = append(sub, own+".tool.>") - // A tool that emits an event is the module's code and emits under the module's name - // (ADR 0042); the runtime carrying that code may publish what the module declared it - // emits, and nothing it did not. - for _, e := range d.Emits { - pub = append(pub, own+".event."+e) - } - // And its own streams' events (novox/hq ADR 0363 §2). - pub = append(pub, ownStreamGrants(d.Module, d.Streams)...) - for _, s := range d.Holds { - for _, t := range s.Serves { - sub = append(sub, seatToolSubject(s, t, p.Node)) - } - } - } - // Every assigned module's membership on this node (ADR 0160): one per module, read and watched from the - // grants part's state memberships (ADR 0571 clause 7), and no longer heard on `mesh.assignment`, where the grants - // part stops publishing at the lease write (ADR 0571 clause 10, issue 774). This node's and no other's — the - // one token that varies is the module, so the pattern is the machine's own assignments. - pub = append(pub, readGrants(GrantsMembershipsBucket, p.Node+".*")...) - // And that it is there (novox/hq to-be 45 §3, S11): its own heartbeat, under its machine's - // name and no other's, on core NATS like the host's. - pub = append(pub, "mesh.control."+p.Node+".tools-alive") - // And every tool on the mesh (ADR 0175, decision 5): any node may call any tool on any - // node, as the console already could — the runtime is the console's serving mode. - invoked, err := invokedSubjects([]string{"*"}) - if err != nil { - return Permissions{}, err - } - pub = append(pub, invoked...) - // And its own machine's provisioner client, for a carried module's config and non-secret bound values, - // naming the module from the bundle that asks (novox/hq ADR 0370 §3, §4): the ask on its own machine's - // subject alone, and what the client says moved, so a module's code's watches are asked again. - pub = append(pub, ProvisionerAskSubject(p.Node)) - sub = append(sub, "prov.changed."+p.Node+".>") - // It says what it serves and may ask what answers (novox/hq ADR 0197): the runtime answers - // discovery for each module and seat it carries, and the console it is asks the bus. - // One service per runtime process, named for the runtime: the bus lets a principal answer each - // request once, so the runtime announces everything it carries under its own name. - sub = append(sub, announcing(append([]string{RuntimeModule}, serves...)...)...) - pub = append(pub, discovering()...) - // **And it consumes for the modules it carries** (novox/hq ADR 0198, which changes ADR 0175's - // "it consumes nothing"): a module's long-running code is a bundle this runtime launches, and - // the runtime is its bus — it reads the module's own durable consumer and acknowledges what - // the module's code took. Exactly the grants the module's own principal has for that consumer, - // on its name and no other's: asking about it, pulling from it, acknowledging it. The - // consumer is still the controller's to make, from the module's own principal. - for _, d := range p.Carries { - own := Principal{Kind: KindModule, Node: p.Node, Module: d.Module, Emits: d.Emits, - Consumes: d.Consumes, Serves: d.Serves, Holds: d.Holds, Uses: d.Uses, Watches: d.Watches, - Streams: d.Streams, Hears: ComposedHears(d)} - // And the module streams it hears, each through its own consumer there (novox/hq ADR 0363 §8) — never - // a restricted one, which only a module on an account of its own hears (ADR 0376 §1). - pub = append(pub, heardGrants(own, own.Hears)...) - if _, consumes := ConsumerFor(own); !consumes { - continue - } - stream, durable := consumerStream(own), consumerDurable(own) - pub = append(pub, - "$JS.API.CONSUMER.INFO."+stream+"."+durable, - "$JS.API.CONSUMER.MSG.NEXT."+stream+"."+durable, - "$JS.ACK."+stream+"."+durable+".>") - } - // **And it keeps and reads state for the modules it carries** (novox/hq ADR 0201): the union - // of what each may do with a bucket — an owner's write, a reader's read. That one module's code - // does not write another's bucket through it is the runtime's to keep, from the membership - // each assignment is issued, as it keeps each module's events under that module's own name. - for _, d := range p.Carries { - pub = append(pub, stateGrants(stateAccess{Module: d.Module, Node: p.Node, Keeps: stateNames(d.State), - PerMachine: perMachineNames(d.State), Reads: d.Reads, KeyedReads: d.KeyedReads})...) - } - // **And it keeps the logs of the modules it carries** (novox/hq ADR 0297): each module's own, the - // union over them. That one module's code does not append to another's log through it is the - // 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 - // call or setting adds one. - waits, err := waitGrants(p.Carries) - if err != nil { - return Permissions{}, err - } - sub = append(sub, waits...) - // **Never the traffic of a trusted holder** (novox/hq ADR 0259 §8): the machine's runtime runs as the - // operator's account, which every agent runs as, so a module saying warrants or speaking for a kind - // that proves its sender is never composed into it — refused here, naming it, whatever registration - // let through. - for _, d := range p.Carries { - if why := trustedTraffic(d); why != "" { - return Permissions{}, fmt.Errorf("%s on %s is carried by the machine's runtime, and %s: it runs "+ - "as an account of its own, never the runtime's (novox/hq ADR 0259)", d.Module, p.Node, why) - } - } - // **And the seat traffic of the modules it carries** (novox/hq ADR 0259 §3): a bundle reaches the - // bus only through its runtime, so the runtime is granted the union. That one module's code does - // not publish under another's name or kind through it is the runtime's to keep, from the seat - // traffic each module's membership lists. - for _, d := range p.Carries { - tp, ts := seatTrafficGrants(SeatTrafficOf(d.Module, d.Holds, d.Uses, d.Watches)) - pub = append(pub, tp...) - sub = append(sub, ts...) - } - sub = unique(sub) - pub = unique(pub) - } - - if p.Kind == KindPerson { - // An inbox to hear answers in, and nothing else. No ack subject: a person has no durable - // consumer, because nothing is delivered to a person — they ask and are answered. - sub = append(sub, p.inbox()) - } - if p.Kind == KindNodeTools { - // Its reply space, so the answers to what its tools call come back to it. No ack subject - // for the same reason a person has none: nothing is delivered to it. - sub = append(sub, p.inbox()) - } - if p.Kind == KindProcess { - // Its own reply space, and nothing wider (novox/hq ADR 0436 §1, §3): a process's queue consumer is - // pulled and acknowledged by publishing, granted above with its own stream and consumer name, never - // a generic EVENTS consumer's — consumerStream/consumerDurable below are a module's own, not a - // process's. - sub = append(sub, p.inbox()) - } - - if p.Kind == KindModule || p.Kind == KindNode { - // Its own reply space, and nothing wider. - sub = append(sub, p.inbox()) - - // Acking a JetStream delivery is a publish to that consumer's own ack address — a - // different subject from anything the consumer subscribes. Without it every message a - // module received would be redelivered forever, refused by the permission list it already - // has (design 25 §4). Scoped to this principal's own consumer name, so it can ack its own - // deliveries and no other's. - pub = append(pub, "$JS.ACK."+consumerStream(p)+"."+consumerDurable(p)+".>") - } - - // **And every tool grant again, naming its caller** (novox/hq issue 365): a call in this principal's own - // name, and an answer to a call naming anybody. The `tool` grants above stay beside these, so callers and runtimes - // move without a gap; their retirement is hq issue 464. - // The vault's calls in its own name (issue 723), kept apart so the desk's grant is not denied below. - var vaultsOwn []string - pub, sub = callerNamed(p.Username(), pub, sub) - if p.Kind == KindModule { - vaultsOwn = vaultCalls(p) - pub = append(pub, vaultsOwn...) - } - - sort.Strings(pub) - sort.Strings(sub) - // One writer per piece of state (novox/hq to-be 45 §1): a grant that would make a second is - // refused here, at composition, naming the state and its writer. - if err := CheckWriters(p, pub); err != nil { - return Permissions{}, err - } - // What the vault's holder alone may publish is denied to everybody else whose grant reaches it. - var deny []string - for _, only := range DeskOnly() { - for _, a := range pub { - if slices.Contains(vaultsOwn, a) { - continue - } - if SubjectsOverlap(a, only) { - deny = append(deny, only) - break - } - } - } - // And the vault's enrolment verbs to every principal but mesh-enrolment's (novox/hq ADR 0587 §1). - if p.VaultEnrols && !((p.Kind == KindModule || p.Kind == KindProcess) && p.Module == delivery.EnrolmentModule) { - for _, only := range EnrolmentOnly() { - if slices.ContainsFunc(pub, func(a string) bool { return SubjectsOverlap(a, only) }) { - deny = append(deny, only) - } - } - } - return Permissions{ - Publish: pub, - PublishDeny: deny, - Subscribe: sub, - // A module answers what it was asked — a tool call reaches it on its own namespace, so the - // authority is bounded by having been asked. A node is asked one - // thing, a hand-over on its own subject (novox/hq issue 356), and answers that: the node is its - // machine's engine, root there already, and it is delivered only its own subjects. What it answers is - // never trusted for being an answer. A - // provisioner client is asked by its own machine's tool runner alone, on its own machine's subject (novox/hq - // ADR 0370 §3), and answers that. A person is never asked anything, and is granted nothing here. - AllowResponses: p.Kind == KindModule || p.Kind == KindNodeTools || p.Kind == KindNode || - p.Kind == KindProvisioner, - }, nil -} - -// vaultCalls are the calls the vault's holder makes in its own name (novox/hq ADR 0349 clauses 9 and 10, issue -// 723): the desks' secret prompt on any machine, and each tool that answers a credential its provider's software -// issued, on any machine — each on the subject whose last token is the vault's own name, which the bus lets it -// alone publish, so the prompt and the tool know the vault asks. Nothing else outside its seat. -func vaultCalls(p Principal) []string { - token := CallerToken(p.Username()) - if token == "" { - return nil - } - var out []string - if p.AsksTheDesk { - for _, v := range VerbsTheVaultAsksForASecret { - out = append(out, "mesh.seat."+v.Seat+"."+CallKind+"."+v.Verb+".*."+token) - } - } - for _, key := range p.AsksIssued { - module, tool, ok := strings.Cut(key, ".") - if !ok || module == "" || tool == "" || strings.ContainsAny(key, "*> ") { - continue - } - out = append(out, "mesh.mod."+module+"."+CallKind+"."+tool+".*."+token) - } - return out -} - -// seatSubject places a seat's verb under the kind of traffic it is. -// -// **The kind token is load-bearing, not decoration.** A stream is defined by a subject filter, so -// without it a stream over a seat or a module's namespace would capture that namespace's *tool* -// traffic too — and a tool call must never be persisted (design 25 §3: tools stay on core NATS). -// Found while defining the streams: the first draft of design 29 had one namespace per module -// with no kind, which reads well and cannot be filtered. -// -// A seat serving more than its current protocol version carries the version as a token -// (design 29 §8): the seat stays one role, and v1 and v2 run beside each other until nothing is -// bound to the old one. -func seatSubject(s Seat, kind, verb string) string { - return "mesh.seat." + s.Name + "." + kind + "." + verb -} - -// seatToolSubject is where a role's tool is asked. Mesh-wide for a mesh-scoped seat; a node-scoped -// seat carries the node it is asked of, because a flat subject would reach every machine's holder -// and the queue group would silently pick a winner (novox/hq ADR 0132, design 33 §4). A holder -// subscribes its own node's; a user publishes any node's (`*`) and names the machine in the subject. -func seatToolSubject(s Seat, verb, node string) string { - base := seatSubject(s, "tool", verb) - if s.Scope == "node" && node != "" { - return base + "." + node - } - return base -} - -// consumerStream and consumerDurable are the two halves of a consumer's identity, and they are -// two functions because conflating them was a real bug. -// -// **A durable name may not contain a dot; an ack subject is built from two names that do.** The -// server acknowledges on `$JS.ACK...…`, so a single string "EVENTS.one_audit" -// reads correctly inside the permission and is rejected as a consumer name — *nats: invalid -// consumer name*. Caught against a running server, and worth the comment because the shape of -// the failure if it had not been is the one design 25 §4 warns about: a consumer that cannot ack -// has every message redelivered forever, and its permission list looks right while it happens. -// -// They are derived here, beside the permission that must match them, because two places deriving -// the same name is how a module ends up unable to ack its own deliveries. -// consumedSubject is where a consumed event lands, from the local pattern a module declared. -// -// **The mesh's wildcards become this transport's** (design 29 §1): `*` is one name on both, and `**` -// — the rest — is `>` here. A module writes neither transport's spelling, so a manifest stays correct -// when the wire changes, which is the whole reason names are local. -// -// `**` on its own is every event from every module: the emitter is any, the event is anything. An -// audit logger wants exactly that and says so in one token. -func consumedSubject(pattern string) (string, error) { - if pattern == catalogueTheRest { - return "mesh.mod.*.event.>", nil - } - emitter, event, named := strings.Cut(pattern, ".") - if !named || emitter == "" || event == "" { - return "", fmt.Errorf( - "%q does not name an emitter and an event: a consumed event is ., or "+ - "%q for every event", pattern, catalogueTheRest) - } - if emitter == catalogueTheRest { - return "", fmt.Errorf("%q stands for the rest of a name, so it cannot name the emitter", catalogueTheRest) - } - // Each name is checked before it becomes a subject: a name carrying a dot would add a token and - // silently widen the permission, which is the whole reason safeSubject exists. - var out []string - for _, part := range strings.Split(event, ".") { - switch part { - case catalogueTheRest: - out = append(out, ">") - case "*": - out = append(out, "*") - default: - if !safeSubject.MatchString(part) { - return "", fmt.Errorf("%q cannot be part of a subject: it would widen the permission", part) - } - out = append(out, part) - } - } - if emitter != "*" && !safeSubject.MatchString(emitter) { - return "", fmt.Errorf("%q cannot name an emitter: it would widen the permission", emitter) - } - return "mesh.mod." + emitter + ".event." + strings.Join(out, "."), nil -} - -// catalogueTheRest is the mesh's wildcard for "the rest of a name", duplicated from the catalogue -// package for the one direction of dependency the build queue's name is duplicated for. -const catalogueTheRest = "**" - -func consumerStream(p Principal) string { - switch p.Kind { - case KindModule: - return "EVENTS" - case KindNode: - return "NODES" - } - return "" -} - -func consumerDurable(p Principal) string { - switch p.Kind { - case KindModule: - return p.Node + "_" + p.Module - case KindNode: - return p.Node - } - return "" -} - -// Server is everything the composed file needs that is not a principal. -type Server struct { - // ClientPort carries TLS itself. There is no plaintext port beside it: a bus reachable - // without TLS is one a module can reach without TLS by mistake. - ClientPort int - MonitoringPort int - TLSCert string - TLSKey string - TLSCA string - // StoreDir is a host directory bind, not a named volume — issue 115 is resolved and converted - // four modules away from named volumes; the bus's own data is not the place to bring one back. - StoreDir string -} - -// Compose renders the server's whole configuration. The order is stable and the output is -// deterministic, because the file's digest is what the module's entrypoint watches to decide -// whether to reload: a composer that reordered a map on each run would signal a reload every time -// the controller restarted, for a file that had not changed. -func Compose(s Server, principals []Principal) (string, error) { - sorted := append([]Principal(nil), principals...) - sort.Slice(sorted, func(i, j int) bool { return sorted[i].Username() < sorted[j].Username() }) - - var b strings.Builder - b.WriteString("# Composed by the mesh. Do not edit: the next composition overwrites it.\n") - b.WriteString("# Accounts and permissions are derived from what each module declares and nothing\n") - b.WriteString("# else (novox/hq ADR 0043, design 29 §2).\n\n") - - fmt.Fprintf(&b, "port: %d\n", s.ClientPort) - fmt.Fprintf(&b, "http: 127.0.0.1:%d\n\n", s.MonitoringPort) - - // **No `verify`, and it said `verify: true` until this configuration was run.** That setting - // makes the server demand a *client* certificate, and nothing in the mesh presents one: a host - // pins this server's exact certificate and authenticates with the password the mesh minted - // (ADR 0004, design 25 §4), and so does a module's runtime. With it on, every connection in the - // mesh is refused at the TLS handshake before any password is looked at, and the error — - // "client didn't provide a certificate" — reads as a fault in the client. - // - // TLS is still required: the block is what requires it, and verify only decides whether client - // certificates are checked. - b.WriteString("tls {\n") - fmt.Fprintf(&b, " cert_file: %q\n", s.TLSCert) - fmt.Fprintf(&b, " key_file: %q\n", s.TLSKey) - fmt.Fprintf(&b, " ca_file: %q\n", s.TLSCA) - b.WriteString("}\n\n") - - b.WriteString("jetstream {\n") - fmt.Fprintf(&b, " store_dir: %q\n", s.StoreDir) - b.WriteString("}\n\n") - - accounts, err := ComposeAccounts(sorted) - if err != nil { - return "", err - } - b.WriteString(accounts) - return b.String(), nil -} - -// ComposeAccounts is the accounts block alone — every user, and nothing about the server. -// -// **This is the only part of the configuration the mesh writes, and the split is deliberate.** A -// server's ports, its TLS paths and its store directory are properties of the container the module -// raises: they live in its image and its mounts, and they change when it does. The controller has no -// business knowing them, and a controller that did would have to be kept in step with a Dockerfile -// it never sees. What only the mesh knows is *who may connect*, so that is what it writes, and the -// module's own configuration includes it. -// -// Four things checked against a running server before this shape was committed to: a user in an -// included file authenticates; an unknown user is refused, so the include is the whole authority -// rather than an addition to something; a publish outside a user's grant is refused; and rewriting -// this file alone and signalling a reload makes a new user appear **without dropping the connection -// the mesh already has** — which is what makes every later account, permission or person change cost -// nothing (task 1.2's payoff). -func ComposeAccounts(principals []Principal) (string, error) { - sorted := append([]Principal(nil), principals...) - sort.Slice(sorted, func(i, j int) bool { return sorted[i].Username() < sorted[j].Username() }) - - var b strings.Builder - b.WriteString("# The mesh's users, composed by the grants part. Do not edit: the next\n") - b.WriteString("# composition overwrites it. Permissions are derived from what each module\n") - b.WriteString("# declares and nothing else (novox/hq ADR 0043, design 29 §2).\n\n") - - // One account for the mesh: accounts in NATS isolate subject spaces entirely, and the mesh is - // one space (design 25 §4). The cost of that — that permissions are the only isolation — is - // paid in the scoping of every inbox and every ack subject. - // JetStream is enabled per account once accounts exist at all: with only the global block set, - // a user in MESH is told "JetStream not enabled for account" the first time it binds a - // consumer, which is the first thing every host does (2026-09-28). - b.WriteString("accounts {\n MESH {\n jetstream: enabled\n") - b.WriteString(meshSystemImports) - b.WriteString(" users = [\n") - for _, p := range sorted { - perms, err := PermissionsFor(p) - if err != nil { - return "", err - } - if p.PasswordHash == "" { - return "", fmt.Errorf("%s has no password hash: a user without one is a user anybody is", p.Username()) - } - b.WriteString(AccountsEntry(p.Username(), p.PasswordHash, perms)) - } - b.WriteString(" ]\n }\n") - b.WriteString(systemAccount) - b.WriteString("}\n") - 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. -const WatchmanModule = "mesh-delivery-watchman" - -// CreateStreamGrants is what creating each named stream needs, and nothing wider (novox/hq ADR 0547 §5; the operator, -// 2026-10-26 08:54): its create, and its information to read back what it holds. Each by its own name: a name that -// is not one plain subject token is left out, since a wildcard there would be every stream. -func CreateStreamGrants(streams []string) []string { - var out []string - for _, s := range streams { - if !safeSubject.MatchString(s) { - continue - } - out = append(out, "$JS.API.STREAM.CREATE."+s, "$JS.API.STREAM.INFO."+s) - } - return out -} - -// CreateConsumerGrants are the subjects that create each consumer by its stream and name, and read its info back: the -// create of the client library's CreateConsumer for a consumer with no filter or several, never one filter's form (a -// wildcard in a filter would widen the grant), an update or a delete. -func CreateConsumerGrants(consumers []ModuleConsumer) []string { - var out []string - for _, mc := range consumers { - c := mc.Consumer - if !safeSubject.MatchString(c.Stream) || !safeSubject.MatchString(c.Name) || len(c.Filters) == 1 { - continue - } - out = append(out, "$JS.API.CONSUMER.CREATE."+c.Stream+"."+c.Name, "$JS.API.CONSUMER.INFO."+c.Stream+"."+c.Name) - } - return out -} - // GrantsModule is the one module whose principal publishes the modules' memberships (novox/hq ADR 0547 §5): the grants // part, which composes them from the manifests and placement. It defines no stream and is granted no other subject here. const GrantsModule = "mesh-grants" @@ -1450,167 +323,7 @@ const GrantsModule = "mesh-grants" const ( GrantsMembershipsState = "memberships" GrantsMembershipsBucket = GrantsModule + "_" + GrantsMembershipsState - GrantsMembershipsRead = GrantsModule + "." + GrantsMembershipsState ) // MembershipKey is an assignment's key in the grants part's memberships: `.`. 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 -// gains its other requests and events; it exports exactly these three, to MESH alone. -const ( - BusConnectSubject = "mesh.bus.connect" - BusDisconnectSubject = "mesh.bus.disconnect" - BusConnzSubject = "mesh.bus.connz" -) - -// meshSystemImports is what MESH imports from $SYS: the two event streams and the one service. -const meshSystemImports = " imports = [\n" + - " { stream: { account: \"$SYS\", subject: \"$SYS.ACCOUNT.MESH.CONNECT\" }, to: \"" + BusConnectSubject + "\" }\n" + - " { stream: { account: \"$SYS\", subject: \"$SYS.ACCOUNT.MESH.DISCONNECT\" }, to: \"" + BusDisconnectSubject + "\" }\n" + - " { service: { account: \"$SYS\", subject: \"$SYS.REQ.ACCOUNT.MESH.CONNZ\" }, to: \"" + BusConnzSubject + "\" }\n" + - " ]\n" - -// systemAccount is the account `$SYS`, the server's default system account by its own name (a config that declares an -// account of that name makes it the system account), with no user and the three exports to MESH. Quoted, because an -// unquoted `$` begins a variable in the server's config grammar. -const systemAccount = " \"$SYS\" {\n" + - " exports = [\n" + - " { stream: \"$SYS.ACCOUNT.MESH.CONNECT\", accounts: [\"MESH\"] }\n" + - " { stream: \"$SYS.ACCOUNT.MESH.DISCONNECT\", accounts: [\"MESH\"] }\n" + - " { service: \"$SYS.REQ.ACCOUNT.MESH.CONNZ\", accounts: [\"MESH\"] }\n" + - " ]\n" + - " }\n" - -// unique is a sorted list with each subject once. Two carried modules holding seats with the same -// verb, or the runtime module itself carried beside the others, would otherwise write a grant twice -// — harmless to the server, and noise in a file that is read as the mesh's authority model. -func unique(values []string) []string { - sort.Strings(values) - out := values[:0] - for i, v := range values { - if i == 0 || v != values[i-1] { - out = append(out, v) - } - } - return out -} - -func quoted(values []string) string { - if len(values) == 0 { - return "" - } - out := make([]string, len(values)) - for i, v := range values { - out[i] = fmt.Sprintf("%q", v) - } - return strings.Join(out, ", ") -} - -// invokedSubjects is the publish side of a grant to call tools: one subject per `.`, -// or the whole tool namespace for `*`. A person's authority and a module's `invokes` are both this -// (novox/hq ADR 0152), so a malformed entry is refused in one place, before it could be widened into -// something that happens to parse. -func invokedSubjects(invokes []string) ([]string, error) { - var out []string - for _, t := range invokes { - if t == "*" { - // Every module's tools and every role's (novox/hq ADR 0132): a role's verb is a tool - // like any other, addressed to the seat instead of a module. - out = append(out, "mesh.mod.*.tool.>", "mesh.seat.*.tool.>") - continue - } - if rest, isSeat := strings.CutPrefix(t, "seat:"); isSeat { - // A role's tool, `seat:.`. Both address shapes, because the grant is - // written without knowing the seat's scope: a mesh seat's verb is flat and a node - // seat's carries the machine (design 33 §4). - seat, verb, ok := strings.Cut(rest, ".") - if !ok || seat == "" || verb == "" { - return nil, fmt.Errorf( - "%q does not name a role's tool: one invokes seat:.", t) - } - out = append(out, "mesh.seat."+seat+".tool."+verb, "mesh.seat."+seat+".tool."+verb+".*") - continue - } - module, tool, ok := strings.Cut(t, ".") - if !ok || module == "" || tool == "" { - return nil, fmt.Errorf( - "%q does not name a tool: one invokes ., seat:., or * for every one", t) - } - // Both ways a module's tool is addressed (novox/hq ADR 0159): to whichever instance - // answers, and to the instance on one machine, which is the same subject with the machine - // as its last token. - out = append(out, "mesh.mod."+module+".tool."+tool, "mesh.mod."+module+".tool."+tool+".*") - } - return out, nil -} - -// announcing is what a principal that serves tools subscribes to answer the NATS services -// protocol's discovery (novox/hq ADR 0197): the questions asked of every service, and those asked of -// each name it serves — its own and no other's, so it cannot answer for a service it is not. -func announcing(names ...string) []string { - out := []string{"$SRV.PING", "$SRV.INFO", "$SRV.STATS"} - for _, n := range names { - if !safeSubject.MatchString(n) { - continue - } - for _, verb := range []string{"PING", "INFO", "STATS"} { - out = append(out, "$SRV."+verb+"."+n, "$SRV."+verb+"."+n+".>") - } - } - return out -} - -// discovering is what a principal publishes to ask what answers (novox/hq ADR 0197): the services -// protocol's discovery requests, whose replies come to its own inbox. -func discovering() []string { - return []string{"$SRV.PING", "$SRV.PING.>", "$SRV.INFO", "$SRV.INFO.>"} -} - -// WitnessSubjects are the subjects a machine's node-engine publishes to witness the core builds it -// places (novox/hq ADR 0236): its own node tools' PING. The answers come to the machine's own inbox. -func WitnessSubjects(p Principal) []string { - return append([]string{pingSubject(p.Node)}, CheckSubjects(p)...) -} - -// CheckSubjects are the tools a machine's node-engine asks as declared health checks (novox/hq ADR 0240, -// to-be 48 §3): each `.` on this machine's instance — `mesh.mod..tool..` -// — and never the plain subject, which any machine's instance may answer. Sorted and once each. -func CheckSubjects(p Principal) []string { - seen := map[string]bool{} - var out []string - for _, c := range p.Checks { - module, tool, ok := strings.Cut(c, ".") - if !ok || !safeSubject.MatchString(module) || !safeSubject.MatchString(tool) { - continue - } - subject := "mesh.mod." + module + ".tool." + tool + "." + p.Node - if !seen[subject] { - seen[subject] = true - out = append(out, subject) - } - } - sort.Strings(out) - return out -} - -// pingSubject is a node-engine's witness's ping of its own node's tools (novox/hq ADR 0236). -func pingSubject(node string) string { return "$SRV.PING." + nodeToolsService + "." + node } - -const nodeToolsService = "node-tools" - -// VaultOwnCall is the subject a machine principal's node-engine asks the vault's `own` on, in its own name. -func VaultOwnCall(p Principal) string { - return "mesh.seat." + VaultOwn.Seat + "." + CallKind + "." + VaultOwn.Verb + "." + CallerToken(p.Username()) -} - -// VaultSignCall is the subject a machine principal's node-engine asks the vault's `sign` on, in its own name. -func VaultSignCall(p Principal) string { - return "mesh.seat." + VaultSign.Seat + "." + CallKind + "." + VaultSign.Verb + "." + CallerToken(p.Username()) -} - -// VaultSecretChanged is the vault's event that a secret changed (a name and its generation, never a value), which a -// node composing its own hears to compose again (issue 1126, ADR 0547 §2). -var VaultSecretChanged = "mesh.mod." + VaultOwn.Seat + ".event.secret-changed" diff --git a/go/buscompose/onnats.go b/go/buscompose/onnats.go deleted file mode 100644 index ee39eda..0000000 --- a/go/buscompose/onnats.go +++ /dev/null @@ -1,5 +0,0 @@ -package buscompose - -// NATSVar names the environment variable that says the controller is on the bus (mesh-controller internal/broker -// onnats.go); kept with the composer for the readiness it composes. -const NATSVar = "MESH_BUS_NATS" diff --git a/go/buscompose/process_grants.go b/go/buscompose/process_grants.go deleted file mode 100644 index 99c7b1d..0000000 --- a/go/buscompose/process_grants.go +++ /dev/null @@ -1,56 +0,0 @@ -package buscompose - -// The bus account of one process of a module that declares bus (novox/hq ADR 0436 §1, §2, §3, §5). -// -// **Never the module's own account's grants, never the tool runner's.** A process's authority is read -// straight from its bus declaration — its emits, its queue's consumer, its state, whether it reads the -// records of its module's own asks, and the tools and seat verbs it calls — and nothing else: it holds no -// seat, serves no tool, hears no module stream, and answers nothing a caller asked (§3). Modelled on -// provisionerGrants, the other kind of process account the mesh mints (ADR 0349 clauses 6 and 11). - -// processGrants is one process's whole authority: to publish, and to subscribe. -func processGrants(p Principal) (pub, sub []string, err error) { - // 1. Its own events, under its module's own namespace — the same subjects the module's own principal - // would publish them on, had it declared them there instead (novox/hq ADR 0436 §1). - own := "mesh.mod." + p.Module - for _, e := range p.Emits { - pub = append(pub, own+".event."+e) - } - - // 2. The one durable consumer of each work-queue stream its bus names (novox/hq ADR 0436 §1, §2, §6): - // asked about, pulled from and acknowledged, by the same name ConsumersOf composes the consumer under - // (QueueConsumer) — and no other stream's. - for _, q := range p.Queues { - consumer := p.Node + "_" + p.Module + "_" + q.Process - pub = append(pub, - "$JS.API.CONSUMER.INFO."+q.Stream+"."+consumer, - "$JS.API.CONSUMER.MSG.NEXT."+q.Stream+"."+consumer, - "$JS.ACK."+q.Stream+"."+consumer+".>") - } - - // 3. Its module's own state, by the local names its bus names alone — never the whole of what the - // module keeps (novox/hq ADR 0436 §1), read and written as stateGrants gives any module's own bucket. - kept := stateAccess{Module: p.Module, Node: p.Node, Keeps: p.State} - pub = append(pub, stateGrants(kept)...) - // And it hears each of them created, so one not on the bus at its start is bound when it appears (novox/hq issue - // 1790; process.AwaitBucket). - sub = append(sub, stateCreatedHeard(kept)...) - - // 4. The router's records of its module's own asks (novox/hq ADR 0436 §1, ADR 0259 §3): the same - // direct-get subjects a module's own principal reads for the seats its Uses names that keep them — - // read here from the one list, so the two cannot disagree. - if p.Asks { - pub = append(pub, SeatTrafficOf(p.Module, nil, p.Uses, nil).Records...) - } - - // 5. The tools and seat verbs it calls (novox/hq ADR 0152, ADR 0436 §1): the same derivation a - // module's own principal's invokes takes, so `callerNamed` (applied to every non-controller principal) - // adds the caller-named `call` variants for it exactly as it would for a module. - invoked, err := invokedSubjects(p.Invokes) - if err != nil { - return nil, nil, err - } - pub = append(pub, invoked...) - - return pub, sub, nil -} diff --git a/go/buscompose/provisioner.go b/go/buscompose/provisioner.go deleted file mode 100644 index e279463..0000000 --- a/go/buscompose/provisioner.go +++ /dev/null @@ -1,229 +0,0 @@ -package buscompose - -import ( - "errors" - "fmt" - "strings" - - "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/provisions" - - "github.com/nats-io/nats.go" -) - -// The provisioner client's own bus account (novox/hq ADR 0349 clauses 6 and 11, design 57 package P3). -// -// **Not the tool runner's account.** The client fills a machine's files and asks the vault for the secrets that -// machine's declaration names; the vault hands a secret only to an account of its own, because a call under the -// tool runner's account cannot be told from an agent's on that machine (ADR 0305 §3, §12). So each machine that -// runs the client has a principal of its own, KindProvisioner, named for the machine, and granted what the client -// does and nothing else: read its own machine's keys in each domain's bucket, write its own machine's keys of the -// provisioner server's needs, applied and collections (design 57 P4), read the server's registry, say what it -// applied on its own machine, and ask the vault in its own name. - -// NodeProvisionerSeat is the seat the provisioner client holds on every machine (ADR 0349 clause 3). -const NodeProvisionerSeat = "node-provisioner" - -// ProvisionerSecret is the own secret of the module running the client that its bus account is delivered in, as -// `broker` is a module's own (novox/hq issue 195): a module that claims NodeProvisionerSeat and declares no own -// secret of this name has nowhere to read an account, and is given none. -const ProvisionerSecret = "provisioner-broker" - -// ConfigResolvedBucket is the config domain's resolved values, keyed `..` (ADR 0348, research -// 051): mesh-config's state `resolved`, written by mesh-config alone. -var ConfigResolvedBucket = BucketName("mesh-config", "resolved") - -// ProvisionerReads are the domains' buckets a provisioner client reads its own machine's keys of, each keyed -// `.…` (research 051): placement, the controller's assignments mirror (`.`), whose value carries -// the provisioner's plan of a module handed to it (inventory.Provisioning, design 57 P5); and config, -// mesh-config's resolved values; and the seat mesh-provisioner's plans of its machine, `.` (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 seat mesh-placement's placement, -// where a provider's consumers are placed on their own machines, which the provider machine's client reads to fill -// ${consumers:} with each one's credential (novox/hq ADR 0373, issue 679; ADR 0567, issue 1126: the -// controller's assignments mirror it read before is gone). It holds placement, never a secret's value; the client -// writes none of it. **The seat's plans are read whole for the same -// reason** (2026-10-30 recovery, issue 774): the client V3 watches every machine's plans to bind a provider's -// consumers, and granted only its own machine's keys it read none. ADR 0570 clause 3 has providers act on the seat's -// provision keys instead; once the client does, plans go back to its own machine's keys. -var ProvisionerReadsWhole = []string{delivery.PlacementBucket, provisions.PlansBucket} - -// BoundState is a providing module's state of entries, one per consumer (mesh-sdk go/provider, issue 678): a -// consumer's machine's client reads its own keys of it, `..[.]`, to fill -// ${bound::} (issue 679). -const BoundState = "bound" - -// ProvisionerServerModule is the module holding the seat mesh-provisioner once for the mesh (ADR 0349 clause 3, -// design 57 P4), whose buckets the clients write their own machine's keys of. -const ProvisionerServerModule = "mesh-provisioner" - -// The provisioner server's buckets (ADR 0349 clause 3): its state, created by the controller from its manifest -// like any module's, and written by its own account whole. -var ( - // ProvisionerRegistryBucket is which domain answers which kind of placeholder, keyed by the kind; the - // server's alone, read whole by every client. - ProvisionerRegistryBucket = BucketName(ProvisionerServerModule, "registry") - // ProvisionerNeedsBucket is what each module on each machine needs, `...`. - ProvisionerNeedsBucket = BucketName(ProvisionerServerModule, "needs") - // ProvisionerAppliedBucket is what is applied where, `.`. - ProvisionerAppliedBucket = BucketName(ProvisionerServerModule, "applied") - // ProvisionerCollectBucket is the items of every collection, `..`. - ProvisionerCollectBucket = BucketName(ProvisionerServerModule, "collect") -) - -// ProvisionerWrites are the server's buckets a client writes, and reads, its own machine's keys of (`.>`) -// 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 -// clause 11): `get`, a secret its machine's declaration names, answered sealed to the asker's key. On the subject -// that names its caller, so the vault knows which machine's client asks from the bus's word. -var VerbsTheProvisionerAsksTheVault = []SeatVerb{{Seat: "mesh-vault", Verb: "get"}} - -// VaultSecretChangedSubject is where the vault says a secret it holds changed (`secret-changed`: which, by whom, -// when, its generation; never a value, ADR 0349 clause 11), as link.EventSubject addresses mesh-vault's event. A -// provisioner client listens to it so a rotated secret reaches its file without a new declaration (design 57 V4): -// it asks the vault again, in its own name, for a secret its machine's declaration names. -const VaultSecretChangedSubject = "mesh.mod.mesh-vault.event.secret-changed" - -// ProvisionerAppliedSubject is where a machine's client says what it applied for one module, applied or failed -// with the reason (ADR 0349 clauses 4 and 15): `prov.applied..`. -func ProvisionerAppliedSubject(node, module string) string { - return "prov.applied." + node + "." + module -} - -// ProvisionerAskSubject is where a machine's tool runner asks that machine's provisioner client for a config value -// or a non-secret bound value of a module it carries, naming the module from the bundle that asks (novox/hq ADR -// 0370 §1, §3, §4; issue 685): `prov.ask.`, which the client subscribes and the tool runner's account of the -// same machine alone may publish. The bus proves only that the tool runner asks; the module is the runtime's word, -// so the client answers no secret here (ADR 0370 §2). -func ProvisionerAskSubject(node string) string { return "prov.ask." + node } - -// ProvisionerChangedSubject is where a machine's client says that a value of one module it answers moved, so the -// tool runner asks again for the module's code's watches (ADR 0349 §14, ADR 0370 §1): `prov.changed..`, -// carrying which value and never the value itself, heard by that machine's tool runner alone. -func ProvisionerChangedSubject(node, module string) string { - return "prov.changed." + node + "." + module -} - -// provisionerGrants is a provisioner client's whole authority: to publish, and to subscribe. -func provisionerGrants(p Principal) (pub, sub []string, err error) { - if p.Node == "" { - // Refused rather than composed into `$KV...>`, a pattern no machine's keys are under, and a - // name every nameless client would share. - return nil, nil, errors.New("a provisioner client names no machine, so it would have no keys of its own " + - "to read and an account it shared with every other") - } - for _, bucket := range ProvisionerReads { - pub = append(pub, machineKeysRead(bucket, p.Node)...) - } - for _, bucket := range ProvisionerReadsWhole { - pub = append(pub, wholeBucketRead(bucket)...) - } - for _, bucket := range p.BoundBuckets { - module, name, ok := strings.Cut(bucket, "_") - if !ok || name != BoundState || !safeSubject.MatchString(module) || strings.Contains(module, "_") { - return nil, nil, fmt.Errorf("%q is not a provider's bucket of entries, _%s", bucket, BoundState) - } - pub = append(pub, machineKeysRead(bucket, p.Node)...) - } - // The server's buckets (design 57 P4): its own machine's keys of the needs, the applied and the - // collections, read and written, `$KV...>` and nothing wider; and the registry, read whole, - // which says which domain answers which kind and is the same for every machine. - for _, bucket := range ProvisionerWrites { - pub = append(pub, machineKeysRead(bucket, p.Node)...) - pub = append(pub, "$KV."+bucket+"."+p.Node+".>") - } - pub = append(pub, wholeBucketRead(ProvisionerRegistryBucket)...) - // Its own node's own have, `.node`, read and never written, and no other key of the bucket: it fills every - // module again when the node-engine moves to a new composition of the node's own (issue 1945), since an apply can - // leave a filled file as the composition had it. - pub = append(pub, readGrants(delivery.HaveBucket, p.Node+".node")...) - pub = append(pub, "prov.applied."+p.Node+".>") - token := CallerToken(p.Username()) - if token == "" { - return nil, nil, fmt.Errorf("%q cannot name a caller", p.Username()) - } - for _, v := range VerbsTheProvisionerAsksTheVault { - pub = append(pub, "mesh.seat."+v.Seat+"."+CallKind+"."+v.Verb+"."+token) - } - // What moved of a value it answers its own machine's tool runner (ADR 0370 §1), under its own machine's name. - pub = append(pub, "prov.changed."+p.Node+".>") - // Its own reply space, for the vault's answers and its reads' and watches' deliveries; what the vault says - // changed; and its own machine's tool runner asking it for a module's config and non-secret bound values (ADR - // 0370 §3), which it answers on the ask's reply and nowhere else. - return pub, []string{p.inbox(), VaultSecretChangedSubject, ProvisionerAskSubject(p.Node)}, nil -} - -// machineKeysRead is reading every key of a bucket under one machine's name, `.>`, and no other key: a -// direct get of a key's own subject, and a consumer whose filter is under it — the server's create-with-filter -// form, which it holds to the filter the subject names (as stateGrants does for one key, novox/hq ADR 0260). -// Binding, deleting a consumer and flow control cannot be narrowed by key and read no value. -func machineKeysRead(bucket, node string) []string { - stream := "KV_" + bucket - keys := "$KV." + bucket + "." + node + ".>" - return []string{ - "$JS.API.STREAM.INFO." + stream, - "$JS.API.DIRECT.GET." + stream + "." + keys, - "$JS.API.CONSUMER.CREATE." + stream + ".*." + keys, - "$JS.API.CONSUMER.DELETE." + stream + ".>", - "$JS.FC." + stream + ".>", - } -} - -// wholeBucketRead is reading every key of a bucket and writing none: what stateGrants grants a module for a -// bucket it `reads`. -func wholeBucketRead(bucket string) []string { - stream := "KV_" + bucket - return []string{ - "$JS.API.STREAM.INFO." + stream, - "$JS.API.DIRECT.GET." + stream + ".>", - "$JS.API.CONSUMER.CREATE." + stream + ".>", - "$JS.API.CONSUMER.DELETE." + stream + ".>", - "$JS.FC." + stream + ".>", - } -} - -// LastProvApplied is what a machine's provisioner client last said of one module's files, as it published it on -// ProvisionerAppliedSubject and ProvAppliedStream keeps it (design 57 P6): false when it has said nothing of it in -// the stream's week. -func LastProvApplied(js nats.JetStreamContext, node, module string) ([]byte, bool, error) { - msg, err := js.GetLastMsg(ProvAppliedStream, ProvisionerAppliedSubject(node, module)) - if errors.Is(err, nats.ErrMsgNotFound) { - return nil, false, nil - } - if err != nil { - return nil, false, fmt.Errorf("what %s's provisioner client said of %s cannot be read: %w", node, module, err) - } - return msg.Data, true, nil -} - -// ProvAppliedReports is the newest word of every machine's provisioner client on every module it said one of, kept by -// ProvAppliedStream, by subject (`prov.applied..`). -func ProvAppliedReports(js nats.JetStreamContext) (map[string][]byte, error) { - info, err := js.StreamInfo(ProvAppliedStream, &nats.StreamInfoRequest{SubjectsFilter: ProvAppliedSubjects}) - if err != nil { - return nil, fmt.Errorf("the provisioner clients' reports cannot be listed: %w", err) - } - out := map[string][]byte{} - for subject := range info.State.Subjects { - msg, err := js.GetLastMsg(ProvAppliedStream, subject) - if errors.Is(err, nats.ErrMsgNotFound) { - continue // aged out between the two reads - } - if err != nil { - return nil, fmt.Errorf("the provisioner client's report on %s cannot be read: %w", subject, err) - } - out[subject] = msg.Data - } - return out, nil -} diff --git a/go/buscompose/raise.go b/go/buscompose/raise.go deleted file mode 100644 index 9104f04..0000000 --- a/go/buscompose/raise.go +++ /dev/null @@ -1,73 +0,0 @@ -package buscompose - -import "fmt" - -// Bringing the bus's own objects into being, in the one order that works. -// -// **Asserted on every start rather than created once at genesis.** A stream somebody deleted, a mesh -// raised from a restored backup, or a bus whose data directory was replaced all have records and no -// objects — and a node whose consumer is missing hears nothing while everything else about it looks -// correct. Idempotence is the whole requirement, and the parts are already idempotent; this is the -// order they have to be asked in. - -// Raiser is everything asserting the bus's objects needs of a connection to it. -type Raiser interface { - Asserter - Ensurer -} - -// Raise asserts the mesh's streams, its own consumers, and one consumer per node. -// -// **The order is not a preference.** A consumer on a stream that does not exist is refused, and the -// refusal names the stream rather than the order — so somebody reading it goes looking for a deleted -// stream instead of a reversed pair of lines. Nodes last, because the one a node reads lives on a -// stream the mesh's own set defines. -func Raise(r Raiser, nodes []string) error { - if err := AssertMeshStreams(r); err != nil { - return err - } - if err := AssertMeshConsumers(r); err != nil { - return err - } - if err := AssertNodeConsumers(r, nodes); err != nil { - return err - } - return nil -} - -// RaiseSeats asserts one work queue per declared seat, and the worker of whoever holds it. -// -// Separate from Raise because it is answered by a different question: the mesh's own objects exist -// because the mesh does, and a seat's exist because a module declaring one was registered. Kept -// beside it so the order is visible — a holder's worker needs the seat's stream, and a seat's stream -// needs nothing. -func RaiseSeats(r Raiser, seats []DeclaredSeat, holders map[string]Holder) error { - for _, s := range SeatStreams(seats) { - if err := r.EnsureStream(s); err != nil { - return fmt.Errorf("asserting the work queue for %s: %w", s.Name, err) - } - } - for _, s := range seats { - h, held := holders[s.Name] - if !held { - // **The stream exists and the consumer does not, on purpose.** Work queues until a - // holder appears, so installing the module a week after something started sending to it - // flushes the backlog instead of having lost it. - continue - } - c, needed := HolderConsumerFor(h.Node, h.Module, s) - if !needed { - continue - } - if err := r.EnsureConsumer(c); err != nil { - return fmt.Errorf("asserting how %s on %s works %s: %w", h.Module, h.Node, s.Name, err) - } - } - return nil -} - -// Holder is which module on which machine holds a seat. -type Holder struct { - Node string - Module string -} diff --git a/go/buscompose/readiness.go b/go/buscompose/readiness.go deleted file mode 100644 index 5fb24ec..0000000 --- a/go/buscompose/readiness.go +++ /dev/null @@ -1,139 +0,0 @@ -package buscompose - -import ( - "fmt" - "sort" - "strings" -) - -// Whether a mesh could move its bus, and what is missing if not. -// -// **Asked before anything moves, and answerable from records alone.** The rollout moves every node at -// once (novox/hq ADR 0116 step 5), so there is no partial state to inspect afterwards and no half to -// roll back: either the mesh was ready or it was not. That makes a readiness question the most -// valuable thing here — it costs nothing, it can be asked of a running mesh any number of times, and -// every answer is a thing somebody can go and fix. -// -// Deliberately pure. It is handed what the mesh knows and returns sentences; nothing here connects to -// anything, so it can be asked on a workstation about a mesh it has never reached. - -// Readiness is what the mesh knows about its own ability to move. -type Readiness struct { - // TheBus is the address the mesh's own traffic would move to, empty when nothing names one. - TheBus string - // ServerStanding is whether a bus is reachable at that address, as somebody checked. - ServerStanding bool - // Holder is the node running the module that holds the bus seat, empty when nothing does. - Holder string - // AccountsComposed is whether that node has been sent the composed user list. - AccountsComposed bool - // Nodes is every machine the mesh knows. - Nodes []string - // Credentialled is which of them has a credential for the new bus. - Credentialled map[string]bool - // Modules is every assigned module, as `/`. - Modules []string - // ModuleCredentialled is which of those has one. - ModuleCredentialled map[string]bool -} - -// NotReady is every reason this mesh cannot move its bus yet, in the order somebody would fix them. -// -// Empty means ready. **Each entry names one thing and what to do about it**, because a readiness check -// that says "not ready" is a check nobody can act on — and this is read at the point where the next -// step is irreversible. -func NotReady(r Readiness) []string { - var why []string - - if strings.TrimSpace(r.TheBus) == "" { - why = append(why, "nothing names the bus to move to: set "+NATSVar+" on the control node "+ - "to the address the new server answers on") - } - if !r.ServerStanding { - why = append(why, "no bus is answering at that address. Step 2 of the change raises it beside "+ - "the one the mesh is on, carrying nothing — assign the module that holds "+ - "mesh-broker and push the machine that runs it") - } - if r.Holder == "" { - why = append(why, "no machine holds mesh-broker, so nothing would compose the bus's user "+ - "list. Assign the module that claims it") - } else if !r.AccountsComposed { - why = append(why, fmt.Sprintf( - "%s holds mesh-broker and has not been sent the composed user list, so the bus would "+ - "refuse every connection. `push %s`", r.Holder, r.Holder)) - } - - // A node with no credential cannot come back after the move, and a node that cannot come back is - // a machine the mesh has lost until somebody goes to it. - var missing []string - for _, n := range r.Nodes { - if !r.Credentialled[n] { - missing = append(missing, n) - } - } - sort.Strings(missing) - if len(missing) > 0 { - why = append(why, fmt.Sprintf( - "%d machine(s) have no credential for the new bus and would not come back: %s. Each needs "+ - "one minted before the move, not after — after, there is no bus to ask over", - len(missing), strings.Join(missing, ", "))) - } - - // A module without one keeps running and stops being reachable, which is a smaller fault and still - // one somebody should choose rather than discover. - var quiet []string - for _, m := range r.Modules { - if !r.ModuleCredentialled[m] { - quiet = append(quiet, m) - } - } - sort.Strings(quiet) - if len(quiet) > 0 { - why = append(why, fmt.Sprintf( - "%d module(s) have no credential for the new bus: %s. Each keeps serving and stops "+ - "answering tools and hearing events until it is issued one", - len(quiet), strings.Join(quiet, ", "))) - } - - return why -} - -// WhatMoves is what the rollout would do, in order, for somebody reading before they commit. -// -// **Written out rather than summarised.** This is the one step with nothing to inspect afterwards, so -// the last useful moment to disagree with it is while reading this. -func WhatMoves(r Readiness) []string { - out := []string{ - fmt.Sprintf("compose the bus's user list and send it to %s", holderOr(r.Holder)), - fmt.Sprintf("move this control plane to %s, and confirm it is heard", busOr(r.TheBus)), - } - nodes := append([]string(nil), r.Nodes...) - sort.Strings(nodes) - for _, n := range nodes { - out = append(out, fmt.Sprintf("move %s, and confirm it reports", n)) - } - if len(r.Modules) > 0 { - out = append(out, fmt.Sprintf("move %d module runtime(s), and confirm each answers", - len(r.Modules))) - } - // **The old broker goes, and it goes last** (novox/hq ADR 0131). AMQP is not a provision, so once - // every machine reports on the new bus nothing of the mesh is left speaking to it, and its module - // is unassigned. Said as a step so nobody reads the move as leaving a second bus behind. - out = append(out, "then unassign the old broker's module: AMQP is not a provision (ADR 0131), and "+ - "once every machine reports on the new bus nothing of the mesh speaks to it") - return out -} - -func holderOr(node string) string { - if node == "" { - return "whichever machine holds mesh-broker" - } - return node -} - -func busOr(address string) string { - if address == "" { - return "the new bus" - } - return address -} diff --git a/go/buscompose/sdk_membership.go b/go/buscompose/sdk_membership.go index d9ad34c..215f145 100644 --- a/go/buscompose/sdk_membership.go +++ b/go/buscompose/sdk_membership.go @@ -2,37 +2,7 @@ package buscompose import ( "git.novox.be/novox/mesh-sdk/go/membership" - "git.novox.be/novox/mesh-sdk/go/process" -) - -// The membership the mesh issues an assignment (novox/hq ADR 0160), its subject, its stream, the bare address and the -// pin are mesh-sdk's, public so that a module's program outside this Go module, the route proxy, reads them too -// (novox/hq ADR 0480 clause 4, issue 1633). This package keeps no second copy: what follows only names them where the -// controller already did. -type ( - Membership = membership.Membership - Served = membership.Served - SeatServed = membership.SeatServed - StateIssued = membership.StateIssued - LogIssued = membership.LogIssued - Worker = membership.Worker - SeatTraffic = membership.SeatTraffic - KindHeld = membership.KindHeld - StreamIssued = membership.StreamIssued - HeardIssued = membership.HeardIssued - WaitIssued = membership.WaitIssued ) // AssignmentsStream holds every assignment's membership, the newest per subject. const AssignmentsStream = membership.AssignmentsStream - -var ( - // MembershipSubject is where an assignment's own membership is published. - MembershipSubject = membership.Subject - // CredentialIn reads the user and password out of a bus address, and the address without them. - CredentialIn = membership.CredentialIn - // BareAddress is a bus address with any credential stripped. - BareAddress = membership.BareAddress - // PinnedToFingerprint accepts exactly the certificate with this SHA-256 and no other. - PinnedToFingerprint = process.PinnedToFingerprint -) diff --git a/go/buscompose/seattraffic.go b/go/buscompose/seattraffic.go index 243f7ac..fe5b9e8 100644 --- a/go/buscompose/seattraffic.go +++ b/go/buscompose/seattraffic.go @@ -1,319 +1,7 @@ package buscompose -import ( - "fmt" - "sort" -) - -// What a module may say and take on the seats it holds, uses and watches, beyond tools (novox/hq ADR -// 0259 §3, to-be 46 §10). -// -// Three rules are added to the ones a seat always had, each a subject whose last token names who may -// publish it, granted to that publisher alone — the way a node seat's event about a machine carries the -// machine (ADR 0219): -// -// - **A verb named by its caller** (`by-caller`). A user of the seat submits that accept, and hears that -// event, under its own module's name and no other: `accept.ask.`, `event.decided.`. The -// holder takes every caller's accept and says the event to any caller. So an ask's asker is a fact the -// server enforces, and a warrant reaches only the asker it is for. -// - **A kinded bench** (ADR 0234 §2). A holder claims one kind and takes its own kind's accepts, says its -// own kind's events and asks its own kind's proofs, and nothing of another kind; a user submits to any -// kind. Each kind has its own worker on the seat's queue, so a holder that is away keeps its work and -// holds up no other kind. -// - **A proof** (`proofs`): core request and reply on `mesh.seat..proof..`. No stream's -// subjects cover it, so what travels there — a code typed by the operator — is never persisted. A kinded -// holder asks with its own kind; the modules that watch the seat answer. -// -// And two reads: **a holder's records**, a bucket the seat names, read by each user under its own name only -// (`$KV...>`), so an asker reads the state of its own asks and no other asker's; and on a seat pulled by -// kind, **what its workers gave up on**, the copies DEAD_LETTERS keeps of the seat's stream (DeadLetterReads, issue 734). - -// BuildAskMaxDeliver bounds the deliveries of a build seat's ask (issue 1907): mesh-sdk delivery.AskMaxDeliver, written -// here as the broker mirrors the catalogue rather than importing it. -const BuildAskMaxDeliver = 5 - -// KindedBenches are the seats that may be kinded (ADR 0234 §2): making another is a decision, recorded. -var KindedBenches = map[string]bool{"channel": true, "intake": true} - -// WorkerName is the worker a seat's holders pull from: one for the seat, or one per kind on a kinded bench. -func WorkerName(seat, kind string) string { - if kind == "" { - return "SEAT_" + upperSnake(seat) + "_worker" - } - return "SEAT_" + upperSnake(seat) + "_" + upperSnake(kind) + "_worker" -} - -func namesVerb(list []string, s string) bool { - for _, x := range list { - if x == s { - return true - } - } - return false -} - -// SeatTrafficOf derives one module's seat traffic from the seats it holds, uses and watches. Only seats -// carrying one of the rules above are read: every other seat is composed as it always was. -func SeatTrafficOf(module string, holds, uses, watches []Seat) SeatTraffic { - var t SeatTraffic - for _, s := range holds { - if !s.isNewTraffic() { - // **A holder says its own seat's events** (novox/hq ADR 0564 clause 6, issue 1952): its own account is - // granted them by the held seats' rule (PermissionsFor, step 3), and a module the tool runner carries had - // no way to, so the self-check's `checked` was never said. Listed here, the membership names them for the - // runtime and the runtime is granted them. A machine's event on a node seat carries the machine, which - // only the holder's own account is composed with, so it is left to that rule. - for _, e := range s.Emits { - if s.Scope == "node" && perMachineEvents[e] { - continue - } - t.Publish = append(t.Publish, seatSubject(s, "event", e)) - } - continue - } - kind := "" - if s.Kinded { - kind = s.Kind - if kind == "" || !safeSubject.MatchString(kind) { - // A kinded claim without a usable kind is refused at registration; here it is granted - // nothing, which is the same answer at the last place it could be asked. - continue - } - } - if len(s.Accepts) > 0 && s.PulledByKind { - // One worker per accepted kind, which every holder pulls in the order of Accepts (ADR 0525 §4, ADR 0536 §4): - // `SEAT___worker`, filtered to `accept.` (mesh-sdk delivery.AskWorker). - for _, a := range s.Accepts { - t.Workers = append(t.Workers, Worker{Stream: seatStreamName(s.Name), Consumer: WorkerName(s.Name, a), - Filter: "mesh.seat." + s.Name + ".accept." + a}) - } - } else if len(s.Accepts) > 0 { - stream := seatStreamName(s.Name) - filter := "mesh.seat." + s.Name + ".accept.>" - if kind != "" { - filter = "mesh.seat." + s.Name + ".accept.*." + kind - } - t.Workers = append(t.Workers, Worker{Stream: stream, Consumer: WorkerName(s.Name, kind), Filter: filter}) - } - for _, a := range s.Accepts { - switch { - case kind != "": - t.Subscribe = append(t.Subscribe, seatSubject(s, "accept", a+"."+kind)) - case namesVerb(s.ByCaller, a): - t.Subscribe = append(t.Subscribe, seatSubject(s, "accept", a+".*")) - } - } - for _, e := range s.Emits { - switch { - case kind != "": - t.Publish = append(t.Publish, seatSubject(s, "event", e+"."+kind)) - case namesVerb(s.ByCaller, e): - t.Publish = append(t.Publish, seatSubject(s, "event", e+".*")) - } - } - if kind != "" { - for _, v := range s.Proofs { - t.Publish = append(t.Publish, seatSubject(s, "proof", v+"."+kind)) - } - } - } - for _, s := range uses { - if !s.isNewTraffic() { - continue - } - for _, a := range s.Accepts { - switch { - case namesVerb(s.ByCaller, a): - t.Publish = append(t.Publish, seatSubject(s, "accept", a+"."+module)) - case s.Kinded && module == s.DeclaredBy: - // Work for a kind is put on its queue by the bench's own router, and by no other user. - t.Publish = append(t.Publish, seatSubject(s, "accept", a+".*")) - } - } - for _, e := range s.Emits { - if namesVerb(s.ByCaller, e) { - t.Subscribe = append(t.Subscribe, seatSubject(s, "event", e+"."+module)) - } - } - for _, b := range s.Records { - if !safeSubject.MatchString(b) { - continue - } - t.Records = append(t.Records, "$JS.API.DIRECT.GET.KV_"+b+".$KV."+b+"."+module+".>") - } - if s.PulledByKind && len(s.Accepts) > 0 && safeSubject.MatchString(s.Name) { - t.Records = append(t.Records, DeadLetterReads(seatStreamName(s.Name))...) - } - } - for _, w := range watches { - if w.Kinded { - for _, e := range w.Emits { - t.Subscribe = append(t.Subscribe, seatSubject(w, "event", e+".*")) - } - if module == w.DeclaredBy { - // A code is answered by the bench's own router, and by no other watcher. - for _, v := range w.Proofs { - t.Answers = append(t.Answers, seatSubject(w, "proof", v+".*")) - } - } - } - } - t.Publish = unique(t.Publish) - t.Subscribe = unique(t.Subscribe) - t.Answers = unique(t.Answers) - t.Records = unique(t.Records) - sort.Slice(t.Workers, func(i, j int) bool { return t.Workers[i].Consumer < t.Workers[j].Consumer }) - return t -} - -// DeadLetterReads is what a user of a seat pulled by kind is granted to hear which of the asks it put the seat's workers -// gave up on (novox/hq issue 734): an ordered consumer on DEAD_LETTERS filtered to the copies the controller keeps of that -// seat's stream (DeadLetterSubject(stream, *)), pulled, and its deletion. A copy carries the ask's own message id -// (Mesh-Dead-Msg-Id), so the asker ends the step it waits for instead of waiting for ever; it reads the seat's dead -// letters and no other stream's, and changes none. -func DeadLetterReads(stream string) []string { - return []string{ - "$JS.API.STREAM.INFO." + DeadLettersStream, - "$JS.API.CONSUMER.CREATE." + DeadLettersStream + ".*." + DeadLetterSubject(stream, ">"), - "$JS.API.CONSUMER.MSG.NEXT." + DeadLettersStream + ".*", - "$JS.API.CONSUMER.DELETE." + DeadLettersStream + ".*", - } -} - -// seatTrafficGrants is the bus permissions seat traffic needs: the subjects themselves, and the JetStream API a -// worker is pulled and acknowledged through and a record is read through. -func seatTrafficGrants(t SeatTraffic) (pub, sub []string) { - pub = append(pub, t.Publish...) - sub = append(sub, t.Subscribe...) - sub = append(sub, t.Answers...) - for _, w := range t.Workers { - pub = append(pub, - "$JS.API.CONSUMER.INFO."+w.Stream+"."+w.Consumer, - "$JS.API.CONSUMER.MSG.NEXT."+w.Stream+"."+w.Consumer, - "$JS.ACK."+w.Stream+"."+w.Consumer+".>") - } - pub = append(pub, t.Records...) - return pub, sub -} - // isNewTraffic says whether a seat carries any of the rules above, so a seat that carries none is // composed exactly as before them. func (s Seat) isNewTraffic() bool { return s.Kinded || len(s.ByCaller) > 0 || len(s.Proofs) > 0 || len(s.Records) > 0 || s.PulledByKind } - -// plainVerbs is a seat's accepts or emits with those the rules above compose taken out: a verb named by -// its caller and every verb of a kinded bench are composed by SeatTrafficOf and nowhere else. -func plainVerbs(s Seat, verbs []string) []string { - if s.Kinded { - return nil - } - var out []string - for _, v := range verbs { - if !namesVerb(s.ByCaller, v) { - out = append(out, v) - } - } - return out -} - -// SeatTrafficObjects is the work queues and workers the seat traffic of every composed user implies -// (novox/hq ADR 0259 §3): a queue for each seat a holder takes work from, and each holder's worker on it — -// one per kind on a kinded bench, filtered to that kind, so the kinds never take each other's work. Only -// seats carrying the rules above; the mesh's own seats' queues are RaiseSeats'. -func SeatTrafficObjects(users []Principal) ([]Stream, []Consumer) { - streams := map[string]Stream{} - consumers := map[string]Consumer{} - add := func(module string, holds []Seat) { - for _, w := range SeatTrafficOf(module, holds, nil, nil).Workers { - seat, byKind := "", false - for _, s := range holds { - if seatStreamName(s.Name) == w.Stream { - seat, byKind = s.Name, s.PulledByKind - } - } - streams[w.Stream] = Stream{ - Name: w.Stream, - Subjects: []string{"mesh.seat." + seat + ".accept.>"}, - Retention: RetentionWorkQueue, - MaxAge: 7 * 24 * 60 * 60, - Why: "work submitted to the " + seat + " seat; its holders take it, each kind its own, and it queues while nobody does", - } - c := Consumer{ - Name: w.Consumer, - Stream: w.Stream, - Filters: []string{w.Filter}, - AckWaitSeconds: 60, - // No bound on redelivery: a channel away for a day keeps its work, offered again later and - // later by its holder's runtime (novox/hq ADR 0259; the correctness review of 2026-10-08). - MaxDeliver: 0, - Why: module + " holds " + seat + "; it pulls one ask at a time and acknowledges once it has " + - "recorded it, so a crash redelivers rather than loses", - } - if byKind { - // A build seat's ask is offered a bounded number of times (issue 1907): the holder that takes it past - // mesh-sdk delivery.AskRuns ends it failed saying it is poison, and the bound stops a run that kills - // every node it reaches even when that holder dies too. - c.MaxDeliver = BuildAskMaxDeliver - c.Why = module + " holds " + seat + "; each holder pulls an ask when it has a free slot, and an ask that " + - "no run ends is offered at most " + fmt.Sprint(BuildAskMaxDeliver) + " times, its step failed as poison" - } - consumers[w.Consumer] = c - } - } - for _, p := range users { - switch p.Kind { - case KindModule: - add(p.Module, p.Holds) - case KindNodeTools: - for _, d := range p.Carries { - add(d.Module, d.Holds) - } - } - } - var ss []Stream - for _, s := range streams { - ss = append(ss, s) - } - sort.Slice(ss, func(i, j int) bool { return ss[i].Name < ss[j].Name }) - var cs []Consumer - for _, c := range consumers { - cs = append(cs, c) - } - sort.Slice(cs, func(i, j int) bool { return cs[i].Name < cs[j].Name }) - return ss, cs -} - -// trustedTraffic is why a module's seat traffic is the trusted holder's (novox/hq ADR 0259 §8), or "": it -// says a seat's event to one caller each (a warrant), or holds a kind of a kinded bench that proves its sender. -func trustedTraffic(d Declared) string { - for _, s := range d.Holds { - for _, e := range s.Emits { - if namesVerb(s.ByCaller, e) { - return "it says " + s.Name + "'s " + e + " to one caller each" - } - } - if s.Kinded && namesVerb(s.Capabilities, "verified-sender") { - return "it holds " + s.Name + " of kind " + s.Kind + ", which proves its sender" - } - } - return "" -} - -// TrafficQueues is the work queue of every seat naming its caller or its kind that accepts work, held or not -// (novox/hq ADR 0259 §3): what is submitted before a holder is assigned waits for it. -func TrafficQueues(seats []Seat) []Stream { - var out []Stream - seen := map[string]bool{} - for _, s := range seats { - if len(s.Accepts) == 0 || !s.isNewTraffic() || seen[s.Name] { - continue - } - seen[s.Name] = true - out = append(out, Stream{Name: seatStreamName(s.Name), Subjects: []string{"mesh.seat." + s.Name + ".accept.>"}, - Retention: RetentionWorkQueue, MaxAge: 7 * 24 * 60 * 60, - Why: "work submitted to the " + s.Name + " seat; its holders take it, each kind its own, and it queues while nobody does"}) - } - sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) - return out -} diff --git a/go/buscompose/state.go b/go/buscompose/state.go index b64461e..2f090aa 100644 --- a/go/buscompose/state.go +++ b/go/buscompose/state.go @@ -2,29 +2,12 @@ package buscompose import ( "fmt" - "git.novox.be/novox/mesh-sdk/go/announce" - "git.novox.be/novox/mesh-sdk/go/delivery" - "sort" - "strings" - "time" ) -// A module's state on the bus (novox/hq ADR 0201, design 32 §4, design 25 §3). -// -// A module names the state it keeps (`state`) and the state of others it reads (`reads`), and each -// is a key-value bucket: the server's own last-per-subject stream with direct reads, delete markers -// and watches, which is the state relationship the mesh already uses for declarations, opened to -// modules. The controller creates every bucket from the catalogue — from registration, like a -// seat's stream, so a reader may watch before the owner runs anywhere — and no module can. -// -// Pure, like everything else in this package that decides what the bus holds; jetstream.go is the -// part that asks a server. - // The mesh's caps on a bucket, the same for every module: a value is a piece of state, not a file, // and a bucket that grew without bound would be one module filling the bus's disk for everyone. const ( StateMaxValueBytes = 256 * 1024 - StateMaxBytes = 64 * 1024 * 1024 ) // A Bucket is one module's declared state as the bus holds it. @@ -43,9 +26,6 @@ type Bucket struct { KeyTTL bool } -// KeyTTLMarkerTTL is how long the marker an expired key leaves is kept: long enough for a watcher to hear it. -const KeyTTLMarkerTTL = time.Minute - // BucketName is the bucket a module's state lives in: the module and the local name joined by an // underscore, which neither may contain, so two modules can never derive one bucket. func BucketName(module, name string) string { return module + "_" + name } @@ -61,310 +41,11 @@ func (b Bucket) Why() string { b.Module, b.Name, b.Module, b.Module) } -// bucketOfRead is the bucket a read names, `.`, or false when it names none. -func bucketOfRead(read string) (string, bool) { - at := strings.LastIndex(read, ".") - if at <= 0 || at == len(read)-1 { - return "", false - } - module, name := read[:at], read[at+1:] - if !safeSubject.MatchString(module) || !safeSubject.MatchString(name) { - return "", false - } - return BucketName(module, name), true -} - -// readGrants is what reaching a bucket's keys needs: binding to it, a direct get, an ordered consumer for listing and -// watching and the deletion of that consumer and its flow control. key is one key's subject tail, a pattern such as -// `one.>` for a prefix of keys, or empty for every key. The bucket is made with AllowDirect, so a get is the direct get -// and never the stream's message get. -func readGrants(bucket, key string) []string { - stream := "KV_" + bucket - out := []string{"$JS.API.STREAM.INFO." + stream} - if key == "" { - out = append(out, - "$JS.API.DIRECT.GET."+stream+".>", - "$JS.API.CONSUMER.CREATE."+stream+".>") - } else { - out = append(out, - "$JS.API.DIRECT.GET."+stream+".$KV."+bucket+"."+key, - "$JS.API.CONSUMER.CREATE."+stream+".*.$KV."+bucket+"."+key) - } - return append(out, - "$JS.API.CONSUMER.DELETE."+stream+".>", - "$JS.FC."+stream+".>") -} - -// NodeKeysGrants is what a node's node-engine is granted of a bucket it writes by node, `.>` and no other key -// (novox/hq ADR 0429 §1: each key one writer, enforced by credentials): the writes of those keys and what a get, -// create, update and watch of them need. The node name is checked as every subject token is. -func NodeKeysGrants(bucket, node string) ([]string, error) { - if !safeSubject.MatchString(node) { - return nil, fmt.Errorf("%q cannot be part of a subject: a permission is a subject pattern, and this would widen it", node) - } - return append([]string{"$KV." + bucket + "." + node + ".>"}, readGrants(bucket, node+".>")...), nil -} - -// stateGrants is what a principal publishes to reach the state its modules keep and read: for every -// bucket, binding to it, reading a key directly, and an ordered consumer for listing and watching, -// created and deleted on the bucket's own stream, with its flow control answered; for a bucket an -// owner keeps, writing under the bucket's own subjects too. -// -// **Measured against a running server, 2026-10-04** (novox/hq research 024), and each one is there -// because leaving it out failed: without STREAM.INFO nothing binds; without DIRECT.GET nothing is -// read; without CONSUMER.CREATE no key is listed and nothing is watched; without CONSUMER.DELETE a -// watch cannot be stopped and lingers on the server. A write outside these is refused by the server -// — and reaches the writer as a timeout, not a refusal, which is why the runtime refuses first. -// -// **One key, where the state is per machine** (novox/hq ADR 0260). The owner's instance on a machine -// writes and reads the key named for that machine, and a holder granted a read for its machine reads -// that key: a direct get of the key's own subject, and a consumer whose filter is that subject — the -// server's create-with-filter form, which a client uses for a watch of one key and which the server -// holds to the filter it names. What cannot be narrowed by key: binding (STREAM.INFO), deleting a -// consumer (named at random by the client) and flow control; none of them reads or writes a value. -func stateGrants(a stateAccess) []string { - var out []string - read := func(bucket, key string) { out = append(out, readGrants(bucket, key)...) } - machineKey := "" - if safeSubject.MatchString(a.Node) { - machineKey = a.Node - } - perMachine := map[string]bool{} - for _, n := range a.PerMachine { - perMachine[n] = true - } - for _, name := range a.Keeps { - if !safeSubject.MatchString(name) { - continue - } - bucket := BucketName(a.Module, name) - if perMachine[name] { - if machineKey == "" { - continue // a machine with no usable name reaches none of it - } - read(bucket, machineKey) - out = append(out, "$KV."+bucket+"."+machineKey) - continue - } - read(bucket, "") - out = append(out, "$KV."+bucket+".>") - } - // **And it says what it keeps** (novox/hq ADR 0495 clause 2, issue 1790): a module keeping state announces it on its - // own event subject, from its code; refused, its readers are read as before. - if len(a.Keeps) > 0 && safeSubject.MatchString(a.Module) { - out = append(out, announce.Subject(a.Module)) - } - for _, r := range a.Reads { - if bucket, ok := bucketOfRead(r); ok { - if NodeKeyedReads[r] { - if machineKey != "" { - read(bucket, machineKey+".>") - } - continue - } - read(bucket, "") - } - } - for _, k := range a.KeyedReads { - if bucket, ok := bucketOfRead(k.Read); ok && safeSubject.MatchString(k.Key) { - read(bucket, k.Key) - } - } - return out -} - -// NodeKeyedReads are the states keyed `.>` 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_. -func StreamCreatedAdvisory(bucket string) string { - return "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_" + bucket -} - -// stateCreatedHeard is what a principal subscribes to so that a bucket it may bind and that is not on the bus yet is -// bound when it appears (novox/hq issue 1790, 2026-10-26: since the grants part creates a manifest's buckets, ADR 0547, -// a module's delivery can start it before its new state exists): the bus's advisory that the bucket's stream was -// created, for each bucket stateGrants lets it bind, and no other. Read-only: the server publishes it in the mesh's own -// account, and hearing it changes nothing. -func stateCreatedHeard(a stateAccess) []string { - var out []string - seen := map[string]bool{} - add := func(bucket string) { - if !seen[bucket] { - seen[bucket] = true - out = append(out, StreamCreatedAdvisory(bucket)) - } - } - perMachine := map[string]bool{} - for _, n := range a.PerMachine { - perMachine[n] = true - } - for _, name := range a.Keeps { - if !safeSubject.MatchString(name) || (perMachine[name] && !safeSubject.MatchString(a.Node)) { - continue - } - add(BucketName(a.Module, name)) - } - for _, r := range a.Reads { - if bucket, ok := bucketOfRead(r); ok { - add(bucket) - } - } - for _, k := range a.KeyedReads { - if bucket, ok := bucketOfRead(k.Read); ok && safeSubject.MatchString(k.Key) { - add(bucket) - } - } - return out -} - // KeyedRead is one key of another module's state a principal reads, and no other key of it. type KeyedRead struct { Read string `json:"read"` Key string `json:"key"` } -// stateAccess is what one principal may do with state: the module's own buckets (some per machine), -// what it reads whole, and what it reads for its machine's key alone. -type stateAccess struct { - Module, Node string - Keeps, PerMachine []string - Reads []string - KeyedReads []KeyedRead -} - -// perMachineNames is the local names of a module's buckets keyed by machine. -func perMachineNames(buckets []Bucket) []string { - var out []string - for _, b := range buckets { - if b.PerMachine { - out = append(out, b.Name) - } - } - return out -} - -// stateIssuedFor is every bucket a module's code may reach on a machine, as its membership lists them. -func stateIssuedFor(d Declared, node string) []StateIssued { - var out []StateIssued - for _, b := range d.State { - issued := StateIssued{Name: b.Name, Bucket: BucketName(d.Module, b.Name), Writes: true} - if b.PerMachine { - issued.Key = node - } - out = append(out, issued) - } - for _, r := range d.Reads { - if bucket, ok := bucketOfRead(r); ok { - out = append(out, StateIssued{Name: r, Bucket: bucket}) - } - } - for _, k := range d.KeyedReads { - if bucket, ok := bucketOfRead(k.Read); ok { - out = append(out, StateIssued{Name: k.Read, Bucket: bucket, Key: k.Key}) - } - } - return out -} - -// stateNames is the local names of a module's own buckets. -func stateNames(buckets []Bucket) []string { - out := make([]string, 0, len(buckets)) - for _, b := range buckets { - out = append(out, b.Name) - } - return out -} - -// A BucketAsserter is the part of a JetStream connection bucket assertion needs. -type BucketAsserter interface { - // EnsureBucket creates the bucket if absent and brings its options to match if present, never - // discarding what it holds. - EnsureBucket(b Bucket) error - // BucketNames is every key-value bucket on the server. - BucketNames() ([]string, error) -} - -// RaiseBuckets asserts every declared bucket and answers the buckets on the server that nothing -// declares any more. -// -// **Those are reported, never removed** (novox/hq ADR 0201, ADR 0030): what a module stored is -// data, and a manifest edited, a module renamed or a catalogue entry dropped is an ordinary day's -// work that must not take data with it. Removing one is a person's act. -func RaiseBuckets(a BucketAsserter, buckets []Bucket) (undeclared []string, err error) { - sorted := append([]Bucket(nil), buckets...) - sort.Slice(sorted, func(i, j int) bool { return sorted[i].Bucket() < sorted[j].Bucket() }) - declared := map[string]bool{} - for _, b := range sorted { - if err := a.EnsureBucket(b); err != nil { - return nil, fmt.Errorf("asserting %s's state %q: %w", b.Module, b.Name, err) - } - declared[b.Bucket()] = true - } - names, err := a.BucketNames() - if err != nil { - return nil, fmt.Errorf("listing the bus's state: %w", err) - } - for _, n := range names { - // A seat's cancelled set is the mesh's own (novox/hq ADR 0219), not a module's state. The ended controller's - // buckets are undeclared like any other until they are removed (ADR 0590 clause 6). - if !declared[n] && !IsCancelledSet(n) { - undeclared = append(undeclared, n) - } - } - sort.Strings(undeclared) - return undeclared, nil -} - // ResolvedBucket is mesh-config's state of resolved values, keyed `..`. const ResolvedBucket = "mesh-config_resolved" - -// NodeComposeReads is what a node's agent reads to compose its own modules (novox/hq ADR 0547 §2, ADR 0567 §3, §9, -// issue 1126): every key of the seat mesh-placement's placement and candidates (both the assigner's states), of the -// indexer's modules and manifests and of the builder's builds, and of mesh-config's resolved values only its own node's, -// `.>`. Every candidate key, not only `*.`: a node answering its candidate composes with the whole -// proposal laid over (the seed places every node at once, and a binding crosses nodes), and nothing in a placement is -// secret. Reads only; the node is checked as every subject token is by the caller's principal. -func NodeComposeReads(node string) []string { - var out []string - for _, bucket := range []string{delivery.PlacementBucket, delivery.PlacementCandidatesBucket, delivery.ModulesBucket, - delivery.ManifestsBucket, delivery.BuilderBuildsBucket} { - out = append(out, readGrants(bucket, "")...) - } - return append(out, readGrants(ResolvedBucket, node+".>")...) -} - -// NodeComposeAdvisories are the stream-created advisories of the seat mesh-placement's placement and candidates -// (ADR 0567 §9): what a node agent hears to bind each once it is made. Those two and no wider. -func NodeComposeAdvisories() []string { - return []string{StreamCreatedAdvisory(delivery.PlacementBucket), StreamCreatedAdvisory(delivery.PlacementCandidatesBucket)} -} - -// ServedReadGrants is the read of each key of mesh-config's resolved values named, `..` -// (Principal.ServedReads, issue 1126): a get and a watch of that key alone. Each part is checked as every subject token -// is, so a key cannot widen the grant. -func ServedReadGrants(keys []string) ([]string, error) { - var out []string - seen := map[string]bool{} - for _, k := range keys { - parts := strings.Split(k, ".") - if len(parts) != 3 { - return nil, fmt.Errorf("%q is not ..", k) - } - for _, part := range parts { - if !safeSubject.MatchString(part) { - return nil, fmt.Errorf("%q cannot be part of a subject: a permission is a subject pattern, and this would widen it", part) - } - } - for _, g := range readGrants(ResolvedBucket, k) { - if !seen[g] { - seen[g] = true - out = append(out, g) - } - } - } - return out, nil -} diff --git a/go/buscompose/streams.go b/go/buscompose/streams.go index 3544acd..637793d 100644 --- a/go/buscompose/streams.go +++ b/go/buscompose/streams.go @@ -1,23 +1,9 @@ package buscompose import ( - "fmt" - "sort" "strings" ) -// The mesh's own streams. -// -// **These and no more** (novox/hq ADR 0116 task 1.4, as revised by ADR 0118; the two that keep what a -// consumer gave up on added for issue 330). An earlier -// reading had the controller create *every* stream at genesis, from a fixed set. That is only the -// mesh's own half: a seat's streams are created when the module declaring it is registered, and a -// module's durable consumers when it is assigned — neither of which has happened at genesis. What -// is here is the foundation, which exists before any module does. -// -// The controller is the only writer of stream definitions (design 25 §3). A module declares -// nothing about them and cannot reach the JetStream API to make one. - // Retention is how a stream decides what to keep, which is the whole of what distinguishes the // mesh's four relationships on the wire (design 29 §4). type Retention string @@ -117,31 +103,6 @@ func DeadLetterOf(subject string) (stream, consumer string, ok bool) { return stream, consumer, ok && stream != "" && consumer != "" && !strings.Contains(consumer, ".") } -// AgainSubject is where an event given up on is delivered again to the one consumer that gave it up, -// and to nobody else: `mesh.again..` and the original subject without its `mesh.`. Every -// consumer on EVENTS filters its own (AgainFilter), and a module's runtime reads the event's key from -// the tokens around `.event.`, so the handler sees the same key it saw the first time. -func AgainSubject(consumer, original string) string { - return againPrefix + consumer + "." + strings.TrimPrefix(original, "mesh.") -} - -// AgainFilter is the one consumer's share of the subjects events are delivered again on. -func AgainFilter(consumer string) string { return againPrefix + consumer + ".>" } - -// OriginalOfAgain is the subject an event delivered again was first published on; false for a subject -// that is not one delivered again. -func OriginalOfAgain(subject string) (string, bool) { - rest, found := strings.CutPrefix(subject, againPrefix) - if !found { - return "", false - } - _, original, ok := strings.Cut(rest, ".") - if !ok || original == "" { - return "", false - } - return "mesh." + original, true -} - // MeshStreams is the foundation set, in the order a person reads it. // // **EVENTS filters on the `event` token**, which is the reason that token exists. A module's @@ -223,80 +184,6 @@ const ProvAppliedStream = "PROV_APPLIED" // all (ProvisionerAppliedSubject). const ProvAppliedSubjects = "prov.applied.>" -// DeliverSubjectFor is where a push consumer's messages land. -// -// **Per consumer, which means per stream as well as per name** (novox/hq 04-ISSUES/146). A push -// consumer delivers onto an ordinary subject, and everything subscribed to that subject gets a -// copy. The controller holds a consumer called `controller` on CONTROL and another called -// `controller` on EVENTS; while both were given `_DELIVER.controller`, the one process holding -// both subscriptions acted on every message twice — a joining machine was enrolled twice from one -// request, and the second enrolment minted a credential that replaced the one the machine had just -// been handed. Every report and every followed event doubled the same way, silently: nothing is -// redelivered, no count is wrong, the work simply happens twice. -// -// The stream belongs in it because the pair is what identifies a consumer — the server scopes a -// durable's name to its stream, and this subject was the one place that scoping was dropped. It -// stays inside what a controller may already subscribe (`_DELIVER.controller.>`). -func DeliverSubjectFor(c Consumer) string { - if c.DeliverSubject != "" { - return c.DeliverSubject - } - return "_DELIVER." + c.Name + "." + c.Stream -} - -// An Asserter is the part of a JetStream connection stream assertion needs. Narrow on purpose: it -// keeps this testable without a server, and keeps the client library out of everything that only -// wants to know what the streams are. -type Asserter interface { - // EnsureStream creates the stream if absent and updates it to match if present. It must be - // idempotent: the controller asserts on every start, not only at genesis. - EnsureStream(s Stream) error -} - -// AssertMeshStreams brings the foundation set into being, in order, and says which one failed -// rather than that something did. -// -// Asserted on every start rather than created once at genesis, because a stream that was deleted, -// or a mesh raised from a restored backup, must converge rather than run without the guarantee -// its messages assume. Idempotence is the whole requirement. -func AssertMeshStreams(a Asserter) error { - for _, s := range MeshStreams() { - if err := a.EnsureStream(s); err != nil { - return fmt.Errorf("asserting stream %s: %w", s.Name, err) - } - } - return nil -} - -// Overlaps reports subject filters claimed by more than one stream. -// -// **Corrected against the server**: an earlier version of this comment said NATS accepts -// overlapping streams and stores the message twice. It does not — it refuses the second stream -// with "subjects overlap with an existing stream" (verified against nats-server 2.10). The check -// still earns its place, for a different reason: the server's refusal arrives when the controller -// is applying, naming one stream, at a moment when the mesh is half-configured. This one arrives -// where the set is written, names both, and cannot reach a running mesh. -// -// It also decides a design question. Because overlap is refused rather than merged, a shared -// EVENTS stream and a per-module stream cannot coexist — the module's would be refused — so -// "one stream for most, its own for a module that wants different retention" is not an option -// the server allows. It is all of one or all of the other. -func Overlaps() []string { - seen := map[string]string{} - var clashes []string - for _, s := range MeshStreams() { - for _, subject := range s.Subjects { - if first, ok := seen[subject]; ok { - clashes = append(clashes, fmt.Sprintf("%s and %s both claim %s", first, s.Name, subject)) - continue - } - seen[subject] = s.Name - } - } - sort.Strings(clashes) - return clashes -} - // The ended controller's consumers' names (novox/hq ADR 0590 clause 6): kept only so its leftovers are named for their // removal (ControllerLeftoverConsumers) and the watchman can read whether `controller` on DEAD_LETTER_NOTICES still // stands. @@ -305,50 +192,5 @@ const ( MergesConsumerName = "controller-merges" ) -// AsksSeat is the seat an ask is made on and its warrant heard from (novox/hq ADR 0259): the router's. -const AsksSeat = "operator-channel" - -// The provider standing events, by their local names. Written here as well as in the catalogue -// (catalogue.ProvisionerEvents), which this package cannot import; a test keeps them agreeing. -const ( - ProvisionerFailing = "provisioner.failing" - ProvisionerRecovered = "provisioner.recovered" - ProvisionerRetirement = "provisioner.retirement" -) - -// moduleEventSubject is where one module's event lands. The same derivation PermissionsFor uses, so -// what the controller subscribes and what the emitter is permitted to publish cannot drift apart. -func moduleEventSubject(module, event string) string { - return "mesh.mod." + module + ".event." + event -} - -// seatEventSubject is where a role's own event lands, derived the same way a holder's permission is. -func seatEventSubject(seat, verb string) string { - return "mesh.seat." + seat + ".event." + verb -} - // EventsStream holds every module's and every role's events, a build's log among them. const EventsStream = "EVENTS" - -// MeshConsumers are the mesh's own durable consumers, beside those derived from declarations: none since the controller -// ended (novox/hq ADR 0590), whose consumers they were. -func MeshConsumers() []Consumer { return nil } - -// Ensurer is the part of a JetStream connection consumer assertion needs, narrow for the reason -// Asserter is. -type Ensurer interface { - EnsureConsumer(c Consumer) error -} - -// AssertMeshConsumers brings the mesh's own consumers into being, and says which one failed. -// -// After the streams, necessarily: a consumer on a stream that does not exist is refused, and the -// refusal names the stream rather than the order. -func AssertMeshConsumers(e Ensurer) error { - for _, c := range MeshConsumers() { - if err := e.EnsureConsumer(c); err != nil { - return fmt.Errorf("asserting consumer %s on %s: %w", c.Name, c.Stream, err) - } - } - return nil -} diff --git a/go/buscompose/testdata/composed.conf b/go/buscompose/testdata/composed.conf deleted file mode 100644 index 0fe0bb6..0000000 --- a/go/buscompose/testdata/composed.conf +++ /dev/null @@ -1,73 +0,0 @@ -# Composed by the mesh. Do not edit: the next composition overwrites it. -# Accounts and permissions are derived from what each module declares and nothing -# else (novox/hq ADR 0043, design 29 §2). - -port: 4222 -http: 127.0.0.1:8222 - -tls { - cert_file: "/tls/tls.crt" - key_file: "/tls/tls.key" - ca_file: "/tls/ca.crt" -} - -jetstream { - store_dir: "/data" -} - -# The mesh's users, composed by the grants part. Do not edit: the next -# composition overwrites it. Permissions are derived from what each module -# declares and nothing else (novox/hq ADR 0043, design 29 §2). - -accounts { - MESH { - jetstream: enabled - imports = [ - { stream: { account: "$SYS", subject: "$SYS.ACCOUNT.MESH.CONNECT" }, to: "mesh.bus.connect" } - { stream: { account: "$SYS", subject: "$SYS.ACCOUNT.MESH.DISCONNECT" }, to: "mesh.bus.disconnect" } - { service: { account: "$SYS", subject: "$SYS.REQ.ACCOUNT.MESH.CONNZ" }, to: "mesh.bus.connz" } - ] - users = [ - { user: "enrol.one", password: "$2a$11$eeeeeeeeeeeeeeeeeeeeee", permissions: { - publish: { allow: ["mesh.seat.mesh-enrolment.call.redeem.enrol~one"] } - subscribe: { allow: ["_INBOX.enrol.one.>"] } - } } - { user: "node.one", password: "$2a$11$nnnnnnnnnnnnnnnnnnnnnn", permissions: { - publish: { allow: ["$JS.ACK.NODES.one.>", "$JS.API.CONSUMER.CREATE.KV_composed-one.>", "$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.one.>", "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_candidates.>", "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_placement.>", "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_wants.*.$KV.mesh-delivery-assigner_wants.want.one.>", "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_builds.>", "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_manifests.>", "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_modules.>", "$JS.API.CONSUMER.CREATE.KV_mesh-host_acts.*.$KV.mesh-host_acts.one.>", "$JS.API.CONSUMER.CREATE.KV_mesh-host_data.*.$KV.mesh-host_data.one.>", "$JS.API.CONSUMER.CREATE.KV_mesh-host_health.*.$KV.mesh-host_health.one", "$JS.API.CONSUMER.CREATE.KV_mesh-host_packages.*.$KV.mesh-host_packages.one", "$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_provisions.*.$KV.mesh-provisioner_provisions.one.>", "$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.*.node", "$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.one.>", "$JS.API.CONSUMER.CREATE.OBJ_declaration-files-one.*.$O.declaration-files-one.C.*", "$JS.API.CONSUMER.DELETE.KV_composed-one.>", "$JS.API.CONSUMER.DELETE.KV_mesh-config_resolved.>", "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_candidates.>", "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_placement.>", "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_wants.>", "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_builds.>", "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_manifests.>", "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_modules.>", "$JS.API.CONSUMER.DELETE.KV_mesh-host_acts.>", "$JS.API.CONSUMER.DELETE.KV_mesh-host_data.>", "$JS.API.CONSUMER.DELETE.KV_mesh-host_health.>", "$JS.API.CONSUMER.DELETE.KV_mesh-host_packages.>", "$JS.API.CONSUMER.DELETE.KV_mesh-provisioner_provisions.>", "$JS.API.CONSUMER.DELETE.KV_node-engine_have.>", "$JS.API.CONSUMER.DELETE.OBJ_declaration-files-one.*", "$JS.API.CONSUMER.INFO.NODES.one", "$JS.API.DIRECT.GET.KV_composed-one.>", "$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.one.>", "$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_candidates.>", "$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_placement.>", "$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_wants.$KV.mesh-delivery-assigner_wants.want.one.>", "$JS.API.DIRECT.GET.KV_mesh-delivery-builder_builds.>", "$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_manifests.>", "$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_modules.>", "$JS.API.DIRECT.GET.KV_mesh-host_acts.$KV.mesh-host_acts.one.>", "$JS.API.DIRECT.GET.KV_mesh-host_data.$KV.mesh-host_data.one.>", "$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.one", "$JS.API.DIRECT.GET.KV_mesh-host_packages.$KV.mesh-host_packages.one", "$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.one.>", "$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.*.node", "$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.one.>", "$JS.API.DIRECT.GET.OBJ_declaration-files-one.$O.declaration-files-one.M.*", "$JS.API.STREAM.INFO.KV_composed-one", "$JS.API.STREAM.INFO.KV_mesh-config_resolved", "$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_candidates", "$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_placement", "$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_wants", "$JS.API.STREAM.INFO.KV_mesh-delivery-builder_builds", "$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_manifests", "$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_modules", "$JS.API.STREAM.INFO.KV_mesh-host_acts", "$JS.API.STREAM.INFO.KV_mesh-host_data", "$JS.API.STREAM.INFO.KV_mesh-host_health", "$JS.API.STREAM.INFO.KV_mesh-host_packages", "$JS.API.STREAM.INFO.KV_mesh-provisioner_provisions", "$JS.API.STREAM.INFO.KV_node-engine_have", "$JS.FC.KV_composed-one.>", "$JS.FC.KV_mesh-config_resolved.>", "$JS.FC.KV_mesh-delivery-assigner_candidates.>", "$JS.FC.KV_mesh-delivery-assigner_placement.>", "$JS.FC.KV_mesh-delivery-assigner_wants.>", "$JS.FC.KV_mesh-delivery-builder_builds.>", "$JS.FC.KV_mesh-delivery-indexer_manifests.>", "$JS.FC.KV_mesh-delivery-indexer_modules.>", "$JS.FC.KV_mesh-host_acts.>", "$JS.FC.KV_mesh-host_data.>", "$JS.FC.KV_mesh-host_health.>", "$JS.FC.KV_mesh-host_packages.>", "$JS.FC.KV_mesh-provisioner_provisions.>", "$JS.FC.KV_node-engine_have.>", "$JS.FC.OBJ_declaration-files-one.>", "$KV.mesh-host_acts.one.>", "$KV.mesh-host_data.one.>", "$KV.mesh-host_health.one", "$KV.mesh-host_packages.one", "$KV.node-engine_have.one.>", "$SRV.INFO", "$SRV.INFO.>", "$SRV.PING", "$SRV.PING.>", "$SRV.PING.node-tools.one", "mesh.control.one.>", "mesh.seat.node-backup.call.backed-up.one.node~one", "mesh.seat.node-backup.call.now.one.node~one", "mesh.seat.node-backup.tool.backed-up.one", "mesh.seat.node-backup.tool.now.one", "mesh.seat.node-engine.event.*.one"] } - subscribe: { allow: ["$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_candidates", "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_placement", "$SRV.INFO", "$SRV.INFO.node-engine", "$SRV.INFO.node-engine.>", "$SRV.PING", "$SRV.PING.node-engine", "$SRV.PING.node-engine.>", "$SRV.STATS", "$SRV.STATS.node-engine", "$SRV.STATS.node-engine.>", "_DELIVER.one", "_DELIVER.one.>", "_INBOX.node.one.>", "mesh.mod.restic.event.backed-up", "mesh.node.one.ask.access-act", "mesh.node.one.ask.hand-over", "mesh.node.one.ask.judge-directories", "mesh.node.one.ask.move-secret", "mesh.node.one.ask.report", "mesh.node.one.ask.setuid-search", "mesh.node.one.declare", "mesh.seat.node-engine.call.*.one.*", "mesh.seat.node-engine.tool.*.one"] } - allow_responses: { max: 1, ttl: "1m" } - } } - { user: "one.mesh-delivery-watchman", password: "$2a$11$wwwwwwwwwwwwwwwwwwwwww", permissions: { - publish: { allow: ["$JS.ACK.DEAD_LETTER_NOTICES.mesh-delivery-watchman.>", "$JS.ACK.EVENTS.one_mesh-delivery-watchman.>", "$JS.API.CONSUMER.CREATE.DEAD_LETTER_NOTICES.mesh-delivery-watchman", "$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.one.mesh-delivery-watchman", "$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.>", "$JS.API.CONSUMER.INFO.DEAD_LETTER_NOTICES.controller", "$JS.API.CONSUMER.INFO.DEAD_LETTER_NOTICES.mesh-delivery-watchman", "$JS.API.CONSUMER.INFO.EVENTS.one_mesh-delivery-watchman", "$JS.API.CONSUMER.MSG.NEXT.DEAD_LETTER_NOTICES.mesh-delivery-watchman", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.one_mesh-delivery-watchman", "$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.one.mesh-delivery-watchman", "$JS.API.DIRECT.GET.PROV_APPLIED.>", "$JS.API.STREAM.INFO.DEAD_LETTERS", "$JS.API.STREAM.INFO.DEAD_LETTER_NOTICES", "$JS.API.STREAM.INFO.KV_mesh-grants_memberships", "$JS.API.STREAM.INFO.PROV_APPLIED", "$JS.API.STREAM.MSG.GET.DEAD_LETTERS", "$JS.API.STREAM.MSG.GET.PROV_APPLIED", "$JS.FC.KV_mesh-grants_memberships.>", "mesh.bus.connz", "mesh.events.dead.>"] } - subscribe: { allow: ["$JS.EVENT.ADVISORY.CONSUMER.DELETED.>", "$SRV.INFO", "$SRV.INFO.mesh-delivery-watchman", "$SRV.INFO.mesh-delivery-watchman.>", "$SRV.PING", "$SRV.PING.mesh-delivery-watchman", "$SRV.PING.mesh-delivery-watchman.>", "$SRV.STATS", "$SRV.STATS.mesh-delivery-watchman", "$SRV.STATS.mesh-delivery-watchman.>", "_INBOX.one.mesh-delivery-watchman.>", "mesh.bus.connect", "mesh.bus.disconnect", "mesh.control.*.tools-alive", "mesh.events.dead.>", "mesh.mod.*.event.provisioner.failing", "mesh.mod.*.event.provisioner.recovered", "mesh.mod.mesh-delivery-watchman.call.>", "mesh.mod.mesh-delivery-watchman.tool.>", "mesh.mod.nats.event.bus.connection-closed", "mesh.mod.nats.event.bus.slow-consumer", "mesh.mod.power.event.*", "mesh.seat.mesh-self-check.event.checked", "prov.applied.>"] } - allow_responses: { max: 1, ttl: "1m" } - } } - { user: "one.nats", password: "$2a$11$bbbbbbbbbbbbbbbbbbbbbb", permissions: { - publish: { allow: ["$JS.API.STREAM.INFO.*", "$JS.API.STREAM.NAMES", "$JS.API.STREAM.SNAPSHOT.*", "$JS.SNAPSHOT.ACK.>"] } - subscribe: { allow: ["_INBOX.one.nats.>"] } - } } - { user: "one.telegram", password: "$2a$11$tttttttttttttttttttttt", permissions: { - publish: { allow: ["$JS.ACK.EVENTS.one_telegram.>", "$JS.ACK.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker.>", "$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.one.telegram", "$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.>", "$JS.API.CONSUMER.INFO.EVENTS.one_telegram", "$JS.API.CONSUMER.INFO.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.one_telegram", "$JS.API.CONSUMER.MSG.NEXT.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker", "$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.one.telegram", "$JS.API.STREAM.INFO.KV_mesh-grants_memberships", "$JS.FC.KV_mesh-grants_memberships.>", "mesh.seat.telegram-sender.event.delivered", "mesh.seat.telegram-sender.event.failed"] } - subscribe: { allow: ["$SRV.INFO", "$SRV.INFO.telegram", "$SRV.INFO.telegram.>", "$SRV.PING", "$SRV.PING.telegram", "$SRV.PING.telegram.>", "$SRV.STATS", "$SRV.STATS.telegram", "$SRV.STATS.telegram.>", "_INBOX.one.telegram.>", "mesh.mod.telegram.call.>", "mesh.mod.telegram.tool.>", "mesh.seat.telegram-sender.accept.send"] } - allow_responses: { max: 1, ttl: "1m" } - } } - { user: "two.audit", password: "$2a$11$aaaaaaaaaaaaaaaaaaaaaa", permissions: { - publish: { allow: ["$JS.ACK.EVENTS.two_audit.>", "$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.two.audit", "$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.>", "$JS.API.CONSUMER.INFO.EVENTS.two_audit", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.two_audit", "$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.two.audit", "$JS.API.STREAM.INFO.KV_mesh-grants_memberships", "$JS.FC.KV_mesh-grants_memberships.>"] } - subscribe: { allow: ["$SRV.INFO", "$SRV.INFO.audit", "$SRV.INFO.audit.>", "$SRV.PING", "$SRV.PING.audit", "$SRV.PING.audit.>", "$SRV.STATS", "$SRV.STATS.audit", "$SRV.STATS.audit.>", "_INBOX.two.audit.>", "mesh.mod.audit.call.>", "mesh.mod.audit.tool.>", "mesh.mod.shop.event.order.placed"] } - allow_responses: { max: 1, ttl: "1m" } - } } - { user: "two.shop", password: "$2a$11$ssssssssssssssssssssss", permissions: { - publish: { allow: ["$JS.ACK.EVENTS.two_shop.>", "$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.two.shop", "$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.>", "$JS.API.CONSUMER.INFO.EVENTS.two_shop", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.two_shop", "$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.two.shop", "$JS.API.STREAM.INFO.KV_mesh-grants_memberships", "$JS.FC.KV_mesh-grants_memberships.>", "mesh.mod.shop.event.order.placed", "mesh.seat.telegram-sender.accept.send"] } - subscribe: { allow: ["$SRV.INFO", "$SRV.INFO.shop", "$SRV.INFO.shop.>", "$SRV.PING", "$SRV.PING.shop", "$SRV.PING.shop.>", "$SRV.STATS", "$SRV.STATS.shop", "$SRV.STATS.shop.>", "_INBOX.two.shop.>", "mesh.mod.shop.call.>", "mesh.mod.shop.tool.>"] } - allow_responses: { max: 1, ttl: "1m" } - } } - ] - } - "$SYS" { - exports = [ - { stream: "$SYS.ACCOUNT.MESH.CONNECT", accounts: ["MESH"] } - { stream: "$SYS.ACCOUNT.MESH.DISCONNECT", accounts: ["MESH"] } - { service: "$SYS.REQ.ACCOUNT.MESH.CONNZ", accounts: ["MESH"] } - ] - } -} diff --git a/go/buscompose/testdata/controller-credentials.json b/go/buscompose/testdata/controller-credentials.json deleted file mode 100644 index ab11cf1..0000000 --- a/go/buscompose/testdata/controller-credentials.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "how": "Written by mesh-controller 0fbb5ef's own issueWith, sealProvisionerClient and heldProcessAccount (cmd/mesh-controller), run unchanged in a scratch integration test against a local store with the default seats seeded, on 2026-10-27 (issue 774): each row is the exact value the controller handed the vault. Never edit a row by hand: capture it again.", - "rows": [ - { - "name": "module", - "held": "{\"url\":\"nats://10.0.0.1:4222\",\"fingerprint\":\"ab:cd\",\"node\":\"laptop\",\"module\":\"cap-bot\",\"user\":\"laptop.cap-bot\",\"password\":\"pw-module\",\"claims\":[{\"seat\":\"mesh-vault\",\"scope\":\"mesh\",\"serves\":[\"get\",\"accept\",\"secret-ask\",\"issued\",\"rotate\",\"move-in\",\"node-key\",\"own\",\"move-asks\"]},{\"seat\":\"node-service-manager\",\"scope\":\"node\",\"serves\":[\"status\"]},{\"seat\":\"mesh-controller\",\"scope\":\"mesh\",\"serves\":[\"tools\",\"calls\",\"status\",\"nodes\",\"node\",\"placement-export\",\"modules\",\"seats\",\"builds\",\"plans\",\"delivery-plan\",\"delivery-order\",\"delivery-check\",\"deliver\",\"delivery-stop\",\"delivery-backups-again\",\"delivery-walks\",\"plan\",\"assign\",\"unassign\",\"pin\",\"unpin\",\"push\",\"rotate\",\"give\",\"issued\",\"secret-move\",\"secret-ask\",\"issue\",\"token\",\"settings\",\"command\",\"queue\",\"cancel\",\"clear\",\"replay\",\"try\",\"kill\",\"pause\",\"resume\",\"hand-act\",\"drill\",\"warranted\",\"hand-acts\",\"durations\",\"conditions\",\"healers\",\"doctor\",\"upgrade\",\"bus\",\"retire\",\"cleanup\",\"judge-directories\",\"root-free\",\"dead-letters\",\"data\",\"build\",\"artifacts\",\"held\",\"collect\",\"move-placement\",\"move-config\",\"mirrors\",\"images\"]},{\"seat\":\"no-such-seat\",\"scope\":\"node\"}]}" - }, - { - "name": "module-no-claims-no-fingerprint", - "held": "{\"url\":\"nats://10.0.0.1:4222\",\"node\":\"laptop\",\"module\":\"cap-plain\",\"user\":\"laptop.cap-plain\",\"password\":\"pw-plain\"}" - }, - { - "name": "provisioner", - "held": "{\"url\":\"nats://10.0.0.1:4222\",\"fingerprint\":\"ab:cd\",\"node\":\"laptop\",\"user\":\"provisioner.laptop\",\"password\":\"pw-prov\"}" - }, - { - "name": "process", - "held": "{\"url\":\"nats://10.0.0.1:4222\",\"fingerprint\":\"ab:cd\",\"node\":\"laptop\",\"module\":\"mesh-mail\",\"user\":\"laptop.mesh-mail.send\",\"password\":\"pw-proc\"}" - } - ] -} diff --git a/go/buscompose/testdata/user-lists.json b/go/buscompose/testdata/user-lists.json deleted file mode 100644 index 4851ccc..0000000 --- a/go/buscompose/testdata/user-lists.json +++ /dev/null @@ -1,289 +0,0 @@ -[ - { - "Name": "empty", - "List": "# The mesh's users, composed by the grants part. Do not edit: the next\n# composition overwrites it. Permissions are derived from what each module\n# declares and nothing else (novox/hq ADR 0043, design 29 §2).\n\naccounts {\n MESH {\n jetstream: enabled\n imports = [\n { stream: { account: \"$SYS\", subject: \"$SYS.ACCOUNT.MESH.CONNECT\" }, to: \"mesh.bus.connect\" }\n { stream: { account: \"$SYS\", subject: \"$SYS.ACCOUNT.MESH.DISCONNECT\" }, to: \"mesh.bus.disconnect\" }\n { service: { account: \"$SYS\", subject: \"$SYS.REQ.ACCOUNT.MESH.CONNZ\" }, to: \"mesh.bus.connz\" }\n ]\n users = [\n ]\n }\n \"$SYS\" {\n exports = [\n { stream: \"$SYS.ACCOUNT.MESH.CONNECT\", accounts: [\"MESH\"] }\n { stream: \"$SYS.ACCOUNT.MESH.DISCONNECT\", accounts: [\"MESH\"] }\n { service: \"$SYS.REQ.ACCOUNT.MESH.CONNZ\", accounts: [\"MESH\"] }\n ]\n }\n}\n", - "Missing": null, - "Err": "", - "Memberships": {} - }, - { - "Name": "the controller's someRecords", - "List": "# The mesh's users, composed by the grants part. Do not edit: the next\n# composition overwrites it. Permissions are derived from what each module\n# declares and nothing else (novox/hq ADR 0043, design 29 §2).\n\naccounts {\n MESH {\n jetstream: enabled\n imports = [\n { stream: { account: \"$SYS\", subject: \"$SYS.ACCOUNT.MESH.CONNECT\" }, to: \"mesh.bus.connect\" }\n { stream: { account: \"$SYS\", subject: \"$SYS.ACCOUNT.MESH.DISCONNECT\" }, to: \"mesh.bus.disconnect\" }\n { service: { account: \"$SYS\", subject: \"$SYS.REQ.ACCOUNT.MESH.CONNZ\" }, to: \"mesh.bus.connz\" }\n ]\n users = [\n { user: \"node.one\", password: \"$2a$11$node.onenode.onenode.ofixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.NODES.one.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_composed-one.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.one.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_wants.*.$KV.mesh-delivery-assigner_wants.want.one.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_acts.*.$KV.mesh-host_acts.one.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_data.*.$KV.mesh-host_data.one.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_health.*.$KV.mesh-host_health.one\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_packages.*.$KV.mesh-host_packages.one\", \"$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_provisions.*.$KV.mesh-provisioner_provisions.one.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.*.node\", \"$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.one.\u003e\", \"$JS.API.CONSUMER.CREATE.OBJ_declaration-files-one.*.$O.declaration-files-one.C.*\", \"$JS.API.CONSUMER.DELETE.KV_composed-one.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-config_resolved.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_wants.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_acts.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_data.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_health.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_packages.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-provisioner_provisions.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_node-engine_have.\u003e\", \"$JS.API.CONSUMER.DELETE.OBJ_declaration-files-one.*\", \"$JS.API.CONSUMER.INFO.NODES.one\", \"$JS.API.DIRECT.GET.KV_composed-one.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.one.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_wants.$KV.mesh-delivery-assigner_wants.want.one.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_acts.$KV.mesh-host_acts.one.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_data.$KV.mesh-host_data.one.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.one\", \"$JS.API.DIRECT.GET.KV_mesh-host_packages.$KV.mesh-host_packages.one\", \"$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.one.\u003e\", \"$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.*.node\", \"$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.one.\u003e\", \"$JS.API.DIRECT.GET.OBJ_declaration-files-one.$O.declaration-files-one.M.*\", \"$JS.API.STREAM.INFO.KV_composed-one\", \"$JS.API.STREAM.INFO.KV_mesh-config_resolved\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_candidates\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_placement\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_wants\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_builds\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_manifests\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_modules\", \"$JS.API.STREAM.INFO.KV_mesh-host_acts\", \"$JS.API.STREAM.INFO.KV_mesh-host_data\", \"$JS.API.STREAM.INFO.KV_mesh-host_health\", \"$JS.API.STREAM.INFO.KV_mesh-host_packages\", \"$JS.API.STREAM.INFO.KV_mesh-provisioner_provisions\", \"$JS.API.STREAM.INFO.KV_node-engine_have\", \"$JS.FC.KV_composed-one.\u003e\", \"$JS.FC.KV_mesh-config_resolved.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_wants.\u003e\", \"$JS.FC.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.FC.KV_mesh-host_acts.\u003e\", \"$JS.FC.KV_mesh-host_data.\u003e\", \"$JS.FC.KV_mesh-host_health.\u003e\", \"$JS.FC.KV_mesh-host_packages.\u003e\", \"$JS.FC.KV_mesh-provisioner_provisions.\u003e\", \"$JS.FC.KV_node-engine_have.\u003e\", \"$JS.FC.OBJ_declaration-files-one.\u003e\", \"$KV.mesh-host_acts.one.\u003e\", \"$KV.mesh-host_data.one.\u003e\", \"$KV.mesh-host_health.one\", \"$KV.mesh-host_packages.one\", \"$KV.node-engine_have.one.\u003e\", \"$SRV.INFO\", \"$SRV.INFO.\u003e\", \"$SRV.PING\", \"$SRV.PING.\u003e\", \"$SRV.PING.node-tools.one\", \"mesh.control.one.\u003e\", \"mesh.seat.node-backup.call.backed-up.one.node~one\", \"mesh.seat.node-backup.call.now.one.node~one\", \"mesh.seat.node-backup.tool.backed-up.one\", \"mesh.seat.node-backup.tool.now.one\", \"mesh.seat.node-engine.event.*.one\"] }\n subscribe: { allow: [\"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_candidates\", \"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_placement\", \"$SRV.INFO\", \"$SRV.INFO.node-engine\", \"$SRV.INFO.node-engine.\u003e\", \"$SRV.PING\", \"$SRV.PING.node-engine\", \"$SRV.PING.node-engine.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.node-engine\", \"$SRV.STATS.node-engine.\u003e\", \"_DELIVER.one\", \"_DELIVER.one.\u003e\", \"_INBOX.node.one.\u003e\", \"mesh.mod.restic.event.backed-up\", \"mesh.node.one.ask.access-act\", \"mesh.node.one.ask.hand-over\", \"mesh.node.one.ask.judge-directories\", \"mesh.node.one.ask.move-secret\", \"mesh.node.one.ask.report\", \"mesh.node.one.ask.setuid-search\", \"mesh.node.one.declare\", \"mesh.seat.node-engine.call.*.one.*\", \"mesh.seat.node-engine.tool.*.one\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"node.two\", password: \"$2a$11$node.twonode.twonode.tfixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.NODES.two.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_composed-two.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.two.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_wants.*.$KV.mesh-delivery-assigner_wants.want.two.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_acts.*.$KV.mesh-host_acts.two.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_data.*.$KV.mesh-host_data.two.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_health.*.$KV.mesh-host_health.two\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_packages.*.$KV.mesh-host_packages.two\", \"$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_provisions.*.$KV.mesh-provisioner_provisions.two.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.*.node\", \"$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.two.\u003e\", \"$JS.API.CONSUMER.CREATE.OBJ_declaration-files-two.*.$O.declaration-files-two.C.*\", \"$JS.API.CONSUMER.DELETE.KV_composed-two.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-config_resolved.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_wants.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_acts.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_data.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_health.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_packages.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-provisioner_provisions.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_node-engine_have.\u003e\", \"$JS.API.CONSUMER.DELETE.OBJ_declaration-files-two.*\", \"$JS.API.CONSUMER.INFO.NODES.two\", \"$JS.API.DIRECT.GET.KV_composed-two.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.two.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_wants.$KV.mesh-delivery-assigner_wants.want.two.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_acts.$KV.mesh-host_acts.two.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_data.$KV.mesh-host_data.two.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.two\", \"$JS.API.DIRECT.GET.KV_mesh-host_packages.$KV.mesh-host_packages.two\", \"$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.two.\u003e\", \"$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.*.node\", \"$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.two.\u003e\", \"$JS.API.DIRECT.GET.OBJ_declaration-files-two.$O.declaration-files-two.M.*\", \"$JS.API.STREAM.INFO.KV_composed-two\", \"$JS.API.STREAM.INFO.KV_mesh-config_resolved\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_candidates\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_placement\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_wants\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_builds\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_manifests\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_modules\", \"$JS.API.STREAM.INFO.KV_mesh-host_acts\", \"$JS.API.STREAM.INFO.KV_mesh-host_data\", \"$JS.API.STREAM.INFO.KV_mesh-host_health\", \"$JS.API.STREAM.INFO.KV_mesh-host_packages\", \"$JS.API.STREAM.INFO.KV_mesh-provisioner_provisions\", \"$JS.API.STREAM.INFO.KV_node-engine_have\", \"$JS.FC.KV_composed-two.\u003e\", \"$JS.FC.KV_mesh-config_resolved.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_wants.\u003e\", \"$JS.FC.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.FC.KV_mesh-host_acts.\u003e\", \"$JS.FC.KV_mesh-host_data.\u003e\", \"$JS.FC.KV_mesh-host_health.\u003e\", \"$JS.FC.KV_mesh-host_packages.\u003e\", \"$JS.FC.KV_mesh-provisioner_provisions.\u003e\", \"$JS.FC.KV_node-engine_have.\u003e\", \"$JS.FC.OBJ_declaration-files-two.\u003e\", \"$KV.mesh-host_acts.two.\u003e\", \"$KV.mesh-host_data.two.\u003e\", \"$KV.mesh-host_health.two\", \"$KV.mesh-host_packages.two\", \"$KV.node-engine_have.two.\u003e\", \"$SRV.INFO\", \"$SRV.INFO.\u003e\", \"$SRV.PING\", \"$SRV.PING.\u003e\", \"$SRV.PING.node-tools.two\", \"mesh.control.two.\u003e\", \"mesh.seat.node-backup.call.backed-up.two.node~two\", \"mesh.seat.node-backup.call.now.two.node~two\", \"mesh.seat.node-backup.tool.backed-up.two\", \"mesh.seat.node-backup.tool.now.two\", \"mesh.seat.node-engine.event.*.two\"] }\n subscribe: { allow: [\"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_candidates\", \"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_placement\", \"$SRV.INFO\", \"$SRV.INFO.node-engine\", \"$SRV.INFO.node-engine.\u003e\", \"$SRV.PING\", \"$SRV.PING.node-engine\", \"$SRV.PING.node-engine.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.node-engine\", \"$SRV.STATS.node-engine.\u003e\", \"_DELIVER.two\", \"_DELIVER.two.\u003e\", \"_INBOX.node.two.\u003e\", \"mesh.mod.restic.event.backed-up\", \"mesh.node.two.ask.access-act\", \"mesh.node.two.ask.hand-over\", \"mesh.node.two.ask.judge-directories\", \"mesh.node.two.ask.move-secret\", \"mesh.node.two.ask.report\", \"mesh.node.two.ask.setuid-search\", \"mesh.node.two.declare\", \"mesh.seat.node-engine.call.*.two.*\", \"mesh.seat.node-engine.tool.*.two\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"one.telegram\", password: \"$2a$11$one.telegramone.telegrfixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.EVENTS.one_telegram.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.one.telegram\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.\u003e\", \"$JS.API.CONSUMER.INFO.EVENTS.one_telegram\", \"$JS.API.CONSUMER.MSG.NEXT.EVENTS.one_telegram\", \"$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.one.telegram\", \"$JS.API.STREAM.INFO.KV_mesh-grants_memberships\", \"$JS.FC.KV_mesh-grants_memberships.\u003e\"] }\n subscribe: { allow: [\"$SRV.INFO\", \"$SRV.INFO.telegram\", \"$SRV.INFO.telegram.\u003e\", \"$SRV.PING\", \"$SRV.PING.telegram\", \"$SRV.PING.telegram.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.telegram\", \"$SRV.STATS.telegram.\u003e\", \"_INBOX.one.telegram.\u003e\", \"mesh.mod.telegram.call.\u003e\", \"mesh.mod.telegram.tool.\u003e\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"person.ada\", password: \"$2a$11$person.adaperson.adapefixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$SRV.INFO\", \"$SRV.INFO.\u003e\", \"$SRV.PING\", \"$SRV.PING.\u003e\", \"mesh.mod.mesh-catalog.call.catalog_tools.*.person~ada\", \"mesh.mod.mesh-catalog.call.catalog_tools.person~ada\", \"mesh.mod.mesh-catalog.tool.catalog_tools\", \"mesh.mod.mesh-catalog.tool.catalog_tools.*\"] }\n subscribe: { allow: [\"_INBOX.person.ada.\u003e\"] }\n } }\n { user: \"two.shop\", password: \"$2a$11$two.shoptwo.shoptwo.shfixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.EVENTS.two_shop.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.two.shop\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.\u003e\", \"$JS.API.CONSUMER.INFO.EVENTS.two_shop\", \"$JS.API.CONSUMER.MSG.NEXT.EVENTS.two_shop\", \"$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.two.shop\", \"$JS.API.STREAM.INFO.KV_mesh-grants_memberships\", \"$JS.FC.KV_mesh-grants_memberships.\u003e\", \"mesh.mod.shop.event.order.placed\"] }\n subscribe: { allow: [\"$SRV.INFO\", \"$SRV.INFO.shop\", \"$SRV.INFO.shop.\u003e\", \"$SRV.PING\", \"$SRV.PING.shop\", \"$SRV.PING.shop.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.shop\", \"$SRV.STATS.shop.\u003e\", \"_INBOX.two.shop.\u003e\", \"mesh.mod.shop.call.\u003e\", \"mesh.mod.shop.tool.\u003e\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n ]\n }\n \"$SYS\" {\n exports = [\n { stream: \"$SYS.ACCOUNT.MESH.CONNECT\", accounts: [\"MESH\"] }\n { stream: \"$SYS.ACCOUNT.MESH.DISCONNECT\", accounts: [\"MESH\"] }\n { service: \"$SYS.REQ.ACCOUNT.MESH.CONNZ\", accounts: [\"MESH\"] }\n ]\n }\n}\n", - "Missing": null, - "Err": "", - "Memberships": { - "one.telegram": { - "node": "one", - "module": "telegram", - "serves": [ - { - "subject": "mesh.mod.telegram.tool.{tool}.one" - }, - { - "subject": "mesh.mod.telegram.tool.{tool}", - "queue": "serve.telegram" - } - ], - "emits": "mesh.mod.telegram.event.{event}", - "tools": "mesh.mod.telegram.tool.tools" - }, - "two.shop": { - "node": "two", - "module": "shop", - "serves": [ - { - "subject": "mesh.mod.shop.tool.{tool}.two" - }, - { - "subject": "mesh.mod.shop.tool.{tool}", - "queue": "serve.shop" - } - ], - "emits": "mesh.mod.shop.event.{event}", - "tools": "mesh.mod.shop.tool.tools" - } - } - }, - { - "Name": "seats, consumers, people and a hashless user", - "List": "# The mesh's users, composed by the grants part. Do not edit: the next\n# composition overwrites it. Permissions are derived from what each module\n# declares and nothing else (novox/hq ADR 0043, design 29 §2).\n\naccounts {\n MESH {\n jetstream: enabled\n imports = [\n { stream: { account: \"$SYS\", subject: \"$SYS.ACCOUNT.MESH.CONNECT\" }, to: \"mesh.bus.connect\" }\n { stream: { account: \"$SYS\", subject: \"$SYS.ACCOUNT.MESH.DISCONNECT\" }, to: \"mesh.bus.disconnect\" }\n { service: { account: \"$SYS\", subject: \"$SYS.REQ.ACCOUNT.MESH.CONNZ\" }, to: \"mesh.bus.connz\" }\n ]\n users = [\n { user: \"node.one\", password: \"$2a$11$node.onenode.onenode.ofixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.NODES.one.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_composed-one.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.one.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_wants.*.$KV.mesh-delivery-assigner_wants.want.one.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_user-list.*.$KV.mesh-grants_user-list.sealed\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_acts.*.$KV.mesh-host_acts.one.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_data.*.$KV.mesh-host_data.one.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_health.*.$KV.mesh-host_health.one\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_packages.*.$KV.mesh-host_packages.one\", \"$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_provisions.*.$KV.mesh-provisioner_provisions.one.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.*.node\", \"$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.one.\u003e\", \"$JS.API.CONSUMER.CREATE.OBJ_declaration-files-one.*.$O.declaration-files-one.C.*\", \"$JS.API.CONSUMER.CREATE.OBJ_mesh-grants-user-list.*.$O.mesh-grants-user-list.C.*\", \"$JS.API.CONSUMER.DELETE.KV_composed-one.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-config_resolved.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_wants.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_user-list.*\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_acts.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_data.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_health.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_packages.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-provisioner_provisions.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_node-engine_have.\u003e\", \"$JS.API.CONSUMER.DELETE.OBJ_declaration-files-one.*\", \"$JS.API.CONSUMER.DELETE.OBJ_mesh-grants-user-list.*\", \"$JS.API.CONSUMER.INFO.NODES.one\", \"$JS.API.DIRECT.GET.KV_composed-one.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.one.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_wants.$KV.mesh-delivery-assigner_wants.want.one.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-grants_user-list.$KV.mesh-grants_user-list.sealed\", \"$JS.API.DIRECT.GET.KV_mesh-host_acts.$KV.mesh-host_acts.one.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_data.$KV.mesh-host_data.one.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.one\", \"$JS.API.DIRECT.GET.KV_mesh-host_packages.$KV.mesh-host_packages.one\", \"$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.one.\u003e\", \"$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.*.node\", \"$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.one.\u003e\", \"$JS.API.DIRECT.GET.OBJ_declaration-files-one.$O.declaration-files-one.M.*\", \"$JS.API.DIRECT.GET.OBJ_mesh-grants-user-list.$O.mesh-grants-user-list.M.*\", \"$JS.API.STREAM.INFO.KV_composed-one\", \"$JS.API.STREAM.INFO.KV_mesh-config_resolved\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_candidates\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_placement\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_wants\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_builds\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_manifests\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_modules\", \"$JS.API.STREAM.INFO.KV_mesh-grants_user-list\", \"$JS.API.STREAM.INFO.KV_mesh-host_acts\", \"$JS.API.STREAM.INFO.KV_mesh-host_data\", \"$JS.API.STREAM.INFO.KV_mesh-host_health\", \"$JS.API.STREAM.INFO.KV_mesh-host_packages\", \"$JS.API.STREAM.INFO.KV_mesh-provisioner_provisions\", \"$JS.API.STREAM.INFO.KV_node-engine_have\", \"$JS.FC.KV_composed-one.\u003e\", \"$JS.FC.KV_mesh-config_resolved.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_wants.\u003e\", \"$JS.FC.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.FC.KV_mesh-grants_user-list.\u003e\", \"$JS.FC.KV_mesh-host_acts.\u003e\", \"$JS.FC.KV_mesh-host_data.\u003e\", \"$JS.FC.KV_mesh-host_health.\u003e\", \"$JS.FC.KV_mesh-host_packages.\u003e\", \"$JS.FC.KV_mesh-provisioner_provisions.\u003e\", \"$JS.FC.KV_node-engine_have.\u003e\", \"$JS.FC.OBJ_declaration-files-one.\u003e\", \"$JS.FC.OBJ_mesh-grants-user-list.\u003e\", \"$KV.mesh-host_acts.one.\u003e\", \"$KV.mesh-host_data.one.\u003e\", \"$KV.mesh-host_health.one\", \"$KV.mesh-host_packages.one\", \"$KV.node-engine_have.one.\u003e\", \"$SRV.INFO\", \"$SRV.INFO.\u003e\", \"$SRV.PING\", \"$SRV.PING.\u003e\", \"$SRV.PING.node-tools.one\", \"mesh.control.one.\u003e\", \"mesh.seat.node-backup.call.backed-up.one.node~one\", \"mesh.seat.node-backup.call.now.one.node~one\", \"mesh.seat.node-backup.tool.backed-up.one\", \"mesh.seat.node-backup.tool.now.one\", \"mesh.seat.node-engine.event.*.one\"] }\n subscribe: { allow: [\"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_candidates\", \"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_placement\", \"$SRV.INFO\", \"$SRV.INFO.node-engine\", \"$SRV.INFO.node-engine.\u003e\", \"$SRV.PING\", \"$SRV.PING.node-engine\", \"$SRV.PING.node-engine.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.node-engine\", \"$SRV.STATS.node-engine.\u003e\", \"_DELIVER.one\", \"_DELIVER.one.\u003e\", \"_INBOX.node.one.\u003e\", \"mesh.mod.restic.event.backed-up\", \"mesh.node.one.ask.access-act\", \"mesh.node.one.ask.hand-over\", \"mesh.node.one.ask.judge-directories\", \"mesh.node.one.ask.move-secret\", \"mesh.node.one.ask.report\", \"mesh.node.one.ask.setuid-search\", \"mesh.node.one.declare\", \"mesh.seat.node-engine.call.*.one.*\", \"mesh.seat.node-engine.tool.*.one\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"node.two\", password: \"$2a$11$node.twonode.twonode.tfixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.NODES.two.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_composed-two.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.two.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_wants.*.$KV.mesh-delivery-assigner_wants.want.two.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_acts.*.$KV.mesh-host_acts.two.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_data.*.$KV.mesh-host_data.two.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_health.*.$KV.mesh-host_health.two\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_packages.*.$KV.mesh-host_packages.two\", \"$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_provisions.*.$KV.mesh-provisioner_provisions.two.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.*.node\", \"$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.two.\u003e\", \"$JS.API.CONSUMER.CREATE.OBJ_declaration-files-two.*.$O.declaration-files-two.C.*\", \"$JS.API.CONSUMER.DELETE.KV_composed-two.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-config_resolved.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_wants.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_acts.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_data.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_health.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_packages.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-provisioner_provisions.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_node-engine_have.\u003e\", \"$JS.API.CONSUMER.DELETE.OBJ_declaration-files-two.*\", \"$JS.API.CONSUMER.INFO.NODES.two\", \"$JS.API.DIRECT.GET.KV_composed-two.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.two.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_wants.$KV.mesh-delivery-assigner_wants.want.two.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_acts.$KV.mesh-host_acts.two.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_data.$KV.mesh-host_data.two.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.two\", \"$JS.API.DIRECT.GET.KV_mesh-host_packages.$KV.mesh-host_packages.two\", \"$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.two.\u003e\", \"$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.*.node\", \"$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.two.\u003e\", \"$JS.API.DIRECT.GET.OBJ_declaration-files-two.$O.declaration-files-two.M.*\", \"$JS.API.STREAM.INFO.KV_composed-two\", \"$JS.API.STREAM.INFO.KV_mesh-config_resolved\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_candidates\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_placement\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_wants\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_builds\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_manifests\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_modules\", \"$JS.API.STREAM.INFO.KV_mesh-host_acts\", \"$JS.API.STREAM.INFO.KV_mesh-host_data\", \"$JS.API.STREAM.INFO.KV_mesh-host_health\", \"$JS.API.STREAM.INFO.KV_mesh-host_packages\", \"$JS.API.STREAM.INFO.KV_mesh-provisioner_provisions\", \"$JS.API.STREAM.INFO.KV_node-engine_have\", \"$JS.FC.KV_composed-two.\u003e\", \"$JS.FC.KV_mesh-config_resolved.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_wants.\u003e\", \"$JS.FC.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.FC.KV_mesh-host_acts.\u003e\", \"$JS.FC.KV_mesh-host_data.\u003e\", \"$JS.FC.KV_mesh-host_health.\u003e\", \"$JS.FC.KV_mesh-host_packages.\u003e\", \"$JS.FC.KV_mesh-provisioner_provisions.\u003e\", \"$JS.FC.KV_node-engine_have.\u003e\", \"$JS.FC.OBJ_declaration-files-two.\u003e\", \"$KV.mesh-host_acts.two.\u003e\", \"$KV.mesh-host_data.two.\u003e\", \"$KV.mesh-host_health.two\", \"$KV.mesh-host_packages.two\", \"$KV.node-engine_have.two.\u003e\", \"$SRV.INFO\", \"$SRV.INFO.\u003e\", \"$SRV.PING\", \"$SRV.PING.\u003e\", \"$SRV.PING.node-tools.two\", \"mesh.control.two.\u003e\", \"mesh.seat.node-backup.call.backed-up.two.node~two\", \"mesh.seat.node-backup.call.now.two.node~two\", \"mesh.seat.node-backup.tool.backed-up.two\", \"mesh.seat.node-backup.tool.now.two\", \"mesh.seat.node-engine.event.*.two\"] }\n subscribe: { allow: [\"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_candidates\", \"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_placement\", \"$SRV.INFO\", \"$SRV.INFO.node-engine\", \"$SRV.INFO.node-engine.\u003e\", \"$SRV.PING\", \"$SRV.PING.node-engine\", \"$SRV.PING.node-engine.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.node-engine\", \"$SRV.STATS.node-engine.\u003e\", \"_DELIVER.two\", \"_DELIVER.two.\u003e\", \"_INBOX.node.two.\u003e\", \"mesh.mod.restic.event.backed-up\", \"mesh.node.two.ask.access-act\", \"mesh.node.two.ask.hand-over\", \"mesh.node.two.ask.judge-directories\", \"mesh.node.two.ask.move-secret\", \"mesh.node.two.ask.report\", \"mesh.node.two.ask.setuid-search\", \"mesh.node.two.declare\", \"mesh.seat.node-engine.call.*.two.*\", \"mesh.seat.node-engine.tool.*.two\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"one.mesh-grants\", password: \"$2a$11$one.mesh-grantsone.mesfixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.EVENTS.one_mesh-grants.\u003e\", \"$JS.API.CONSUMER.CREATE.EVENTS.two_audit\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.one.mesh-grants\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.\u003e\", \"$JS.API.CONSUMER.INFO.EVENTS.one_mesh-grants\", \"$JS.API.CONSUMER.INFO.EVENTS.two_audit\", \"$JS.API.CONSUMER.MSG.NEXT.EVENTS.one_mesh-grants\", \"$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.*.*\", \"$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.one.mesh-grants\", \"$JS.API.DIRECT.GET.OBJ_mesh-grants-user-list.\u003e\", \"$JS.API.STREAM.CREATE.OBJ_mesh-grants-user-list\", \"$JS.API.STREAM.INFO.KV_mesh-grants_memberships\", \"$JS.API.STREAM.INFO.OBJ_mesh-grants-user-list\", \"$JS.API.STREAM.MSG.GET.OBJ_mesh-grants-user-list\", \"$JS.API.STREAM.PURGE.OBJ_mesh-grants-user-list\", \"$JS.FC.KV_mesh-grants_memberships.\u003e\", \"$O.mesh-grants-user-list.\u003e\", \"mesh.assignment.*.*\", \"mesh.mod.mesh-grants.event.membership-changed\"] }\n subscribe: { allow: [\"$SRV.INFO\", \"$SRV.INFO.mesh-grants\", \"$SRV.INFO.mesh-grants.\u003e\", \"$SRV.PING\", \"$SRV.PING.mesh-grants\", \"$SRV.PING.mesh-grants.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.mesh-grants\", \"$SRV.STATS.mesh-grants.\u003e\", \"_INBOX.one.mesh-grants.\u003e\", \"mesh.mod.mesh-grants.call.\u003e\", \"mesh.mod.mesh-grants.tool.\u003e\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"one.mesh-watchman\", password: \"$2a$11$one.mesh-watchmanone.mfixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.EVENTS.one_mesh-watchman.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.one.mesh-watchman\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.\u003e\", \"$JS.API.CONSUMER.INFO.EVENTS.one_mesh-watchman\", \"$JS.API.CONSUMER.MSG.NEXT.EVENTS.one_mesh-watchman\", \"$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.one.mesh-watchman\", \"$JS.API.STREAM.INFO.KV_mesh-grants_memberships\", \"$JS.FC.KV_mesh-grants_memberships.\u003e\"] }\n subscribe: { allow: [\"$SRV.INFO\", \"$SRV.INFO.mesh-watchman\", \"$SRV.INFO.mesh-watchman.\u003e\", \"$SRV.PING\", \"$SRV.PING.mesh-watchman\", \"$SRV.PING.mesh-watchman.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.mesh-watchman\", \"$SRV.STATS.mesh-watchman.\u003e\", \"_INBOX.one.mesh-watchman.\u003e\", \"mesh.mod.mesh-watchman.call.\u003e\", \"mesh.mod.mesh-watchman.tool.\u003e\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"one.nats\", password: \"$2a$11$one.natsone.natsone.nafixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.API.STREAM.INFO.*\", \"$JS.API.STREAM.NAMES\", \"$JS.API.STREAM.SNAPSHOT.*\", \"$JS.SNAPSHOT.ACK.\u003e\"] }\n subscribe: { allow: [\"_INBOX.one.nats.\u003e\"] }\n } }\n { user: \"one.telegram\", password: \"$2a$11$one.telegramone.telegrfixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.EVENTS.one_telegram.\u003e\", \"$JS.ACK.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.one.telegram\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.\u003e\", \"$JS.API.CONSUMER.INFO.EVENTS.one_telegram\", \"$JS.API.CONSUMER.INFO.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker\", \"$JS.API.CONSUMER.MSG.NEXT.EVENTS.one_telegram\", \"$JS.API.CONSUMER.MSG.NEXT.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker\", \"$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.one.telegram\", \"$JS.API.STREAM.INFO.KV_mesh-grants_memberships\", \"$JS.FC.KV_mesh-grants_memberships.\u003e\", \"mesh.seat.telegram-sender.event.delivered\", \"mesh.seat.telegram-sender.event.failed\"] }\n subscribe: { allow: [\"$SRV.INFO\", \"$SRV.INFO.telegram\", \"$SRV.INFO.telegram.\u003e\", \"$SRV.PING\", \"$SRV.PING.telegram\", \"$SRV.PING.telegram.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.telegram\", \"$SRV.STATS.telegram.\u003e\", \"_INBOX.one.telegram.\u003e\", \"mesh.mod.telegram.call.\u003e\", \"mesh.mod.telegram.tool.\u003e\", \"mesh.seat.telegram-sender.accept.send\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"person.ada\", password: \"$2a$11$person.adaperson.adapefixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$SRV.INFO\", \"$SRV.INFO.\u003e\", \"$SRV.PING\", \"$SRV.PING.\u003e\", \"mesh.mod.*.call.*.*.person~ada\", \"mesh.mod.*.call.*.person~ada\", \"mesh.mod.*.tool.\u003e\", \"mesh.seat.*.call.*.*.person~ada\", \"mesh.seat.*.call.*.person~ada\", \"mesh.seat.*.tool.\u003e\"], deny: [\"mesh.seat.node-launcher.tool.secret\", \"mesh.seat.node-launcher.tool.secret.*\", \"mesh.mod.*.tool.node-launcher.secret\", \"mesh.mod.*.tool.node-launcher.secret.*\", \"mesh.seat.node-launcher.call.secret.\u003e\", \"mesh.mod.*.call.node-launcher.secret.\u003e\"] }\n subscribe: { allow: [\"_INBOX.person.ada.\u003e\"] }\n } }\n { user: \"person.bob\", password: \"$2a$11$person.bobperson.bobpefixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$SRV.INFO\", \"$SRV.INFO.\u003e\", \"$SRV.PING\", \"$SRV.PING.\u003e\", \"mesh.mod.shop.call.status.*.person~bob\", \"mesh.mod.shop.call.status.person~bob\", \"mesh.mod.shop.tool.status\", \"mesh.mod.shop.tool.status.*\"] }\n subscribe: { allow: [\"_INBOX.person.bob.\u003e\"] }\n } }\n { user: \"two.shop\", password: \"$2a$11$two.shoptwo.shoptwo.shfixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.EVENTS.two_shop.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.two.shop\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.\u003e\", \"$JS.API.CONSUMER.INFO.EVENTS.two_shop\", \"$JS.API.CONSUMER.MSG.NEXT.EVENTS.two_shop\", \"$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.two.shop\", \"$JS.API.STREAM.INFO.KV_mesh-grants_memberships\", \"$JS.FC.KV_mesh-grants_memberships.\u003e\", \"mesh.mod.shop.event.order.placed\", \"mesh.seat.telegram-sender.accept.send\"] }\n subscribe: { allow: [\"$SRV.INFO\", \"$SRV.INFO.shop\", \"$SRV.INFO.shop.\u003e\", \"$SRV.PING\", \"$SRV.PING.shop\", \"$SRV.PING.shop.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.shop\", \"$SRV.STATS.shop.\u003e\", \"_INBOX.two.shop.\u003e\", \"mesh.mod.shop.call.\u003e\", \"mesh.mod.shop.tool.\u003e\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n ]\n }\n \"$SYS\" {\n exports = [\n { stream: \"$SYS.ACCOUNT.MESH.CONNECT\", accounts: [\"MESH\"] }\n { stream: \"$SYS.ACCOUNT.MESH.DISCONNECT\", accounts: [\"MESH\"] }\n { service: \"$SYS.REQ.ACCOUNT.MESH.CONNZ\", accounts: [\"MESH\"] }\n ]\n }\n}\n", - "Missing": [ - "two.audit" - ], - "Err": "", - "Memberships": { - "one.mesh-grants": { - "node": "one", - "module": "mesh-grants", - "serves": [ - { - "subject": "mesh.mod.mesh-grants.tool.{tool}.one" - }, - { - "subject": "mesh.mod.mesh-grants.tool.{tool}", - "queue": "serve.mesh-grants" - } - ], - "emits": "mesh.mod.mesh-grants.event.{event}", - "tools": "mesh.mod.mesh-grants.tool.tools" - }, - "one.mesh-watchman": { - "node": "one", - "module": "mesh-watchman", - "serves": [ - { - "subject": "mesh.mod.mesh-watchman.tool.{tool}.one" - }, - { - "subject": "mesh.mod.mesh-watchman.tool.{tool}", - "queue": "serve.mesh-watchman" - } - ], - "emits": "mesh.mod.mesh-watchman.event.{event}", - "tools": "mesh.mod.mesh-watchman.tool.tools" - }, - "one.nats": { - "node": "one", - "module": "nats", - "serves": [ - { - "subject": "mesh.mod.nats.tool.{tool}.one" - }, - { - "subject": "mesh.mod.nats.tool.{tool}", - "queue": "serve.nats" - } - ], - "emits": "mesh.mod.nats.event.{event}", - "tools": "mesh.mod.nats.tool.tools" - }, - "one.telegram": { - "node": "one", - "module": "telegram", - "serves": [ - { - "subject": "mesh.mod.telegram.tool.{tool}.one" - }, - { - "subject": "mesh.mod.telegram.tool.{tool}", - "queue": "serve.telegram" - } - ], - "emits": "mesh.mod.telegram.event.{event}", - "tools": "mesh.mod.telegram.tool.tools", - "seat-traffic": { - "publish": [ - "mesh.seat.telegram-sender.event.delivered", - "mesh.seat.telegram-sender.event.failed" - ] - } - }, - "two.audit": { - "node": "two", - "module": "audit", - "serves": [ - { - "subject": "mesh.mod.audit.tool.{tool}.two" - }, - { - "subject": "mesh.mod.audit.tool.{tool}", - "queue": "serve.audit" - } - ], - "emits": "mesh.mod.audit.event.{event}", - "tools": "mesh.mod.audit.tool.tools" - }, - "two.shop": { - "node": "two", - "module": "shop", - "serves": [ - { - "subject": "mesh.mod.shop.tool.{tool}.two" - }, - { - "subject": "mesh.mod.shop.tool.{tool}", - "queue": "serve.shop" - } - ], - "emits": "mesh.mod.shop.event.{event}", - "tools": "mesh.mod.shop.tool.tools" - } - } - }, - { - "Name": "state, logs, streams, queues and processes", - "List": "# The mesh's users, composed by the grants part. Do not edit: the next\n# composition overwrites it. Permissions are derived from what each module\n# declares and nothing else (novox/hq ADR 0043, design 29 §2).\n\naccounts {\n MESH {\n jetstream: enabled\n imports = [\n { stream: { account: \"$SYS\", subject: \"$SYS.ACCOUNT.MESH.CONNECT\" }, to: \"mesh.bus.connect\" }\n { stream: { account: \"$SYS\", subject: \"$SYS.ACCOUNT.MESH.DISCONNECT\" }, to: \"mesh.bus.disconnect\" }\n { service: { account: \"$SYS\", subject: \"$SYS.REQ.ACCOUNT.MESH.CONNZ\" }, to: \"mesh.bus.connz\" }\n ]\n users = [\n { user: \"ace.plex\", password: \"$2a$11$ace.plexace.plexace.plfixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.EVENTS.ace_plex.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.ace.plex\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.\u003e\", \"$JS.API.CONSUMER.INFO.EVENTS.ace_plex\", \"$JS.API.CONSUMER.MSG.NEXT.EVENTS.ace_plex\", \"$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.ace.plex\", \"$JS.API.STREAM.INFO.KV_mesh-grants_memberships\", \"$JS.FC.KV_mesh-grants_memberships.\u003e\"] }\n subscribe: { allow: [\"$SRV.INFO\", \"$SRV.INFO.plex\", \"$SRV.INFO.plex.\u003e\", \"$SRV.PING\", \"$SRV.PING.plex\", \"$SRV.PING.plex.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.plex\", \"$SRV.STATS.plex.\u003e\", \"_INBOX.ace.plex.\u003e\", \"mesh.mod.plex.call.\u003e\", \"mesh.mod.plex.tool.\u003e\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"node.ace\", password: \"$2a$11$node.acenode.acenode.afixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.NODES.ace.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_composed-ace.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.ace.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_wants.*.$KV.mesh-delivery-assigner_wants.want.ace.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_acts.*.$KV.mesh-host_acts.ace.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_data.*.$KV.mesh-host_data.ace.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_health.*.$KV.mesh-host_health.ace\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_packages.*.$KV.mesh-host_packages.ace\", \"$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_provisions.*.$KV.mesh-provisioner_provisions.ace.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.*.node\", \"$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.ace.\u003e\", \"$JS.API.CONSUMER.CREATE.OBJ_declaration-files-ace.*.$O.declaration-files-ace.C.*\", \"$JS.API.CONSUMER.DELETE.KV_composed-ace.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-config_resolved.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_wants.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_acts.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_data.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_health.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_packages.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-provisioner_provisions.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_node-engine_have.\u003e\", \"$JS.API.CONSUMER.DELETE.OBJ_declaration-files-ace.*\", \"$JS.API.CONSUMER.INFO.NODES.ace\", \"$JS.API.DIRECT.GET.KV_composed-ace.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.ace.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_wants.$KV.mesh-delivery-assigner_wants.want.ace.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_acts.$KV.mesh-host_acts.ace.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_data.$KV.mesh-host_data.ace.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.ace\", \"$JS.API.DIRECT.GET.KV_mesh-host_packages.$KV.mesh-host_packages.ace\", \"$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.ace.\u003e\", \"$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.*.node\", \"$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.ace.\u003e\", \"$JS.API.DIRECT.GET.OBJ_declaration-files-ace.$O.declaration-files-ace.M.*\", \"$JS.API.STREAM.INFO.KV_composed-ace\", \"$JS.API.STREAM.INFO.KV_mesh-config_resolved\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_candidates\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_placement\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_wants\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_builds\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_manifests\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_modules\", \"$JS.API.STREAM.INFO.KV_mesh-host_acts\", \"$JS.API.STREAM.INFO.KV_mesh-host_data\", \"$JS.API.STREAM.INFO.KV_mesh-host_health\", \"$JS.API.STREAM.INFO.KV_mesh-host_packages\", \"$JS.API.STREAM.INFO.KV_mesh-provisioner_provisions\", \"$JS.API.STREAM.INFO.KV_node-engine_have\", \"$JS.FC.KV_composed-ace.\u003e\", \"$JS.FC.KV_mesh-config_resolved.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_wants.\u003e\", \"$JS.FC.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.FC.KV_mesh-host_acts.\u003e\", \"$JS.FC.KV_mesh-host_data.\u003e\", \"$JS.FC.KV_mesh-host_health.\u003e\", \"$JS.FC.KV_mesh-host_packages.\u003e\", \"$JS.FC.KV_mesh-provisioner_provisions.\u003e\", \"$JS.FC.KV_node-engine_have.\u003e\", \"$JS.FC.OBJ_declaration-files-ace.\u003e\", \"$KV.mesh-host_acts.ace.\u003e\", \"$KV.mesh-host_data.ace.\u003e\", \"$KV.mesh-host_health.ace\", \"$KV.mesh-host_packages.ace\", \"$KV.node-engine_have.ace.\u003e\", \"$SRV.INFO\", \"$SRV.INFO.\u003e\", \"$SRV.PING\", \"$SRV.PING.\u003e\", \"$SRV.PING.node-tools.ace\", \"mesh.control.ace.\u003e\", \"mesh.mod.plex.call.plex_status.ace.node~ace\", \"mesh.mod.plex.tool.plex_status.ace\", \"mesh.seat.node-backup.call.backed-up.ace.node~ace\", \"mesh.seat.node-backup.call.now.ace.node~ace\", \"mesh.seat.node-backup.tool.backed-up.ace\", \"mesh.seat.node-backup.tool.now.ace\", \"mesh.seat.node-engine.event.*.ace\"] }\n subscribe: { allow: [\"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_candidates\", \"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_placement\", \"$SRV.INFO\", \"$SRV.INFO.node-engine\", \"$SRV.INFO.node-engine.\u003e\", \"$SRV.PING\", \"$SRV.PING.node-engine\", \"$SRV.PING.node-engine.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.node-engine\", \"$SRV.STATS.node-engine.\u003e\", \"_DELIVER.ace\", \"_DELIVER.ace.\u003e\", \"_INBOX.node.ace.\u003e\", \"mesh.mod.restic.event.backed-up\", \"mesh.node.ace.ask.access-act\", \"mesh.node.ace.ask.hand-over\", \"mesh.node.ace.ask.judge-directories\", \"mesh.node.ace.ask.move-secret\", \"mesh.node.ace.ask.report\", \"mesh.node.ace.ask.setuid-search\", \"mesh.node.ace.declare\", \"mesh.seat.node-engine.call.*.ace.*\", \"mesh.seat.node-engine.tool.*.ace\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"node.novox\", password: \"$2a$11$node.novoxnode.novoxnofixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.NODES.novox.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_composed-novox.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.novox.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_wants.*.$KV.mesh-delivery-assigner_wants.want.novox.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_acts.*.$KV.mesh-host_acts.novox.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_data.*.$KV.mesh-host_data.novox.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_health.*.$KV.mesh-host_health.novox\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_packages.*.$KV.mesh-host_packages.novox\", \"$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_provisions.*.$KV.mesh-provisioner_provisions.novox.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.*.node\", \"$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.novox.\u003e\", \"$JS.API.CONSUMER.CREATE.OBJ_declaration-files-novox.*.$O.declaration-files-novox.C.*\", \"$JS.API.CONSUMER.DELETE.KV_composed-novox.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-config_resolved.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_wants.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_acts.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_data.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_health.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_packages.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-provisioner_provisions.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_node-engine_have.\u003e\", \"$JS.API.CONSUMER.DELETE.OBJ_declaration-files-novox.*\", \"$JS.API.CONSUMER.INFO.NODES.novox\", \"$JS.API.DIRECT.GET.KV_composed-novox.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.novox.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_wants.$KV.mesh-delivery-assigner_wants.want.novox.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_acts.$KV.mesh-host_acts.novox.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_data.$KV.mesh-host_data.novox.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.novox\", \"$JS.API.DIRECT.GET.KV_mesh-host_packages.$KV.mesh-host_packages.novox\", \"$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.novox.\u003e\", \"$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.*.node\", \"$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.novox.\u003e\", \"$JS.API.DIRECT.GET.OBJ_declaration-files-novox.$O.declaration-files-novox.M.*\", \"$JS.API.STREAM.INFO.KV_composed-novox\", \"$JS.API.STREAM.INFO.KV_mesh-config_resolved\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_candidates\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_placement\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_wants\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_builds\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_manifests\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_modules\", \"$JS.API.STREAM.INFO.KV_mesh-host_acts\", \"$JS.API.STREAM.INFO.KV_mesh-host_data\", \"$JS.API.STREAM.INFO.KV_mesh-host_health\", \"$JS.API.STREAM.INFO.KV_mesh-host_packages\", \"$JS.API.STREAM.INFO.KV_mesh-provisioner_provisions\", \"$JS.API.STREAM.INFO.KV_node-engine_have\", \"$JS.FC.KV_composed-novox.\u003e\", \"$JS.FC.KV_mesh-config_resolved.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_wants.\u003e\", \"$JS.FC.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.FC.KV_mesh-host_acts.\u003e\", \"$JS.FC.KV_mesh-host_data.\u003e\", \"$JS.FC.KV_mesh-host_health.\u003e\", \"$JS.FC.KV_mesh-host_packages.\u003e\", \"$JS.FC.KV_mesh-provisioner_provisions.\u003e\", \"$JS.FC.KV_node-engine_have.\u003e\", \"$JS.FC.OBJ_declaration-files-novox.\u003e\", \"$KV.mesh-host_acts.novox.\u003e\", \"$KV.mesh-host_data.novox.\u003e\", \"$KV.mesh-host_health.novox\", \"$KV.mesh-host_packages.novox\", \"$KV.node-engine_have.novox.\u003e\", \"$SRV.INFO\", \"$SRV.INFO.\u003e\", \"$SRV.PING\", \"$SRV.PING.\u003e\", \"$SRV.PING.node-tools.novox\", \"mesh.control.novox.\u003e\", \"mesh.seat.node-backup.call.backed-up.novox.node~novox\", \"mesh.seat.node-backup.call.now.novox.node~novox\", \"mesh.seat.node-backup.tool.backed-up.novox\", \"mesh.seat.node-backup.tool.now.novox\", \"mesh.seat.node-engine.event.*.novox\"] }\n subscribe: { allow: [\"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_candidates\", \"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_placement\", \"$SRV.INFO\", \"$SRV.INFO.node-engine\", \"$SRV.INFO.node-engine.\u003e\", \"$SRV.PING\", \"$SRV.PING.node-engine\", \"$SRV.PING.node-engine.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.node-engine\", \"$SRV.STATS.node-engine.\u003e\", \"_DELIVER.novox\", \"_DELIVER.novox.\u003e\", \"_INBOX.node.novox.\u003e\", \"mesh.mod.restic.event.backed-up\", \"mesh.node.novox.ask.access-act\", \"mesh.node.novox.ask.hand-over\", \"mesh.node.novox.ask.judge-directories\", \"mesh.node.novox.ask.move-secret\", \"mesh.node.novox.ask.report\", \"mesh.node.novox.ask.setuid-search\", \"mesh.node.novox.declare\", \"mesh.seat.node-engine.call.*.novox.*\", \"mesh.seat.node-engine.tool.*.novox\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"novox.mesh-delivery-builder\", password: \"$2a$11$novox.mesh-delivery-bufixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.EVENTS.novox_mesh-delivery-builder.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_presence.*.$KV.mesh-delivery-builder_presence.novox\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.novox.mesh-delivery-builder\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_presence.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.\u003e\", \"$JS.API.CONSUMER.INFO.EVENTS.novox_mesh-delivery-builder\", \"$JS.API.CONSUMER.MSG.NEXT.EVENTS.novox_mesh-delivery-builder\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-builder_presence.$KV.mesh-delivery-builder_presence.novox\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.novox.mesh-delivery-builder\", \"$JS.API.DIRECT.GET.LOG_mesh-delivery-builder_builds\", \"$JS.API.DIRECT.GET.LOG_mesh-delivery-builder_builds.\u003e\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_presence\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_steps\", \"$JS.API.STREAM.INFO.KV_mesh-grants_memberships\", \"$JS.API.STREAM.INFO.LOG_mesh-delivery-builder_builds\", \"$JS.API.STREAM.INFO.SEAT_NODE_BUILD_GO\", \"$JS.API.STREAM.INFO.SEAT_NODE_BUILD_IMAGE\", \"$JS.API.STREAM.INFO.SEAT_NODE_BUILD_NODE\", \"$JS.API.STREAM.MSG.GET.SEAT_NODE_BUILD_GO\", \"$JS.API.STREAM.MSG.GET.SEAT_NODE_BUILD_IMAGE\", \"$JS.API.STREAM.MSG.GET.SEAT_NODE_BUILD_NODE\", \"$JS.FC.KV_mesh-delivery-builder_presence.\u003e\", \"$JS.FC.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.FC.KV_mesh-grants_memberships.\u003e\", \"$KV.mesh-delivery-builder_presence.novox\", \"$KV.mesh-delivery-builder_steps.\u003e\", \"mesh.log.mesh-delivery-builder.builds.\u003e\", \"mesh.mod.gitea.call.gitea_commit.*.novox~mesh-delivery-builder\", \"mesh.mod.gitea.call.gitea_commit.novox~mesh-delivery-builder\", \"mesh.mod.gitea.tool.gitea_commit\", \"mesh.mod.gitea.tool.gitea_commit.*\", \"mesh.mod.mesh-delivery-builder.event.built\", \"mesh.mod.mesh-delivery-builder.event.states\", \"mesh.stream.mesh-delivery-builder.event.ask\"] }\n subscribe: { allow: [\"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-builder_presence\", \"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-builder_steps\", \"$SRV.INFO\", \"$SRV.INFO.mesh-delivery-builder\", \"$SRV.INFO.mesh-delivery-builder.\u003e\", \"$SRV.PING\", \"$SRV.PING.mesh-delivery-builder\", \"$SRV.PING.mesh-delivery-builder.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.mesh-delivery-builder\", \"$SRV.STATS.mesh-delivery-builder.\u003e\", \"_INBOX.novox.mesh-delivery-builder.\u003e\", \"mesh.mod.mesh-delivery-builder.call.\u003e\", \"mesh.mod.mesh-delivery-builder.tool.\u003e\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"novox.mesh-delivery-builder.worker\", password: \"$2a$11$novox.mesh-delivery-bufixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.asks.novox_mesh-delivery-builder_worker.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.CONSUMER.INFO.asks.novox_mesh-delivery-builder_worker\", \"$JS.API.CONSUMER.MSG.NEXT.asks.novox_mesh-delivery-builder_worker\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_steps\", \"$JS.FC.KV_mesh-delivery-builder_steps.\u003e\", \"$KV.mesh-delivery-builder_steps.\u003e\", \"mesh.mod.gitea.call.gitea_commit.*.novox~mesh-delivery-builder~worker\", \"mesh.mod.gitea.call.gitea_commit.novox~mesh-delivery-builder~worker\", \"mesh.mod.gitea.tool.gitea_commit\", \"mesh.mod.gitea.tool.gitea_commit.*\", \"mesh.mod.mesh-delivery-builder.event.built\", \"mesh.mod.mesh-delivery-builder.event.states\"] }\n subscribe: { allow: [\"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-builder_steps\", \"_INBOX.novox.mesh-delivery-builder.worker.\u003e\"] }\n } }\n { user: \"novox.mesh-delivery-view\", password: \"$2a$11$novox.mesh-delivery-vifixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.EVENTS.novox_mesh-delivery-view.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_presence.*.$KV.mesh-delivery-builder_presence.novox\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.novox.mesh-delivery-view\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_presence.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.\u003e\", \"$JS.API.CONSUMER.INFO.EVENTS.novox_mesh-delivery-view\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_GO.SEAT_NODE_BUILD_GO_CHECK_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_GO.SEAT_NODE_BUILD_GO_DELIVERY_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_GO.SEAT_NODE_BUILD_GO_RECHECK_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_IMAGE.SEAT_NODE_BUILD_IMAGE_CHECK_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_IMAGE.SEAT_NODE_BUILD_IMAGE_DELIVERY_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_IMAGE.SEAT_NODE_BUILD_IMAGE_RECHECK_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_NODE.SEAT_NODE_BUILD_NODE_CHECK_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_NODE.SEAT_NODE_BUILD_NODE_DELIVERY_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_NODE.SEAT_NODE_BUILD_NODE_RECHECK_worker\", \"$JS.API.CONSUMER.MSG.NEXT.EVENTS.novox_mesh-delivery-view\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-builder_presence.$KV.mesh-delivery-builder_presence.novox\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.novox.mesh-delivery-view\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_presence\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_steps\", \"$JS.API.STREAM.INFO.KV_mesh-grants_memberships\", \"$JS.API.STREAM.INFO.SEAT_NODE_BUILD_GO\", \"$JS.API.STREAM.INFO.SEAT_NODE_BUILD_IMAGE\", \"$JS.API.STREAM.INFO.SEAT_NODE_BUILD_NODE\", \"$JS.API.STREAM.MSG.GET.SEAT_NODE_BUILD_GO\", \"$JS.API.STREAM.MSG.GET.SEAT_NODE_BUILD_IMAGE\", \"$JS.API.STREAM.MSG.GET.SEAT_NODE_BUILD_NODE\", \"$JS.FC.KV_mesh-delivery-builder_presence.\u003e\", \"$JS.FC.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.FC.KV_mesh-grants_memberships.\u003e\"] }\n subscribe: { allow: [\"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-builder_presence\", \"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-builder_steps\", \"$SRV.INFO\", \"$SRV.INFO.mesh-delivery-view\", \"$SRV.INFO.mesh-delivery-view.\u003e\", \"$SRV.PING\", \"$SRV.PING.mesh-delivery-view\", \"$SRV.PING.mesh-delivery-view.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.mesh-delivery-view\", \"$SRV.STATS.mesh-delivery-view.\u003e\", \"_INBOX.novox.mesh-delivery-view.\u003e\", \"mesh.mod.mesh-delivery-view.call.\u003e\", \"mesh.mod.mesh-delivery-view.tool.\u003e\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"novox.mesh-grants\", password: \"$2a$11$novox.mesh-grantsnovoxfixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.EVENTS.novox_mesh-grants.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_assertions.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.novox.mesh-grants\", \"$JS.API.CONSUMER.CREATE.asks.novox_mesh-delivery-builder_worker\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_assertions.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.\u003e\", \"$JS.API.CONSUMER.INFO.EVENTS.novox_mesh-grants\", \"$JS.API.CONSUMER.INFO.asks.novox_mesh-delivery-builder_worker\", \"$JS.API.CONSUMER.MSG.NEXT.EVENTS.novox_mesh-grants\", \"$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.*.*\", \"$JS.API.DIRECT.GET.KV_mesh-grants_assertions.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.novox.mesh-grants\", \"$JS.API.DIRECT.GET.OBJ_mesh-grants-user-list.\u003e\", \"$JS.API.STREAM.CREATE.KV_mesh-delivery-builder_presence\", \"$JS.API.STREAM.CREATE.KV_mesh-delivery-builder_steps\", \"$JS.API.STREAM.CREATE.KV_mesh-grants_assertions\", \"$JS.API.STREAM.CREATE.LOG_mesh-delivery-builder_builds\", \"$JS.API.STREAM.CREATE.OBJ_mesh-grants-user-list\", \"$JS.API.STREAM.CREATE.asks\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_presence\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_steps\", \"$JS.API.STREAM.INFO.KV_mesh-grants_assertions\", \"$JS.API.STREAM.INFO.KV_mesh-grants_memberships\", \"$JS.API.STREAM.INFO.LOG_mesh-delivery-builder_builds\", \"$JS.API.STREAM.INFO.OBJ_mesh-grants-user-list\", \"$JS.API.STREAM.INFO.asks\", \"$JS.API.STREAM.MSG.GET.OBJ_mesh-grants-user-list\", \"$JS.API.STREAM.PURGE.OBJ_mesh-grants-user-list\", \"$JS.FC.KV_mesh-grants_assertions.\u003e\", \"$JS.FC.KV_mesh-grants_memberships.\u003e\", \"$KV.mesh-grants_assertions.\u003e\", \"$O.mesh-grants-user-list.\u003e\", \"mesh.assignment.*.*\", \"mesh.mod.mesh-grants.event.membership-changed\", \"mesh.mod.mesh-grants.event.states\"] }\n subscribe: { allow: [\"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-grants_assertions\", \"$SRV.INFO\", \"$SRV.INFO.mesh-grants\", \"$SRV.INFO.mesh-grants.\u003e\", \"$SRV.PING\", \"$SRV.PING.mesh-grants\", \"$SRV.PING.mesh-grants.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.mesh-grants\", \"$SRV.STATS.mesh-grants.\u003e\", \"_INBOX.novox.mesh-grants.\u003e\", \"mesh.mod.mesh-grants.call.\u003e\", \"mesh.mod.mesh-grants.tool.\u003e\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n ]\n }\n \"$SYS\" {\n exports = [\n { stream: \"$SYS.ACCOUNT.MESH.CONNECT\", accounts: [\"MESH\"] }\n { stream: \"$SYS.ACCOUNT.MESH.DISCONNECT\", accounts: [\"MESH\"] }\n { service: \"$SYS.REQ.ACCOUNT.MESH.CONNZ\", accounts: [\"MESH\"] }\n ]\n }\n}\n", - "Missing": null, - "Err": "", - "Memberships": { - "ace.plex": { - "node": "ace", - "module": "plex", - "serves": [ - { - "subject": "mesh.mod.plex.tool.{tool}.ace" - }, - { - "subject": "mesh.mod.plex.tool.{tool}", - "queue": "serve.plex" - } - ], - "emits": "mesh.mod.plex.event.{event}", - "tools": "mesh.mod.plex.tool.tools" - }, - "ace.silent": { - "node": "ace", - "module": "silent", - "serves": [ - { - "subject": "mesh.mod.silent.tool.{tool}.ace" - }, - { - "subject": "mesh.mod.silent.tool.{tool}", - "queue": "serve.silent" - } - ], - "emits": "mesh.mod.silent.event.{event}", - "tools": "mesh.mod.silent.tool.tools" - }, - "novox.mesh-delivery-builder": { - "node": "novox", - "module": "mesh-delivery-builder", - "serves": [ - { - "subject": "mesh.mod.mesh-delivery-builder.tool.{tool}.novox" - }, - { - "subject": "mesh.mod.mesh-delivery-builder.tool.{tool}", - "queue": "serve.mesh-delivery-builder" - } - ], - "emits": "mesh.mod.mesh-delivery-builder.event.{event}", - "reaches": { - "gitea.gitea_commit": [ - "mesh.mod.gitea.tool.gitea_commit" - ] - }, - "tools": "mesh.mod.mesh-delivery-builder.tool.tools", - "state": [ - { - "name": "steps", - "bucket": "mesh-delivery-builder_steps", - "writes": true - }, - { - "name": "presence", - "bucket": "mesh-delivery-builder_presence", - "writes": true, - "key": "novox" - } - ], - "logs": [ - { - "name": "builds", - "stream": "LOG_mesh-delivery-builder_builds", - "subject": "mesh.log.mesh-delivery-builder.builds", - "writes": true - } - ], - "streams": [ - { - "stream": "asks", - "events": [ - "ask" - ], - "subject": "mesh.stream.mesh-delivery-builder.event.{event}" - } - ] - }, - "novox.mesh-delivery-view": { - "node": "novox", - "module": "mesh-delivery-view", - "serves": [ - { - "subject": "mesh.mod.mesh-delivery-view.tool.{tool}.novox" - }, - { - "subject": "mesh.mod.mesh-delivery-view.tool.{tool}", - "queue": "serve.mesh-delivery-view" - } - ], - "emits": "mesh.mod.mesh-delivery-view.event.{event}", - "tools": "mesh.mod.mesh-delivery-view.tool.tools", - "state": [ - { - "name": "mesh-delivery-builder.steps", - "bucket": "mesh-delivery-builder_steps" - }, - { - "name": "mesh-delivery-builder.presence", - "bucket": "mesh-delivery-builder_presence", - "key": "novox" - } - ] - }, - "novox.mesh-grants": { - "node": "novox", - "module": "mesh-grants", - "serves": [ - { - "subject": "mesh.mod.mesh-grants.tool.{tool}.novox" - }, - { - "subject": "mesh.mod.mesh-grants.tool.{tool}", - "queue": "serve.mesh-grants" - } - ], - "emits": "mesh.mod.mesh-grants.event.{event}", - "tools": "mesh.mod.mesh-grants.tool.tools", - "state": [ - { - "name": "assertions", - "bucket": "mesh-grants_assertions", - "writes": true - } - ] - } - } - } -] diff --git a/go/buscompose/userlist_switch.go b/go/buscompose/userlist_switch.go deleted file mode 100644 index 11ea7b9..0000000 --- a/go/buscompose/userlist_switch.go +++ /dev/null @@ -1,174 +0,0 @@ -package buscompose - -import ( - "slices" - "strings" - "time" - - "git.novox.be/novox/mesh-sdk/go/announce" - "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/health" -) - -// **The grants part's user list, sealed to the bus node** (novox/hq ADR 0547 clause 7, issue 1945). The grants part writes -// the list it composed as one object of its own object bucket, sealed to the key of the node holding mesh-broker, and -// names it in one key of its own state `user-list`. The node-engine of that node follows the key when the switch says -// so (clause 8), opens the object and writes the bus's user-list file. -// -// Who may do what, and nothing more: -// -// - the grants part writes its object bucket (create, put, purge of an object's old chunks, delete) and its own state -// as before; -// - the node-engine of the node holding mesh-broker reads that one key of the state, watched, and an object of the -// bucket by its name: never the bucket's list of names, never a write, never another key of the grants part's state; -// - no other principal touches either. - -// GrantsUserListBucket, GrantsUserListKey, GrantsUserListObjects and GrantsUserListObjectStream are delivery's names, -// here where the grants are composed. -const ( - GrantsUserListBucket = delivery.GrantsUserListBucket - GrantsUserListKey = delivery.GrantsUserListKey - GrantsUserListObjects = delivery.GrantsUserListObjects - GrantsUserListObjectStream = delivery.GrantsUserListObjectStream -) - -// GrantsUserListFollows is what the node-engine of the node holding mesh-broker may publish to follow the grants part's -// list: the one key `user-list.sealed` read and watched (its own ordered consumer, filtered on that key alone), and an -// object of the bucket by its name, as a node reads its declaration's files (ADR 0486 §2: never STREAM.INFO of the object -// stream, which lists every name, and never STREAM.MSG.GET). -func GrantsUserListFollows() []string { - kv := "KV_" + GrantsUserListBucket - key := "$KV." + GrantsUserListBucket + "." + GrantsUserListKey - obj, s := GrantsUserListObjects, GrantsUserListObjectStream - return []string{ - "$JS.API.DIRECT.GET." + kv + "." + key, - "$JS.API.CONSUMER.CREATE." + kv + ".*." + key, - "$JS.API.CONSUMER.DELETE." + kv + ".*", - "$JS.API.STREAM.INFO." + kv, - "$JS.FC." + kv + ".>", - "$JS.API.DIRECT.GET." + s + ".$O." + obj + ".M.*", - "$JS.API.CONSUMER.CREATE." + s + ".*.$O." + obj + ".C.*", - "$JS.API.CONSUMER.DELETE." + s + ".*", - "$JS.FC." + s + ".>", - } -} - -// GrantsUserListWrites is what the grants part may publish to keep its object bucket: create it and read its info back, -// put an object (its chunks and its metadata, the old chunks of a name purged), read an object's metadata, and delete -// one. Its own state `user-list` it writes already, as every module writes its own. -func GrantsUserListWrites() []string { - obj, s := GrantsUserListObjects, GrantsUserListObjectStream - return []string{ - "$JS.API.STREAM.CREATE." + s, - "$JS.API.STREAM.INFO." + s, - "$JS.API.STREAM.PURGE." + s, - "$JS.API.DIRECT.GET." + s + ".>", - "$JS.API.STREAM.MSG.GET." + s, - "$O." + obj + ".>", - } -} - -// grantsOwnObjects is the grants part writing its own object bucket, and nothing of another. -func grantsOwnObjects(p Principal, pattern string) bool { - return p.Kind == KindModule && p.Module == GrantsModule && - (pattern == "$O."+GrantsUserListObjects+".>" || pattern == "$JS.API.STREAM.CREATE."+GrantsUserListObjectStream) -} - -// **Named readers** (novox/hq ADR 0547 clause 9, progressive insight of 2026-10-26; issue 1945). A state named here is -// read by the modules named and by no other, whatever another module's `reads` says: the vault's `bus-accounts` holds -// every bus account's bcrypt hash, and from take-over the grants part alone may read it. A module's announcement may name -// 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 `.`: the grants part's wanted accounts -// are read by the vault alone (novox/hq ADR 0561 §2); nobody reads the healers' `would` (ADR 0564 clause 8, -// health_grants.go). -var NamedReaders = map[string][]string{VaultBusAccounts: {GrantsModule}, GrantsAccounts: {VaultSeat}, HealersWould: {}, - // ADR 0577 clause 1: the node-engine's data items are read by disk-load alone, each node's its own node's keys - // (NodeKeyedReads); the engine announces nothing of its own, so its readers are named here. - delivery.EngineDataRead: {health.DiskLoadModule}, - // ADR 0583 §1 insight: every node's login is read by the grants part alone, each engine its own node's key through its - // machine principal (NodeLoginsReads), never through `reads`. - delivery.NodeLoginsRead: {GrantsModule}, - // ADR 0587 §2: the open enrolments are read by their holder and, as a named reader, by the grants part. - delivery.EnrolmentsRead: {GrantsModule}} - -// ReaderMay says whether module may read the state read (`.`) by the named readers: always, for a state -// that names none. -func ReaderMay(module, read string) bool { - readers, named := NamedReaders[read] - return !named || slices.Contains(readers, module) -} - -// readsNamed splits a module's reads by the named readers: those it may read, and those named for others. -func readsNamed(module string, reads []string) (granted, refused []string) { - for _, r := range reads { - if ReaderMay(module, strings.TrimSpace(r)) { - granted = append(granted, r) - } else { - refused = append(refused, r) - } - } - 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) - if len(refused) == 0 { - return d - } - d.Reads = granted - 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 `..` (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: `..`. -func WithheldKey(w Withheld) string { return w.Module + "." + strings.ToLower(w.Stream) + "." + w.Node } diff --git a/go/buscompose/userlist_switch_integration_test.go b/go/buscompose/userlist_switch_integration_test.go deleted file mode 100644 index d8eb3fd..0000000 --- a/go/buscompose/userlist_switch_integration_test.go +++ /dev/null @@ -1,213 +0,0 @@ -//go:build integration - -package buscompose - -import ( - "bytes" - "context" - "encoding/base64" - "encoding/json" - "os" - "path/filepath" - "testing" - "time" - - "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/seal" - "github.com/nats-io/nats-server/v2/server" - "github.com/nats-io/nats.go" - "github.com/nats-io/nats.go/jetstream" -) - -// Defends novox/hq ADR 0547 clause 7 on a real server (issue 1945): with the list this composer writes, the grants part -// creates its object bucket and its state, puts the sealed list and names it in `user-list.sealed`; the node-engine of -// the node holding mesh-broker watches that one key, reads the object by its name and opens it with its key, and may -// neither list the bucket, nor read another key of the state, nor write; another node reads nothing of either. -func TestTheBusNodeFollowsTheGrantsPartsSealedListUnderTheComposedGrants(t *testing.T) { - records := Records{ - Nodes: []string{"novox", "ace"}, - Assigned: map[string][]Declared{ - "novox": {{Module: "nats", SnapshotsTheBus: true}, - {Module: GrantsModule, State: []Bucket{{Module: GrantsModule, Name: "user-list"}}}}, - "ace": {{Module: "plex"}}, - }, - } - users, err := Users(records) - if err != nil { - t.Fatal(err) - } - for i := range users { - users[i].PasswordHash = "pw-" + users[i].Username() - } - accounts, err := ComposeAccounts(users) - if err != nil { - t.Fatal(err) - } - dir := t.TempDir() - conf := filepath.Join(dir, "server.conf") - if err := os.WriteFile(conf, []byte("listen: 127.0.0.1:-1\njetstream { store_dir: \""+dir+"\" }\n"+accounts), 0o600); err != nil { - t.Fatal(err) - } - opts, err := server.ProcessConfigFile(conf) - if err != nil { - t.Fatalf("the composed list is not the server's: %v", err) - } - opts.NoLog, opts.NoSigs = true, true - s, err := server.NewServer(opts) - if err != nil { - t.Fatal(err) - } - go s.Start() - if !s.ReadyForConnections(30 * time.Second) { - t.Fatal("the bus did not come up") - } - t.Cleanup(s.Shutdown) - dial := func(user string) *nats.Conn { - nc, err := nats.Connect(s.ClientURL(), nats.UserInfo(user, "pw-"+user), nats.CustomInboxPrefix("_INBOX."+user)) - if err != nil { - t.Fatalf("%s: %v", user, err) - } - t.Cleanup(nc.Close) - return nc - } - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - // The grants part: its state and its object bucket, the sealed list put and named. - grants, err := jetstream.New(dial("novox." + GrantsModule)) - if err != nil { - t.Fatal(err) - } - // Its state is made as the grants part makes a declared state, by its stream's name (CreateKeyValue would ask the - // account's info, which no module is granted). - if _, err := grants.CreateStream(ctx, jetstream.StreamConfig{Name: "KV_" + GrantsUserListBucket, - Subjects: []string{"$KV." + GrantsUserListBucket + ".>"}, MaxMsgsPerSubject: 1, AllowDirect: true, - AllowRollup: true, DenyDelete: true, Discard: jetstream.DiscardNew}); err != nil { - t.Fatalf("the grants part's state: %v", err) - } - kv, err := grants.KeyValue(ctx, GrantsUserListBucket) - if err != nil { - t.Fatalf("the grants part binds its state: %v", err) - } - objects, err := grants.CreateObjectStore(ctx, jetstream.ObjectStoreConfig{Bucket: GrantsUserListObjects}) - if err != nil { - t.Fatalf("the grants part's object bucket: %v", err) - } - node, err := seal.GenerateKeyPair() - if err != nil { - t.Fatal(err) - } - list := []byte(accounts) - digest := delivery.UserListDigest(list) - object, err := delivery.SealUserList(list, node.PublicKey) - if err != nil { - t.Fatal(err) - } - name := delivery.UserListObjectName(digest) - for range 2 { // put twice: a second put of a name purges its old chunks - if _, err := objects.PutBytes(ctx, name, object); err != nil { - t.Fatalf("the grants part put its object: %v", err) - } - } - value, err := delivery.EncodeGrantsUserList(delivery.GrantsUserList{Follow: delivery.FollowGrants, Digest: digest, - Object: name, SealedTo: delivery.SealingKeyID(node.PublicKey), Node: "novox", Revision: 1, Users: len(users)}) - if err != nil { - t.Fatal(err) - } - if _, err := kv.Put(ctx, GrantsUserListKey, value); err != nil { - t.Fatal(err) - } - if _, err := kv.Put(ctx, "list", []byte(`{"v":1}`)); err != nil { - t.Fatal(err) - } - - // The bus node's engine: the key watched, the object by name (as mesh-host's link reads a declaration's file). - busNode := dial("node.novox") - js, err := busNode.JetStream(nats.MaxWait(5 * time.Second)) - if err != nil { - t.Fatal(err) - } - keys, err := js.KeyValue(GrantsUserListBucket) - if err != nil { - t.Fatalf("the bus node binds the grants part's state: %v", err) - } - w, err := keys.Watch(GrantsUserListKey) - if err != nil { - t.Fatalf("the bus node watches the key: %v", err) - } - defer w.Stop() - var got delivery.GrantsUserList - select { - case e := <-w.Updates(): - if e == nil { - t.Fatal("the watch found no value") - } - if got, err = delivery.DecodeGrantsUserList(e.Value()); err != nil { - t.Fatal(err) - } - case <-ctx.Done(): - t.Fatal("the watch said nothing") - } - read := func(js nats.JetStreamContext, name string) ([]byte, error) { - meta, err := js.GetLastMsg(GrantsUserListObjectStream, "$O."+GrantsUserListObjects+".M."+ - base64.URLEncoding.EncodeToString([]byte(name)), nats.DirectGet()) - if err != nil { - return nil, err - } - var info struct { - NUID string `json:"nuid"` - Size int `json:"size"` - } - if err := json.Unmarshal(meta.Data, &info); err != nil { - return nil, err - } - sub, err := js.SubscribeSync("$O."+GrantsUserListObjects+".C."+info.NUID, nats.OrderedConsumer(), - nats.BindStream(GrantsUserListObjectStream)) - if err != nil { - return nil, err - } - defer sub.Unsubscribe() - var body []byte - for len(body) < info.Size { - m, err := sub.NextMsg(5 * time.Second) - if err != nil { - return nil, err - } - body = append(body, m.Data...) - } - return body, nil - } - body, err := read(js, got.Object) - if err != nil { - t.Fatalf("the bus node reads the object by its name: %v", err) - } - opened, err := delivery.OpenUserList(body, got.Digest, func(b seal.Box) ([]byte, error) { - s, err := seal.Open(b, node.PrivateKey) - return []byte(s), err - }) - if err != nil || !bytes.Equal(opened, list) { - t.Fatalf("the bus node opened %d bytes, %v", len(opened), err) - } - - // And nothing beyond: no other key, no list of the bucket, no write. - if _, err := keys.Get("list"); err == nil { - t.Error("the bus node read another key of the grants part's state") - } - if _, err := js.StreamInfo(GrantsUserListObjectStream); err == nil { - t.Error("the bus node read the object stream's info, which lists every name") - } - if _, err := js.Publish("$O."+GrantsUserListObjects+".M.x", []byte("x")); err == nil { - t.Error("the bus node wrote the grants part's bucket") - } - // Another node reads nothing of it. - other, err := dial("node.ace").JetStream(nats.MaxWait(2 * time.Second)) - if err != nil { - t.Fatal(err) - } - if _, err := read(other, got.Object); err == nil { - t.Error("another node read the object") - } - if _, err := other.GetLastMsg("KV_"+GrantsUserListBucket, "$KV."+GrantsUserListBucket+"."+GrantsUserListKey, nats.DirectGet()); err == nil { - t.Errorf("another node read the key: %v", err) - } -} diff --git a/go/buscompose/users.go b/go/buscompose/users.go index ea1668f..0eba1c7 100644 --- a/go/buscompose/users.go +++ b/go/buscompose/users.go @@ -1,28 +1,5 @@ package buscompose -import ( - "fmt" - "slices" - "sort" - "strings" - - "git.novox.be/novox/mesh-sdk/go/announce" - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -// Every user the composed file should contain, derived from what the mesh knows. -// -// **The list is derived, never kept.** A stored user list would be a second account of who may -// reach the bus, able to disagree with the records it came from — and the disagreement would be -// invisible, because both would look internally consistent. So this is a pure function of the -// mesh's records, run again every time the file is written. -// -// Records are mirrored into this package's own types rather than imported from the catalogue, for -// the reason DeclaredSeat is: composing authority is a different job from parsing a manifest, and -// this package stays free of the other's types so a change to a manifest field cannot quietly widen -// a permission. - // Declared is one module on one node, as composing its authority needs it. type Declared struct { Module string @@ -124,445 +101,5 @@ type ProcessPrincipal struct { Invokes []string } -// Records is what composing a user list needs to know about the mesh, and nothing more. -type Records struct { - // Nodes is every machine the mesh knows. Each gets a host user. - Nodes []string - // Assigned is the modules on each node, as they declare themselves. - Assigned map[string][]Declared - // Enrolling is every node with a live token — one enrolment user each, because the inbox an - // answer goes to is scoped to the token and a shared one is one machine reading another's - // sealed credentials (design 25 §6). The grants part fills it from the seat mesh-enrolment's keys at live or - // redeeming (novox/hq ADR 0587 §4), the controller from its store. - Enrolling []string - // People is each person's name against the tools they may invoke, `*` for an administrator. - People map[string][]string - // Interchangeable is each module whose definition says its instances are the same anywhere - // (ADR 0160), which decides whether the module's plain subject is issued to every instance. - Interchangeable map[string]bool - // RootFree is each machine judged root-free when this was composed (novox/hq ADR 0259 §8): it names an - // account agents run as, judged unable to become root by its node-engine, and serves no login shell - // execute. A machine absent is not free: no record of a pass is no pass. - RootFree map[string]bool - // Engine is the node-engine's own declaration, from its manifest (EngineModule): placed on every node though no node is - // assigned it, since every node runs it (novox/hq ADR 0563 §2, §3). Its state, logs and streams are created by the - // grants part as a placed module's are; it adds no principal, since each node's engine speaks as the node's account. - // Nil where its manifest was not read. - Engine *Declared - // NodeLogins is each node's key of the vault's node-logins (novox/hq ADR 0583 §1): the login in use and the one - // rotating, each composed as a user of the node's permissions. A node absent logs in as `node.` alone. - NodeLogins map[string]delivery.NodeLogin - // Announced is each module's last announcement of the states it keeps (novox/hq ADR 0495, issue 1790), as the - // controller keeps it; a module absent has announced nothing yet. Nil where none was read. - Announced map[string]announce.States -} - -// Users is every user the composed file should contain, in the order it will be written. -func Users(r Records) ([]Principal, error) { - var out []Principal - - served := servedReadsOf(r) - logs := DeclaredLogs(r) - grants := -1 - for _, node := range sortedCopy(r.Nodes) { - var checks []string - bus := false - for _, d := range r.Assigned[node] { - if d.SnapshotsTheBus { - bus = true - } - checks = append(checks, d.Checks...) - } - // **One user per login the node has** (novox/hq ADR 0583 §1): the one in use, and while it rotates the other too, - // each with the node's permissions. - for _, login := range nodeLoginsOf(r, node) { - out = append(out, Principal{Kind: KindNode, Node: node, Login: login, Checks: checks, - MovesIntoTheVault: servesMoveIn(r), StatesItsKeyToTheVault: servesVerb(r, VaultNodeKey), - AsksItsOwnSecrets: servesVerb(r, VaultOwn), AsksTheVaultToSign: servesVerb(r, VaultSign), Uses: asksSeatOf(r), - FollowsGrantsUserList: bus, ServedReads: servedOn(served, node), RotatesItsLogin: servesVerb(r, VaultRotateNode)}) - } - // **And the machine's provisioner client, on an account of its own** (novox/hq ADR 0349 clause 11): one - // per machine that runs it, delivered to the module running it. - for _, d := range r.Assigned[node] { - if d.ProvisionsNode { - out = append(out, Principal{Kind: KindProvisioner, Node: node, Module: d.Module, - BoundBuckets: boundBucketsOf(r)}) - break - } - } - // **Where the runtime is assigned, the machine gets one runtime principal in place of the - // runtime module's own** (novox/hq ADR 0175, to-be 38). It carries every module on the - // node: its serving grants are the union of theirs. Every other module keeps its own - // principal — a module still serving tools from its own container holds its own - // credential until it moves, and the two serve side by side in the meantime. - runtimeHere := false - for _, d := range r.Assigned[node] { - if d.Module == RuntimeModule { - runtimeHere = true - } - } - for _, d := range r.Assigned[node] { - // **The node-engine's manifest is no placed module's** (novox/hq ADR 0563 §2, §3; issue 774): where a placement - // names it, it adds no user, no process and nothing the runner carries; its grants are the node's account's. - if !Composed(d) { - continue - } - // A state with named readers is read by them alone (ADR 0547 clause 9), and a read is granted what its owner - // announced (ADR 0495 clause 3): judged here, for this reader, whatever split the caller made (issue 774). - d = withReadLogs(withAnnouncedReads(d, r.Announced), logs) - // **A process account is its own, delivered in its own secret** (broker-, 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 - // operator's account (ADR 0436 §5): added here, before either of those is judged. - for _, pr := range d.Processes { - out = append(out, Principal{ - Kind: KindProcess, Node: node, Module: d.Module, Process: pr.Process, - Emits: pr.Emits, Queues: pr.Queues, State: pr.State, Uses: d.Uses, Asks: pr.Asks, - Invokes: pr.Invokes, Removes: pr.Removes, - }) - // The ended controller's leftovers include each node's declaration files' bucket (ADR 0590 clause 6). - if slices.Contains(pr.Removes, catalogue.RemovesControllerLeftovers) { - out[len(out)-1].FilesFor = sortedCopy(r.Nodes) - } - } - if runtimeHere && d.Module == RuntimeModule { - continue - } - // **A module with nowhere to read an account is no user** (novox/hq issue 195). `module - // issue` refuses it one (issue 078: an account nothing reads is an orphan), so its user - // could only ever be left out of the file for want of a password — and every such module - // was named, on every status and plan, as a credential the mesh had not minted. Where the - // runtime is, it speaks for the module; where it is not, the module cannot speak at all, - // and a user would not change that. - if d.NoAccount { - continue - } - out = append(out, Principal{ - Kind: KindModule, Node: node, Module: d.Module, - Emits: d.Emits, Consumes: d.Consumes, Serves: d.Serves, - Holds: d.Holds, Uses: d.Uses, Watches: d.Watches, Invokes: d.Invokes, - State: stateNames(d.State), Reads: d.Reads, ReadLogs: d.ReadLogs, SnapshotsTheBus: d.SnapshotsTheBus, - 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 - } - // **The vault's holder asks the desks and the issuing providers itself** (issue 723), under the - // account its own runtime runs on (ADR 0259 §8's runs-as): the one module granted it. - if holdsSeat(d, VaultSeat) { - out[len(out)-1].AsksTheDesk = true - out[len(out)-1].AsksIssued = issuedOf(r) - } - } - if runtimeHere { - var carried []Declared - for _, d := range r.Assigned[node] { - 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) - } - } - out = append(out, Principal{Kind: KindNodeTools, Node: node, Module: RuntimeModule, Carries: carried}) - } - } - // A node enrols through the seat mesh-enrolment alone (ADR 0587 §6, ADR 0590): while no holder serves redeem, an - // enrolling node has nowhere to enrol, and gets no user. - if servesVerb(r, EnrolmentRedeem) { - for _, node := range sortedCopy(r.Enrolling) { - out = append(out, Principal{Kind: KindEnrolment, Node: node, Redeems: true}) - } - } - for _, person := range sortedNames(r.People) { - out = append(out, Principal{Kind: KindPerson, Module: person, Invokes: r.People[person]}) - } - - // The vault's enrolment verbs are mesh-enrolment's alone, however wide another's grant (novox/hq ADR 0587 §1). - if slices.ContainsFunc(VerbsTheEnrolmentAsksTheVault, func(v SeatVerb) bool { return servesVerb(r, v) }) { - for i := range out { - out[i].VaultEnrols = true - } - } - - // The grants part creates the durable consumers the accounts need (novox/hq ADR 0561 §6), by stream and name. - if grants >= 0 { - out[grants].CreatesConsumers = AccountConsumers(out) - } - - // Refused here rather than discovered by the server. Two users with one name is a file the - // server reads as one of them, and which one depends on the order — so a module assigned to a - // node twice, or a person named after nothing, is a composition that must not be written. - seen := map[string]string{} - for _, p := range out { - name := p.Username() - if name == "" || name == "." { - return nil, fmt.Errorf("a %s user has no name, so nothing could authenticate as it", p.Kind) - } - if first, already := seen[name]; already { - return nil, fmt.Errorf( - "two users would be called %q (a %s and a %s): the server would read the file as "+ - "one of them, and which one depends on the order", name, first, p.Kind) - } - seen[name] = string(p.Kind) - } - return out, nil -} - -// WithPasswords fills each user's hash from what the mesh minted, and says which users have none. -// -// **Separated from Users because they fail differently.** A user missing from the records is a bug -// in deriving them; a user with no password is a step that has not happened yet — a module assigned -// but never given a credential, a node enrolled before this existed. The second is ordinary and its -// remedy is to mint one, so it is named rather than returned as an error, and the caller decides -// whether a partial composition is worth writing. -func WithPasswords(principals []Principal, hashes map[string]string) (filled []Principal, missing []string) { - for _, p := range principals { - hash, ok := hashes[p.Username()] - if !ok || hash == "" { - missing = append(missing, p.Username()) - continue - } - p.PasswordHash = hash - filled = append(filled, p) - } - return filled, missing -} - -func sortedCopy(in []string) []string { - out := append([]string(nil), in...) - sort.Strings(out) - return out -} - -func sortedNames(in map[string][]string) []string { - out := make([]string, 0, len(in)) - for k := range in { - out = append(out, k) - } - sort.Strings(out) - return out -} - -// asksSeatOf is the seat an ask is made on, as its holder declares it (novox/hq ADR 0259): the controller -// asks the operator through it like any other user, and is granted what its declaration names for a caller. -// None while nothing holds it. -// issuedOf is every tool of a module assigned anywhere that answers a credential its software issued, as -// `.` (novox/hq ADR 0305): what the controller asks in its own name, and nothing beside. -func issuedOf(r Records) []string { - seen := map[string]bool{} - var out []string - for _, node := range sortedCopy(r.Nodes) { - for _, d := range r.Assigned[node] { - for _, t := range d.Issued { - if key := d.Module + "." + t; !seen[key] { - seen[key] = true - out = append(out, key) - } - } - } - } - sort.Strings(out) - return out -} - -func asksSeatOf(r Records) []Seat { - for _, node := range sortedCopy(r.Nodes) { - for _, d := range r.Assigned[node] { - for _, s := range d.Holds { - if s.Name == AsksSeat && namesVerb(s.ByCaller, "ask") { - seat := s - seat.Kind, seat.Capabilities = "", nil - return []Seat{seat} - } - } - } - } - return nil -} - -// nodeLoginsOf are the logins composed for node, as Principal.Login names them ("" for `node.`): the one its -// node-logins key says is in use and the one rotating, in that order; `node.` alone when the key is absent or does -// not hold (a key naming another node's login is no login of this node's). -func nodeLoginsOf(r Records, node string) []string { - l, ok := r.NodeLogins[node] - if !ok || l.Node != node || (l.Login != delivery.FirstLogin(node) && l.Login != delivery.SecondLogin(node)) { - return []string{""} - } - login := func(name string) string { - if name == delivery.FirstLogin(node) { - return "" - } - return name - } - out := []string{login(l.Login)} - if l.Rotates() && l.Rotating == delivery.OtherLogin(node, l.Login) { - out = append(out, login(l.Rotating)) - } - return out -} - // VaultSeat is the vault's seat: its holder alone asks the desks and the issuing providers in its own name. const VaultSeat = "mesh-vault" - -func holdsSeat(d Declared, seat string) bool { - for _, s := range d.Holds { - if s.Name == seat { - return true - } - } - return false -} - -// servedOf are the verbs of asked that a seat's holder serves, as the records say, by name. -func servedOf(r Records, asked []SeatVerb) []string { - seen := map[string]bool{} - var out []string - for _, node := range sortedCopy(r.Nodes) { - for _, d := range r.Assigned[node] { - for _, s := range d.Holds { - for _, v := range asked { - if s.Name == v.Seat && slices.Contains(s.Serves, v.Verb) && !seen[v.Verb] { - seen[v.Verb] = true - out = append(out, v.Verb) - } - } - } - } - } - sort.Strings(out) - return out -} - -// servesMoveIn says the vault's holder serves move-in, as the records say (issue 676): what lets each machine's -// node-engine ask it in its own name. -func servesMoveIn(r Records) bool { return servesVerb(r, VaultMoveIn) } - -// servesVerb says a holder of verb's seat serves it, as the records say. -func servesVerb(r Records, verb SeatVerb) bool { - for _, node := range r.Nodes { - for _, d := range r.Assigned[node] { - for _, s := range d.Holds { - if s.Name == verb.Seat && slices.Contains(s.Serves, verb.Verb) { - return true - } - } - } - } - return false -} - -// boundBucketsOf is every provider's bucket of entries, `_bound` (mesh-sdk go/provider, issue 678): the state -// named bound of every module placed anywhere, once each, in name order. A consumer on any machine may be bound to -// it, so each machine's provisioner client reads its own keys of each (issue 679). -func boundBucketsOf(r Records) []string { - seen := map[string]bool{} - for _, mods := range r.Assigned { - for _, d := range mods { - for _, b := range d.State { - if b.Name == BoundState { - seen[b.Bucket()] = true - } - } - } - } - out := make([]string, 0, len(seen)) - for b := range seen { - out = append(out, b) - } - sort.Strings(out) - 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 -// carrying it would hand every session on the machine the runner's account with that module's state, seat -// traffic and streams. A module with no account of its own (NoAccount) is spoken for by the runner, so it -// stays carried, and so does the runtime module itself. Whether the module has its own broker secret is not -// the test: a module with tools and its own credential is still carried (ADR 0175). -func runnerServes(d Declared) bool { - return d.Module == RuntimeModule || d.NoAccount || d.Loads || len(d.Serves) > 0 || len(d.Holds) > 0 || len(d.Waits) > 0 || len(d.Checks) > 0 -} - -// declaredStreamsOf is the stream of every bucket, log and stream a module placed anywhere declares, once each, in name -// order (novox/hq ADR 0547 §5, issue 1790): `KV__`, `LOG__` and each module stream's own -// name. What the grants part may create, and nothing beside. -func declaredStreamsOf(r Records) []string { - seen := map[string]bool{} - placed := [][]Declared{} - for _, mods := range r.Assigned { - placed = append(placed, mods) - } - // The node-engine's manifest is placed on every node (ADR 0563): its declared state is created as a placed module's. - if r.Engine != nil { - placed = append(placed, []Declared{*r.Engine}) - } - for _, mods := range placed { - for _, d := range mods { - for _, b := range d.State { - seen["KV_"+b.Bucket()] = true - } - for _, l := range d.Logs { - seen[l.Stream()] = true - } - for _, s := range d.Streams { - seen[s.Name] = true - } - } - } - out := make([]string, 0, len(seen)) - for s := range seen { - out = append(out, s) - } - sort.Strings(out) - return out -} - -// servedReadsOf is every key of mesh-config's resolved values a module assigned on a node serves from, -// `..`, sorted: each part a subject token, so a key that cannot be one is granted nothing (and the -// node composing a consumer of it refuses, naming it), never a wider read. -func servedReadsOf(r Records) []string { - var out []string - for node, declared := range r.Assigned { - for _, d := range declared { - for _, k := range d.Served { - if safeSubject.MatchString(node) && safeSubject.MatchString(d.Module) && safeSubject.MatchString(k) { - out = append(out, node+"."+d.Module+"."+k) - } - } - } - } - sort.Strings(out) - return slices.Compact(out) -} - -// servedOn is, of the served keys, those of other nodes than node: its own slice it reads whole. -func servedOn(served []string, node string) []string { - var out []string - for _, k := range served { - if !strings.HasPrefix(k, node+".") { - out = append(out, k) - } - } - return out -} diff --git a/go/buscompose/waits.go b/go/buscompose/waits.go index c92300e..b10e6c0 100644 --- a/go/buscompose/waits.go +++ b/go/buscompose/waits.go @@ -1,19 +1,5 @@ package buscompose -import ( - "fmt" - "strings" -) - -// What a session may wait on through the mesh MCP server (novox/hq ADR 0367). -// -// **A grant apart from `consumes`, and the tool runner's alone.** The tool runner's module names in its -// manifest the event families a session may wait on, one by one; the runtime's account is granted a plain -// subscription on those subjects and nothing wider — no durable consumer, no queue of its own — and its -// membership names the same list, from which `mesh_wait` refuses any other. Both are read from this one -// composition, so the grant and what the tool runner offers cannot disagree, and nothing but the manifest -// adds a family (§6). - // Wait is one event family the tool runner's module declares: its name as the manifest gives it, and the // emitter — a seat or a module — and the event, as the inventory resolved them. type Wait struct { @@ -30,65 +16,3 @@ type Wait struct { // on with it given, and refused without. Last string } - -// WaitSubject is where a family's events land. Each token is checked before it becomes a subject: a -// wildcard, a dot inside a name or an empty token would widen the grant. -func WaitSubject(w Wait) (string, error) { - if !safeSubject.MatchString(w.Emitter) { - return "", fmt.Errorf("%q cannot name an emitter of a waited event: it would widen the subject", w.Emitter) - } - for _, t := range strings.Split(w.Event, ".") { - if !safeSubject.MatchString(t) { - return "", fmt.Errorf("%q cannot name a waited event: each of its names is plain, never a wildcard "+ - "(novox/hq ADR 0367 §2)", w.Event) - } - } - if w.Last != "" && !strings.Contains(w.Event, ".") { - return "", fmt.Errorf("%s names no %s: %s's events are said for each %s, so a family is %s.%s.<%s>; "+ - "without it no event would ever be heard (novox/hq ADR 0367 §5)", - w.Name, w.Last, w.Emitter, w.Last, w.Emitter, w.Event, w.Last) - } - if w.Seat { - return "mesh.seat." + w.Emitter + ".event." + w.Event, nil - } - return "mesh.mod." + w.Emitter + ".event." + w.Event, nil -} - -// waitGrants is the subscription the tool runner's account is granted for its module's families, and an -// error for a carried module other than the tool runner's that declares any (ADR 0367 §2). -func waitGrants(carries []Declared) ([]string, error) { - var sub []string - for _, d := range carries { - if len(d.Waits) == 0 { - continue - } - if d.Module != RuntimeModule { - return nil, fmt.Errorf("%s declares what a session waits on; only %s, the tool runner's module, "+ - "does (novox/hq ADR 0367 §2)", d.Module, RuntimeModule) - } - for _, w := range d.Waits { - s, err := WaitSubject(w) - if err != nil { - return nil, err - } - sub = append(sub, s) - } - } - return sub, nil -} - -// waitsIssuedFor is the families a membership names: the tool runner's module's, and nobody else's. -func waitsIssuedFor(d Declared) []WaitIssued { - if d.Module != RuntimeModule { - return nil - } - var out []WaitIssued - for _, w := range d.Waits { - s, err := WaitSubject(w) - if err != nil { - continue // refused where the account is composed, which names it - } - out = append(out, WaitIssued{Event: w.Name, Subject: s}) - } - return out -} diff --git a/go/buscompose/writers.go b/go/buscompose/writers.go deleted file mode 100644 index 8ae64d6..0000000 --- a/go/buscompose/writers.go +++ /dev/null @@ -1,282 +0,0 @@ -package buscompose - -import ( - "fmt" - "slices" - "strings" - - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -// The writers table (novox/hq to-be 45 §1, ADR 0227 rule 1), compiled in. -// -// **Every kind of state the core keeps has one writer; anyone else asks it.** The table is the design's, -// row for row, with what each row writes on the bus where it writes there. Two things read it: the -// composition of every principal's grants (PermissionsFor), which **refuses a grant that lets a -// principal publish on a subject another writes** — so a second writer cannot be granted by accident, -// and the composition says which state and whose — and the test beside it, which walks the rows -// against the design's list and the composition of a whole mesh. Changing a writer is a change to -// this table, through a decision. - -// WriterRow is one row of the writers table. -type WriterRow struct { - State string - Writer string - KeptIn string - Others string - // Subjects are the bus subjects a write of this state is a publish to; none for state kept off the - // bus (a module's own store) or not built yet. - Subjects []string - // Writes says a principal granted a publish pattern overlapping Subjects is this state's writer — - // given the pattern, because a machine writes its own report and no other's. - Writes func(p Principal, pattern string) bool - // Shared says why more than one principal may publish here; empty for one writer. - Shared string -} - -// createsDeclared, and the other writers' tests, are who a row's writer is as a principal. -// -// createsDeclared is the grants part creating one stream a manifest declares, by its own name: the create alone, never an -// update or a delete (novox/hq ADR 0547 §5; the operator, 2026-10-26 08:54); or the bus module's tools deleting one of -// the ended controller's leftover streams (ADR 0590 clause 6). -func createsDeclared(p Principal, pattern string) bool { - if removesLeftover(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 - } - return ok && p.Kind == KindModule && p.Module == GrantsModule && slices.Contains(p.CreatesStreams, name) && - safeSubject.MatchString(name) -} - -// ownMachine is a node writing a subject whose third token is its own name, and no wildcard there. -func ownMachine(p Principal, pattern string) bool { - tokens := strings.Split(pattern, ".") - return p.Kind == KindNode && len(tokens) > 2 && tokens[2] == p.Node -} - -// holdsSeatOf is a principal holding the seat a `mesh.seat..…` pattern names: its module's own -// principal, or the node tools carrying a module that holds it (ADR 0175, the runtime is its modules). -func holdsSeatOf(p Principal, pattern string) bool { - tokens := strings.Split(pattern, ".") - if len(tokens) < 3 { - return false - } - seat := tokens[2] - holds := func(seats []Seat) bool { - return slices.ContainsFunc(seats, func(s Seat) bool { return s.Name == seat }) - } - switch p.Kind { - case KindModule: - return holds(p.Holds) - case KindNodeTools: - return slices.ContainsFunc(p.Carries, func(d Declared) bool { return holds(d.Holds) }) - } - return false -} - -// ownModule is a module publishing under its own name, or the node tools carrying it. -func ownModule(p Principal, pattern string) bool { - tokens := strings.Split(pattern, ".") - if len(tokens) < 3 { - return false - } - module := tokens[2] - switch p.Kind { - case KindModule: - return p.Module == module - case KindNodeTools: - return slices.ContainsFunc(p.Carries, func(d Declared) bool { return d.Module == module }) - } - return false -} - -// moduleNamed is the writer that is module: its own principal, a process of it, or the node tools carrying it. -func moduleNamed(module string) func(Principal, string) bool { - return func(p Principal, _ string) bool { - switch p.Kind { - case KindModule, KindProcess: - return p.Module == module - case KindNodeTools: - return slices.ContainsFunc(p.Carries, func(d Declared) bool { return d.Module == module }) - } - return false - } -} - -// ownAnnouncer is a module, a process of it or the node tools carrying it saying what that module keeps (novox/hq ADR -// 0495 clause 2): each under its own name alone. -func ownAnnouncer(p Principal, pattern string) bool { - tokens := strings.Split(pattern, ".") - if len(tokens) < 3 { - return false - } - if p.Kind == KindProcess { - return p.Module == tokens[2] - } - return ownModule(p, pattern) -} - -// ownProvisioner is a machine's provisioner client saying a report about its own machine: the third token of -// `prov.applied..…` is its machine (novox/hq ADR 0349 §4, design 57 P3). -func ownProvisioner(p Principal, pattern string) bool { - tokens := strings.Split(pattern, ".") - return p.Kind == KindProvisioner && len(tokens) > 3 && tokens[2] == p.Node -} - -// ownNodeKeys is a node writing the have keys of its own node: `$KV...…`, the node in the third token -// and no wildcard there (novox/hq ADR 0429 §1). -func ownNodeKeys(p Principal, pattern string) bool { - tokens := strings.Split(pattern, ".") - return p.Kind == KindNode && len(tokens) > 2 && tokens[2] == p.Node -} - -// kvOf is a bucket's write subjects. -func kvOf(bucket string) []string { return []string{"$KV." + bucket + ".>"} } - -// WritersTable is to-be 45 §1, in its order. -var WritersTable = []WriterRow{ - {State: "a machine's applied state and its report", Writer: "the node-engine's apply queue", - KeptIn: "the machine; the report on the bus", Others: "the reconcile and a delivery enqueue, never apply", - // And its health statement between reports (novox/hq ADR 0240): the same writer stating the same - // machine, inside the grant it already had (`mesh.control..>`). And a line mesh-cli was given - // on the machine (novox/hq ADR 0272): that subject is this machine's engine's alone. - Subjects: []string{"mesh.control.*.report", "mesh.control.*.health", "mesh.control.*.cli"}, Writes: ownMachine}, - {State: "stream definitions and bus permissions", Writer: "the grants part", KeptIn: "the bus", Others: "—", - // A stream's definition, and a durable consumer's by the API that names it so. Not every - // consumer create: a module watching its own bucket makes and deletes an ordered consumer on the - // bucket's stream (ADR 0201), which defines nothing the mesh keeps. - Subjects: []string{"$JS.API.STREAM.CREATE.>", "$JS.API.STREAM.UPDATE.>", "$JS.API.STREAM.DELETE.>", - "$JS.API.CONSUMER.DURABLE.CREATE.>"}, - Writes: createsDeclared, - Shared: "the grants part creates a stream a manifest declares, by its name, and never updates or deletes one (novox/hq ADR 0547 §5; the operator, 2026-10-26 08:54); the bus module's tools account deletes the ended controller's leftover streams, each by its name, and no other (novox/hq ADR 0590 clause 6)"}, - {State: "builds and their outcomes", Writer: "the build seat's holder", KeptIn: "its own state", - Others: "the builder asks", - Subjects: []string{"mesh.seat.node-build-agent.event.built", "mesh.seat.mesh-build-machine.event.built"}, - Writes: holdsSeatOf, - Shared: "every machine holding the build seat answers the asks it took; each outcome names its ask"}, - // What a module keeps and who may read it (novox/hq ADR 0495 clauses 2 and 3, issue 1790): said by the module. - {State: "a module's states announced", Writer: "the module itself, from its code, under its own name", - KeptIn: "the bus (EVENTS)", Others: "the grants part grants readers from it", - Subjects: []string{"mesh.mod.*.event.states"}, Writes: ownAnnouncer}, - {State: "a merge announced", Writer: "one announcer per forge (the hook, or the poll when the hook is absent — never both)", - KeptIn: "the bus", Others: "—", Subjects: []string{"mesh.mod.*.event.pull.merged"}, Writes: ownModule}, - // A pull request's head, before it merges (novox/hq to-be 45 §9): the same one announcer. - {State: "a pull request's head announced", Writer: "the forge's announcer, the one that announces its merges", - KeptIn: "the bus", Others: "the checker checks it", Subjects: []string{"mesh.mod.*.event.pull.updated"}, - Writes: ownModule}, - {State: "a pull request's merge check", Writer: "the checker that ran it", - KeptIn: "the bus", Others: "the forge's holder sets it as the pull request's status"}, - {State: "a provider's standing", Writer: "the provider", KeptIn: "the provider's events", - Others: "the watchman keeps the newest word as a condition", - Subjects: []string{"mesh.mod.*.event.provisioner.failing", "mesh.mod.*.event.provisioner.recovered", - "mesh.mod.*.event.provisioner.retirement"}, - Writes: ownModule}, - {State: "the operator-channel's open messages", Writer: "the seat's holder", KeptIn: "its own key-value state", - Others: "—"}, - // The seat mesh-enrolment's open enrolments (novox/hq ADR 0587 §2, issue 774): its holder alone writes them, each a - // compare-and-set at the revision read; the grants part reads them as a named reader. - {State: "the open enrolments", Writer: "the seat mesh-enrolment's holder, compare-and-set", - KeptIn: "key-value " + delivery.EnrolmentsBucket + ", with per-key expiry", - Others: "the grants part reads them (a named reader) and composes each enrol. user from them", - Subjects: kvOf(delivery.EnrolmentsBucket), Writes: moduleNamed(delivery.EnrolmentModule)}, - // Which login each node uses (novox/hq ADR 0583 §1 insight): the vault alone writes it. - {State: "a node's logins", Writer: "the vault", KeptIn: "key-value " + delivery.NodeLoginsBucket, - Others: "the grants part reads every key, each node's engine its own node's key alone", - Subjects: kvOf(delivery.NodeLoginsBucket), Writes: moduleNamed(delivery.VaultModule)}, - // What a consumer gave up on (novox/hq issue 330): copied by the watchman from the server's notice (ADR 0564 - // clause 5, ADR 0590 clause 2). - {State: "a message a consumer gave up on", Writer: "the watchman", KeptIn: "the bus, the stream " + DeadLettersStream, - Others: "read through the watchman's dead-letters; delivered again by nobody until the hand-act log's home is delivered", - Subjects: []string{deadLetterPrefix + ">", againPrefix + ">"}, Writes: keepsDeadLetters}, - // What each machine has of each module (novox/hq ADR 0429 §1, design 47): written by that machine's node-engine under - // its own node's keys; the view and the watchman read it. - {State: "a machine's have", Writer: "the node-engine of the machine, under its own keys `.>`", - KeptIn: "key-value " + delivery.HaveBucket + ", twenty revisions of each key", - Others: "read through `reads` as node-engine.have", Subjects: kvOf(delivery.HaveBucket), Writes: ownNodeKeys}, - // The acts a node's engine was asked through its node seat, and its own asks (novox/hq ADR 0563 §2, §3): the - // node-engine's declared state, created by the grants part, written by each node's engine under its own keys. - // A node's own health report (issue 774): its engine's own key, read by the watchman. - {State: "a machine's health report", Writer: "the node-engine of the machine, its key `` 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 `` 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 `.>`", - 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 `.>`, compare-and-set", - KeptIn: "key-value " + EngineActsBucket + ", the node-engine's declared state acts", - Others: "read through the node seat's verb acts", Subjects: kvOf(EngineActsBucket), - Writes: func(p Principal, pattern string) bool { - return ownNodeKeys(p, pattern) || removesEngineActs(p, pattern) - }, - Shared: "the bus module's tools account removes keys of a full bucket, for removal only (novox/hq ADR 0564 Limits 4)"}, - // The object buckets (novox/hq ADR 0486 §2, ADR 0547 clause 7): the declaration files' buckets the ended controller - // wrote are written by nobody (ADR 0590); the grants part writes its own. - {State: "an object bucket", Writer: "the grants part, its own bucket alone", - KeptIn: "the object bucket " + DeclarationFilesBucket + "- of each node, an object removed a day after the " + - "node's latest declaration no longer names it", - Others: "a node-engine reads an object of its own bucket by its name and may neither list the bucket nor write it", - Subjects: []string{"$O.*.>"}, Writes: func(p Principal, pattern string) bool { - return grantsOwnObjects(p, pattern) - }, - Shared: "nobody else: the grants part writes its own object bucket " + GrantsUserListObjects + ", the user list sealed to the bus node, and no other (novox/hq ADR 0547 clause 7)"}, - // What a machine's provisioner client placed of a module's files (novox/hq ADR 0349 §4, design 57 P6): its own - // machine's word alone, which a walk's end and a gate wait for (ADR 0362, ADR 0349 §15). Kept newest per module - // in the stream ProvAppliedStream, and by the client in the provisioner server's `applied` bucket. - {State: "a module's files placed on a machine", Writer: "that machine's provisioner client", - KeptIn: "the bus, the stream " + ProvAppliedStream + ", newest per module; and key-value " + - ProvisionerAppliedBucket, - Others: "the watchman reads it; a failure is that machine's failed apply", - Subjects: []string{"prov.applied.*.>"}, Writes: ownProvisioner}, -} - -// CheckWriters refuses a grant that lets a principal publish on a subject the writers table gives -// another writer (to-be 45 §1): which state, whose, and the pattern that would make a second writer. -func CheckWriters(p Principal, publish []string) error { - var problems []string - for _, pattern := range publish { - for _, row := range WritersTable { - if row.Writes == nil { - continue - } - for _, subject := range row.Subjects { - if !SubjectsOverlap(pattern, subject) || row.Writes(p, pattern) { - continue - } - problems = append(problems, fmt.Sprintf("%s may publish %s, which writes %s (%s), whose writer is %s", - p.Username(), pattern, row.State, subject, row.Writer)) - } - } - } - if len(problems) == 0 { - return nil - } - return fmt.Errorf("a second writer would be granted (novox/hq to-be 45 §1, one writer per piece of state):\n %s", - strings.Join(problems, "\n ")) -} - -// SubjectsOverlap says some subject matches both patterns: `*` is one token, `>` one or more to the end. -func SubjectsOverlap(a, b string) bool { - x, y := strings.Split(a, "."), strings.Split(b, ".") - for i := 0; ; i++ { - switch { - case i == len(x) && i == len(y): - return true - case i == len(x) || i == len(y): - return false - case x[i] == ">" || y[i] == ">": - return true - case x[i] == "*" || y[i] == "*" || x[i] == y[i]: - continue - default: - return false - } - } -} -- 2.54.0 From b4ade2c502144436f70bc6e98dba10f906a40c9e Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 18:51:24 +0100 Subject: [PATCH 15/22] =?UTF-8?q?delivery:=20the=20fingerprint's=20computa?= =?UTF-8?q?tion,=20the=20named=20tree,=20the=20package=20versions=20and=20?= =?UTF-8?q?the=20step=20runner=20leave=20mesh-sdk=20for=20the=20indexer=20?= =?UTF-8?q?and=20the=20builder,=20which=20alone=20compute=20them=20(ADR=20?= =?UTF-8?q?0593=20=C2=A71,=20=C2=A72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/buildcpu/buildcpu.go | 215 ----- go/buildcpu/buildcpu_agree_test.go | 14 - go/buildcpu/buildcpu_test.go | 90 -- go/buildstep/askrun.go | 473 ----------- go/buildstep/askrun_test.go | 325 ------- go/buildstep/blob.go | 170 ---- go/buildstep/blob_test.go | 151 ---- go/buildstep/buildhours.go | 129 --- go/buildstep/capacity.go | 54 -- go/buildstep/capacity_cpu.go | 141 ---- go/buildstep/capacity_test.go | 135 --- go/buildstep/conformance/captured/README.md | 73 -- .../conformance/captured/go/buildfail.jsonl | 6 - .../conformance/captured/go/deeptimeout.jsonl | 417 --------- .../conformance/captured/go/exiting.jsonl | 10 - .../conformance/captured/go/killed.jsonl | 7 - .../captured/go/logthenfatal.jsonl | 13 - .../conformance/captured/go/mainexit.jsonl | 8 - .../conformance/captured/go/panicking.jsonl | 27 - .../conformance/captured/go/passfirst.jsonl | 22 - .../captured/go/passthenfail.jsonl | 15 - .../conformance/captured/go/race.jsonl | 53 -- .../conformance/captured/go/racemain.jsonl | 27 - .../conformance/captured/go/subpanic.jsonl | 30 - .../conformance/captured/go/subtests.jsonl | 17 - .../conformance/captured/go/timeout.jsonl | 43 - .../conformance/captured/node/crash.console | 2 - .../conformance/captured/node/crash.tap | 41 - .../captured/node/logcrash.console | 2 - .../conformance/captured/node/logcrash.tap | 42 - .../conformance/captured/node/suite.console | 2 - .../conformance/captured/node/suite.tap | 61 -- .../conformance/captured/node/syntax.console | 2 - .../conformance/captured/node/syntax.tap | 47 -- .../conformance/captured/node/timeout.console | 2 - .../conformance/captured/node/timeout.tap | 26 - .../captured/node/two-files.console | 2 - .../conformance/captured/node/two-files.tap | 49 -- .../captured/tsc/typeerror.console | 1 - go/buildstep/conformance/conformance.go | 201 ----- go/buildstep/conformance/conformance_test.go | 167 ---- go/buildstep/conformance/leftrunning.go | 212 ----- go/buildstep/cpu.go | 14 - go/buildstep/digestshape_test.go | 234 ----- go/buildstep/doc.go | 5 + go/buildstep/failure.go | 6 - go/buildstep/fetch.go | 196 ----- go/buildstep/fetch_integration_test.go | 59 -- go/buildstep/fetch_test.go | 137 --- go/buildstep/gotest.go | 409 --------- go/buildstep/guard.go | 52 -- go/buildstep/guard_test.go | 79 -- go/buildstep/helpers.go | 34 + go/buildstep/isolate.go | 175 ---- go/buildstep/isolate_caches_test.go | 30 - go/buildstep/isolate_integration_test.go | 66 -- go/buildstep/isolate_test.go | 115 --- go/buildstep/merge.go | 128 --- go/buildstep/merge_integration_test.go | 199 ----- go/buildstep/merge_test.go | 74 -- go/buildstep/mirror_integration_test.go | 65 -- go/buildstep/mirror_test.go | 124 --- go/buildstep/narrow.go | 71 -- go/buildstep/narrow_test.go | 82 -- go/buildstep/pack.go | 100 --- go/buildstep/pack_test.go | 84 -- go/buildstep/pack_unix_test.go | 25 - go/buildstep/phase.go | 67 -- go/buildstep/programs.go | 115 --- go/buildstep/programs_test.go | 72 -- go/buildstep/pull.go | 798 ------------------ go/buildstep/pull_bus.go | 102 --- go/buildstep/pull_capacity_test.go | 39 - go/buildstep/pull_fake.go | 113 --- go/buildstep/pull_integration_test.go | 144 ---- go/buildstep/pull_seat.go | 44 - go/buildstep/pull_seat_test.go | 40 - go/buildstep/pull_test.go | 520 ------------ go/buildstep/refusal.go | 29 - go/buildstep/seat_tools.go | 39 - go/buildstep/seat_tools_test.go | 52 -- go/buildstep/stale.go | 35 - go/buildstep/stale_test.go | 3 +- go/buildstep/step.go | 125 --- go/buildstep/tap.go | 192 ----- go/buildstep/testdata/isolated-probe/main.go | 49 -- go/buildstep/testdata/pack-golden.tar | Bin 4096 -> 0 bytes go/buildstep/trees.go | 128 +-- go/buildstep/trees_integration_test.go | 63 -- go/buildstep/trees_named_test.go | 110 --- go/buildstep/trees_test.go | 95 --- go/buildstep/watch_settings_linux.go | 112 --- go/buildstep/watch_settings_other.go | 8 - go/buildstep/workspaces.go | 160 ---- go/buildstep/workspaces_test.go | 113 --- go/cmd/mesh-pack/main.go | 111 --- go/cmd/mesh-pack/main_test.go | 52 -- go/delivery/buildrun/asks.go | 316 ------- go/delivery/buildrun/asks_integration_test.go | 61 -- go/delivery/buildrun/asks_test.go | 250 ------ go/delivery/buildrun/choose.go | 88 -- go/delivery/buildrun/choose_test.go | 99 --- go/delivery/buildrun/doc.go | 5 - go/delivery/buildrun/have.go | 56 -- go/delivery/buildrun/heardends.go | 236 ------ go/delivery/buildrun/lostask.go | 503 ----------- go/delivery/buildrun/modules.go | 7 - go/delivery/buildrun/places.go | 145 ---- go/delivery/buildrun/places_test.go | 104 --- go/delivery/buildrun/presence.go | 53 -- go/delivery/buildrun/presence_test.go | 43 - go/delivery/buildrun/standing.go | 225 ----- go/delivery/buildrun/standing_test.go | 263 ------ go/delivery/buildrun/steps.go | 67 -- go/delivery/buildrun/steps_test.go | 116 --- go/delivery/buildrun/toolchains.go | 64 -- go/delivery/buildrun/toolchains_test.go | 49 -- go/delivery/fingerprint2.go | 87 +- go/delivery/fingerprint2_test.go | 144 +--- go/delivery/forge_inputs_test.go | 67 -- go/delivery/namedtree.go | 42 - go/delivery/namedtree_layouts_test.go | 22 +- go/delivery/namedtree_test.go | 68 -- go/delivery/packages.go | 87 +- go/delivery/preview.go | 4 +- go/delivery/source.go | 36 +- 126 files changed, 69 insertions(+), 12855 deletions(-) delete mode 100644 go/buildcpu/buildcpu.go delete mode 100644 go/buildcpu/buildcpu_agree_test.go delete mode 100644 go/buildcpu/buildcpu_test.go delete mode 100644 go/buildstep/askrun.go delete mode 100644 go/buildstep/askrun_test.go delete mode 100644 go/buildstep/blob.go delete mode 100644 go/buildstep/blob_test.go delete mode 100644 go/buildstep/buildhours.go delete mode 100644 go/buildstep/capacity_cpu.go delete mode 100644 go/buildstep/capacity_test.go delete mode 100644 go/buildstep/conformance/captured/README.md delete mode 100644 go/buildstep/conformance/captured/go/buildfail.jsonl delete mode 100644 go/buildstep/conformance/captured/go/deeptimeout.jsonl delete mode 100644 go/buildstep/conformance/captured/go/exiting.jsonl delete mode 100644 go/buildstep/conformance/captured/go/killed.jsonl delete mode 100644 go/buildstep/conformance/captured/go/logthenfatal.jsonl delete mode 100644 go/buildstep/conformance/captured/go/mainexit.jsonl delete mode 100644 go/buildstep/conformance/captured/go/panicking.jsonl delete mode 100644 go/buildstep/conformance/captured/go/passfirst.jsonl delete mode 100644 go/buildstep/conformance/captured/go/passthenfail.jsonl delete mode 100644 go/buildstep/conformance/captured/go/race.jsonl delete mode 100644 go/buildstep/conformance/captured/go/racemain.jsonl delete mode 100644 go/buildstep/conformance/captured/go/subpanic.jsonl delete mode 100644 go/buildstep/conformance/captured/go/subtests.jsonl delete mode 100644 go/buildstep/conformance/captured/go/timeout.jsonl delete mode 100644 go/buildstep/conformance/captured/node/crash.console delete mode 100644 go/buildstep/conformance/captured/node/crash.tap delete mode 100644 go/buildstep/conformance/captured/node/logcrash.console delete mode 100644 go/buildstep/conformance/captured/node/logcrash.tap delete mode 100644 go/buildstep/conformance/captured/node/suite.console delete mode 100644 go/buildstep/conformance/captured/node/suite.tap delete mode 100644 go/buildstep/conformance/captured/node/syntax.console delete mode 100644 go/buildstep/conformance/captured/node/syntax.tap delete mode 100644 go/buildstep/conformance/captured/node/timeout.console delete mode 100644 go/buildstep/conformance/captured/node/timeout.tap delete mode 100644 go/buildstep/conformance/captured/node/two-files.console delete mode 100644 go/buildstep/conformance/captured/node/two-files.tap delete mode 100644 go/buildstep/conformance/captured/tsc/typeerror.console delete mode 100644 go/buildstep/conformance/conformance.go delete mode 100644 go/buildstep/conformance/conformance_test.go delete mode 100644 go/buildstep/conformance/leftrunning.go delete mode 100644 go/buildstep/cpu.go delete mode 100644 go/buildstep/digestshape_test.go create mode 100644 go/buildstep/doc.go delete mode 100644 go/buildstep/fetch.go delete mode 100644 go/buildstep/fetch_integration_test.go delete mode 100644 go/buildstep/fetch_test.go delete mode 100644 go/buildstep/gotest.go delete mode 100644 go/buildstep/guard.go delete mode 100644 go/buildstep/guard_test.go create mode 100644 go/buildstep/helpers.go delete mode 100644 go/buildstep/isolate.go delete mode 100644 go/buildstep/isolate_caches_test.go delete mode 100644 go/buildstep/isolate_integration_test.go delete mode 100644 go/buildstep/isolate_test.go delete mode 100644 go/buildstep/merge.go delete mode 100644 go/buildstep/merge_integration_test.go delete mode 100644 go/buildstep/merge_test.go delete mode 100644 go/buildstep/mirror_integration_test.go delete mode 100644 go/buildstep/mirror_test.go delete mode 100644 go/buildstep/narrow.go delete mode 100644 go/buildstep/narrow_test.go delete mode 100644 go/buildstep/pack.go delete mode 100644 go/buildstep/pack_test.go delete mode 100644 go/buildstep/pack_unix_test.go delete mode 100644 go/buildstep/phase.go delete mode 100644 go/buildstep/programs.go delete mode 100644 go/buildstep/programs_test.go delete mode 100644 go/buildstep/pull.go delete mode 100644 go/buildstep/pull_bus.go delete mode 100644 go/buildstep/pull_capacity_test.go delete mode 100644 go/buildstep/pull_fake.go delete mode 100644 go/buildstep/pull_integration_test.go delete mode 100644 go/buildstep/pull_seat.go delete mode 100644 go/buildstep/pull_seat_test.go delete mode 100644 go/buildstep/pull_test.go delete mode 100644 go/buildstep/refusal.go delete mode 100644 go/buildstep/seat_tools.go delete mode 100644 go/buildstep/seat_tools_test.go delete mode 100644 go/buildstep/tap.go delete mode 100644 go/buildstep/testdata/isolated-probe/main.go delete mode 100644 go/buildstep/testdata/pack-golden.tar delete mode 100644 go/buildstep/trees_integration_test.go delete mode 100644 go/buildstep/trees_named_test.go delete mode 100644 go/buildstep/trees_test.go delete mode 100644 go/buildstep/watch_settings_linux.go delete mode 100644 go/buildstep/watch_settings_other.go delete mode 100644 go/buildstep/workspaces.go delete mode 100644 go/buildstep/workspaces_test.go delete mode 100644 go/cmd/mesh-pack/main.go delete mode 100644 go/cmd/mesh-pack/main_test.go delete mode 100644 go/delivery/buildrun/asks.go delete mode 100644 go/delivery/buildrun/asks_integration_test.go delete mode 100644 go/delivery/buildrun/asks_test.go delete mode 100644 go/delivery/buildrun/choose.go delete mode 100644 go/delivery/buildrun/choose_test.go delete mode 100644 go/delivery/buildrun/doc.go delete mode 100644 go/delivery/buildrun/have.go delete mode 100644 go/delivery/buildrun/heardends.go delete mode 100644 go/delivery/buildrun/lostask.go delete mode 100644 go/delivery/buildrun/modules.go delete mode 100644 go/delivery/buildrun/places.go delete mode 100644 go/delivery/buildrun/places_test.go delete mode 100644 go/delivery/buildrun/presence.go delete mode 100644 go/delivery/buildrun/presence_test.go delete mode 100644 go/delivery/buildrun/standing.go delete mode 100644 go/delivery/buildrun/standing_test.go delete mode 100644 go/delivery/buildrun/steps.go delete mode 100644 go/delivery/buildrun/steps_test.go delete mode 100644 go/delivery/buildrun/toolchains.go delete mode 100644 go/delivery/buildrun/toolchains_test.go delete mode 100644 go/delivery/namedtree_test.go diff --git a/go/buildcpu/buildcpu.go b/go/buildcpu/buildcpu.go deleted file mode 100644 index dcd2f36..0000000 --- a/go/buildcpu/buildcpu.go +++ /dev/null @@ -1,215 +0,0 @@ -// Package buildcpu is how much of a build node's processors its builds may take, and how they yield to everything else -// there (novox/hq ADR 0553, issue 1806; the operator, 2026-10-26: "limit CPU usage on our build nodes so the total cpu -// usage never impacts the normal workings of our nodes ... We have 3 build agents so they should share the cpu power"). -// -// Three parts, each with one owner: -// -// - **Priority.** A build seat's holder (catalogue.Seat.Yields) runs at the lowest priority there is: the composer -// gives its runtime process declaration.Yields, and the node-engine places every yielding process of the node in -// one slice (declaration.YieldSlice) whose CPU weight is idle, with Nice=19 for every process it starts. -// Interactive and service work always comes first; builds take the cycles nothing else wants. -// - **One cap per node, shared.** The node's share of its processors is one value, the node-engine's own config -// `build-cpu-share` (ShareSetting), set per node through the node's settings layer. The node-engine writes it as the -// slice's CPUQuota: one bound the kernel holds for all the node's build modules together, never one per module. -// - **Threads.** The quota bounds the total; threads only size each step. A step gets the share divided by its own -// module's capacity (Threads), from the share the composer hands as a word (ShareWord): GOMAXPROCS sets every go -// command's -p, the compiler's concurrency, a test binary's processors and -parallel. A module given no word sets -// no thread limit at all: without the slice there is nothing to size a step to, and a limit then would only slow -// builds. -package buildcpu - -import ( - "os" - "runtime" - "strconv" - "strings" -) - -// ShareSetting is the node-engine's config value of the node's processor share for its builds, in percent of the -// machine's processors, 1 to 100: one value per node, set through the node's settings layer (novox/hq ADR 0553 §3). -const ShareSetting = "build-cpu-share" - -// DefaultPercent is the share where nothing sets one: half the machine, so a desktop keeps half its processors for -// itself whatever builds. -const DefaultPercent = 50 - -// ShareWord is the word the composer gives every yielding module (novox/hq ADR 0553 §4): the node's share, in percent. -const ShareWord = "MESH_BUILD_CPU_SHARE" - -// Quota is a CPUQuota for a share of a machine with cores processors: systemd counts a quota in percent of one -// processor, so half of 24 is 1200%. At least 100%, one processor, so a share on a small machine never stops a step. -func Quota(percent, cores int) int { - q := percent * cores - if q < 100 { - q = 100 - } - return q -} - -// Threads is how many threads one step may run at once: its part of the share when its own module runs slots steps at -// once, at least one. The slice's quota bounds the steps of all the node's build modules together; this only sizes one. -func Threads(percent, cores, slots int) int { - if slots < 1 { - slots = 1 - } - if percent < 1 || percent > 100 { - percent = DefaultPercent - } - t := cores * percent / 100 / slots - if t < 1 { - t = 1 - } - return t -} - -// Limits is what one step is held to on this node, as a status tool says it. -type Limits struct { - // Percent is the node's share in percent of its processors, and From where it was read: ShareWord, or "none" when - // the composer gave no share and a step runs without a thread limit. - Percent int `json:"share-percent,omitempty"` - From string `json:"share-from"` - // Cores is the machine's processor count. - Cores int `json:"cores"` - // Slots is this module's own capacity on the node: steps at once. - Slots int `json:"slots"` - // Threads is what each step is given (Threads); 0 is no limit. - Threads int `json:"threads-per-step"` -} - -// FromWords is the limits of a step from the share the composer gave this process (ShareWord) and the module's own -// capacity. Without a usable share there is no limit: Threads is 0. -func FromWords(getenv func(string) string, own int) Limits { - if own < 1 { - own = 1 - } - l := Limits{From: "none", Cores: runtime.NumCPU(), Slots: own} - if n, err := strconv.Atoi(strings.TrimSpace(getenv(ShareWord))); err == nil && n >= 1 && n <= 100 { - l.Percent, l.From = n, ShareWord - l.Threads = Threads(n, l.Cores, own) - } - return l -} - -// Env is the environment a step's commands are given so they start no more threads than they may: GOMAXPROCS for every -// Go program (the compiler, a test binary, esbuild, buildah), UV_THREADPOOL_SIZE for Node's worker pool. Returned as -// KEY=value words to append to a command's environment; nothing for 0 threads, no limit. -func Env(threads int) []string { - if threads < 1 { - return nil - } - t := strconv.Itoa(threads) - return []string{"GOMAXPROCS=" + t, "UV_THREADPOOL_SIZE=" + t} -} - -// InForce is what the kernel holds this process to, read rather than assumed (evidence over assertion): its nice -// level, its cgroup (the unit) and the cgroup's parent (the slice the node-engine placed it in), each one's CPU weight, -// idle mark and quota. A field the machine does not answer is left empty and Unread says which. -// -// The IO weight is read but binds only where the disk's scheduler honours it (bfq, or the io.cost controller); under -// the scheduler `none`, the usual one for NVMe, it does nothing, and the CPU bound is what holds. -type InForce struct { - Nice int `json:"nice"` - Cgroup string `json:"cgroup,omitempty"` - Unit Group `json:"unit"` - Slice Group `json:"slice"` - // Quota is the tighter of the two groups' cpu.max as a percent of one processor, 0 with none. - Quota int `json:"quota-percent,omitempty"` - Unread []string `json:"unread,omitempty"` -} - -// Group is one cgroup's controls. -type Group struct { - Path string `json:"path,omitempty"` - CPUIdle string `json:"cpu-idle,omitempty"` - CPUWeight string `json:"cpu-weight,omitempty"` - // CPUMax is the cgroup's cpu.max: " ", or "max" with no quota. - CPUMax string `json:"cpu-max,omitempty"` - IOWeight string `json:"io-weight,omitempty"` -} - -// cgroupRoot is where the unified hierarchy is mounted. A variable so a test can point it elsewhere. -var cgroupRoot = "/sys/fs/cgroup" - -// procSelfCgroup is this process's cgroup file. A variable so a test can point it elsewhere. -var procSelfCgroup = "/proc/self/cgroup" - -// procSelfStat is this process's stat file. A variable so a test can point it elsewhere. -var procSelfStat = "/proc/self/stat" - -// ReadInForce reads what the kernel holds this process to. -func ReadInForce() InForce { - var f InForce - f.Nice = nice() - raw, err := os.ReadFile(procSelfCgroup) - if err != nil { - f.Unread = append(f.Unread, "cgroup: "+err.Error()) - return f - } - for _, line := range strings.Split(strings.TrimSpace(string(raw)), "\n") { - if strings.HasPrefix(line, "0::") { - f.Cgroup = strings.TrimPrefix(line, "0::") - } - } - if f.Cgroup == "" { - f.Unread = append(f.Unread, "cgroup: not on the unified hierarchy") - return f - } - read := func(path string) Group { - g := Group{Path: path} - value := func(name string) string { - b, err := os.ReadFile(cgroupRoot + path + "/" + name) - if err != nil { - f.Unread = append(f.Unread, path+"/"+name+": "+err.Error()) - return "" - } - return strings.TrimSpace(string(b)) - } - g.CPUIdle, g.CPUWeight, g.CPUMax, g.IOWeight = value("cpu.idle"), value("cpu.weight"), value("cpu.max"), value("io.weight") - return g - } - f.Unit = read(f.Cgroup) - if i := strings.LastIndex(f.Cgroup, "/"); i > 0 { - f.Slice = read(f.Cgroup[:i]) - } - for _, q := range []int{quotaOf(f.Unit.CPUMax), quotaOf(f.Slice.CPUMax)} { - if q > 0 && (f.Quota == 0 || q < f.Quota) { - f.Quota = q - } - } - return f -} - -// quotaOf is a cpu.max value as a percent of one processor: "1200000 100000" is 1200; "max ..." or nothing is 0. -func quotaOf(cpuMax string) int { - fields := strings.Fields(cpuMax) - if len(fields) != 2 || fields[0] == "max" { - return 0 - } - q, err1 := strconv.Atoi(fields[0]) - p, err2 := strconv.Atoi(fields[1]) - if err1 != nil || err2 != nil || p <= 0 { - return 0 - } - return q * 100 / p -} - -// nice is this process's nice level from its stat file (the 19th field, counted after the command's closing -// parenthesis, which may itself hold spaces); 0 when it cannot be read. -func nice() int { - raw, err := os.ReadFile(procSelfStat) - if err != nil { - return 0 - } - s := string(raw) - i := strings.LastIndex(s, ")") - if i < 0 { - return 0 - } - fields := strings.Fields(s[i+1:]) - // After the command: state is field 3, so nice (field 19) is index 16. - if len(fields) < 17 { - return 0 - } - n, _ := strconv.Atoi(fields[16]) - return n -} diff --git a/go/buildcpu/buildcpu_agree_test.go b/go/buildcpu/buildcpu_agree_test.go deleted file mode 100644 index bf61988..0000000 --- a/go/buildcpu/buildcpu_agree_test.go +++ /dev/null @@ -1,14 +0,0 @@ -package buildcpu - -import ( - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// The share's names the composer gives are the build modules' (catalogue writes them too, so it does not import this). -func TestTheShareNamesAgreeWithTheCatalogues(t *testing.T) { - if ShareSetting != catalogue.BuildCPUShareSetting || ShareWord != catalogue.BuildCPUShareWord || DefaultPercent != catalogue.BuildCPUDefaultPercent { - t.Fatal("buildcpu's share names differ from catalogue's") - } -} diff --git a/go/buildcpu/buildcpu_test.go b/go/buildcpu/buildcpu_test.go deleted file mode 100644 index 6740687..0000000 --- a/go/buildcpu/buildcpu_test.go +++ /dev/null @@ -1,90 +0,0 @@ -package buildcpu - -import ( - "os" - "path/filepath" - "reflect" - "runtime" - "testing" -) - -// The node's share as a quota, the threads one step is given for its own module's capacity, and the word the composer -// hands, in one table (ADR 0553, issue 1806). -func TestShareQuotaAndThreads(t *testing.T) { - for _, c := range []struct { - name string - percent, cores, slots int - quota, threads int - }{ - {"shanks: half of 24, three slots", 50, 24, 3, 1200, 4}, - {"ace: three quarters of 24, two slots", 75, 24, 2, 1800, 9}, - {"g14: half of 16, one slot", 50, 16, 1, 800, 8}, - {"one processor at least", 10, 2, 4, 100, 1}, - {"no slots counts as one", 100, 4, 0, 400, 4}, - {"a share out of range is the default", 0, 8, 1, 0, 4}, - } { - if c.quota != 0 { - if q := Quota(c.percent, c.cores); q != c.quota { - t.Errorf("%s: Quota = %d, want %d", c.name, q, c.quota) - } - } - if th := Threads(c.percent, c.cores, c.slots); th != c.threads { - t.Errorf("%s: Threads = %d, want %d", c.name, th, c.threads) - } - } - l := FromWords(func(k string) string { return map[string]string{ShareWord: "75"}[k] }, 2) - if l.Percent != 75 || l.From != ShareWord || l.Slots != 2 || l.Threads != Threads(75, runtime.NumCPU(), 2) { - t.Errorf("from the word: %+v", l) - } - // Told nothing usable, a step has no thread limit: nothing in its environment. - for _, word := range []string{"", "250", "lots"} { - l := FromWords(func(string) string { return word }, 3) - if l.Threads != 0 || l.From != "none" || Env(l.Threads) != nil { - t.Errorf("share %q: %+v, env %v", word, l, Env(l.Threads)) - } - } - if got := Env(3); !reflect.DeepEqual(got, []string{"GOMAXPROCS=3", "UV_THREADPOOL_SIZE=3"}) { - t.Errorf("Env = %q", got) - } -} - -// What the kernel holds a process to is read from its unit's and its slice's cgroup and its stat file, not assumed. -func TestReadInForce(t *testing.T) { - dir := t.TempDir() - cg := filepath.Join(dir, "cg") - slice := filepath.Join(cg, "meshyield.slice") - unit := filepath.Join(slice, "mesh-build-go-runtime.service") - if err := os.MkdirAll(unit, 0o755); err != nil { - t.Fatal(err) - } - for at, files := range map[string]map[string]string{ - slice: {"cpu.idle": "1\n", "cpu.weight": "0\n", "cpu.max": "1200000 100000\n", "io.weight": "default 1\n"}, - unit: {"cpu.idle": "0\n", "cpu.weight": "100\n", "cpu.max": "max 100000\n", "io.weight": "default 100\n"}, - } { - for name, v := range files { - if err := os.WriteFile(filepath.Join(at, name), []byte(v), 0o644); err != nil { - t.Fatal(err) - } - } - } - self := filepath.Join(dir, "cgroup") - stat := filepath.Join(dir, "stat") - _ = os.WriteFile(self, []byte("0::/meshyield.slice/mesh-build-go-runtime.service\n"), 0o644) - // A command with a space and a parenthesis in it, then nice 19 in field 19. - _ = os.WriteFile(stat, []byte("42 (mesh (build) go) S 1 42 42 0 -1 4194560 1 0 0 0 0 0 0 0 39 19 8 0 1 2 3\n"), 0o644) - cgroupRoot, procSelfCgroup, procSelfStat = cg, self, stat - t.Cleanup(func() { - cgroupRoot, procSelfCgroup, procSelfStat = "/sys/fs/cgroup", "/proc/self/cgroup", "/proc/self/stat" - }) - - got := ReadInForce() - want := InForce{Nice: 19, Cgroup: "/meshyield.slice/mesh-build-go-runtime.service", - Unit: Group{Path: "/meshyield.slice/mesh-build-go-runtime.service", CPUIdle: "0", CPUWeight: "100", - CPUMax: "max 100000", IOWeight: "default 100"}, - Slice: Group{Path: "/meshyield.slice", CPUIdle: "1", CPUWeight: "0", CPUMax: "1200000 100000", - IOWeight: "default 1"}, - Quota: 1200} - if !reflect.DeepEqual(got, want) { - t.Fatalf("ReadInForce = %+v\nwant %+v", got, want) - } -} diff --git a/go/buildstep/askrun.go b/go/buildstep/askrun.go deleted file mode 100644 index 2a0836b..0000000 --- a/go/buildstep/askrun.go +++ /dev/null @@ -1,473 +0,0 @@ -package buildstep - -import ( - "context" - "encoding/json" - "fmt" - "maps" - "path" - "slices" - "sort" - "strings" - - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -// **One ask, one whole job** (novox/hq ADR 0525 §3). A build module runs a pulled ask with its own step tools' bodies, -// the same ones its request and reply tools run (ADR 0525 §8), in the order the job names: a build ask fetches, then -// builds or copies each artifact; a check ask fetches, digests the level, takes the kept verdicts and tests the rest, -// the packages whose tests the change touches first. The answers are the tools' own, whole, so the asker judges them as -// it judged the tools' answers. - -// StepFunc is one step tool's body: its arguments in, its answer out. -type StepFunc func(ctx context.Context, args map[string]any) (any, error) - -// AskSteps are the step tools a build module runs an ask with. Copy is the image module's alone; Digest the Go module's. -type AskSteps struct { - Fetch, Build, Copy, Digest, Test StepFunc - // Toolchain is the toolchain the module builds with now; a build ask naming another is declined (Declining). Nil checks nothing - // (an image or an archive names none). - Toolchain func(ctx context.Context) (delivery.ToolchainUsed, error) - // Kept looks up a kept verdict by level and digest (the checker's kept bucket, delivery.KeptKey): whether one is - // kept and how many tests it counted. Nil takes none. - Kept func(ctx context.Context, level, digest string) (tests int, found bool, err error) - // Release removes a workspace once the ask's steps on it ended (the module's release tool's body); nil leaves it to - // the module's sweep. - Release func(workspace string) error - Logf func(format string, args ...any) - // Has says whether this node has a need a package's tests declare (delivery.AskNeeds, issue 890); a package needing - // what it lacks is not tested here but named in the level's Unmet, for the asker to place. Nil has nothing. - Has func(need string) bool -} - -// 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 { - job := *ask.Build - job.Fetch = withPackages(job.Fetch, ask.Packages) - return steps.build(ctx, job) - } - if ask.Check != nil { - 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 - } - return json.Marshal(v) -} - -// fetched is what an ask reads of a fetch's answer. -type fetched struct { - Workspace string `json:"workspace"` - Merge string `json:"merge"` - Conflicts []string `json:"conflicts"` - Changed []string `json:"changed"` - Modules []string `json:"modules"` -} - -func (s AskSteps) fetch(ctx context.Context, args map[string]any) (json.RawMessage, fetched, error) { - if s.Fetch == nil { - return nil, fetched{}, Refuse("this build module fetches nothing") - } - if _, merges := args["base"]; merges { - Phase(ctx, "fetch and merge") - } else { - Phase(ctx, "fetch") - } - out, err := s.Fetch(ctx, args) - if err != nil { - return nil, fetched{}, err - } - r, err := raw(out) - if err != nil { - return nil, fetched{}, err - } - var f fetched - if err := json.Unmarshal(r, &f); err != nil || f.Workspace == "" { - return r, f, fmt.Errorf("the fetch answered no workspace: %s", r) - } - return r, f, nil -} - -// onToolchain is the toolchain this node builds with when the ask names one and this module reports its own, nil when -// either is absent. A node on another toolchain than the ask names answers a Declining: it is not on the agreed one and -// sits out, for a node that is to take the ask (novox/hq ADR 0584 clause 2). -func (s AskSteps) onToolchain(ctx context.Context, want *delivery.ToolchainUsed) (*delivery.ToolchainUsed, error) { - if want == nil || s.Toolchain == nil { - return nil, nil - } - tc, err := s.Toolchain(ctx) - if err != nil { - return nil, err - } - if tc.Version != want.Version || tc.Recipe != want.Recipe { - return nil, &Declining{Lacks: []string{fmt.Sprintf("the agreed toolchain %s (recipe %s)", want.Version, want.Recipe)}, - Text: fmt.Sprintf("the ask is for %s (recipe %s) and this node builds with %s (recipe %s): it sits out, and the ask is left to a node on the agreed toolchain", - want.Version, want.Recipe, tc.Version, tc.Recipe)} - } - return &tc, nil -} - -func (s AskSteps) build(ctx context.Context, job delivery.BuildJob) (any, error) { - answer := delivery.BuildAnswer{} - tc, err := s.onToolchain(ctx, job.Toolchain) - if err != nil { - return nil, err - } - answer.Toolchain = tc - var f fetched - defer func() { s.release(f.Workspace) }() - for _, a := range job.Artifacts { - if a.From == "" && f.Workspace == "" { - r, got, err := s.fetch(ctx, job.Fetch) - if err != nil { - return nil, err - } - if _, candidate := job.Fetch["base"]; candidate { - // A candidate is built from its head merged onto the trunk; a head that does not merge leaves the tree at the - // trunk, and building that would store the trunk's build under the candidate's fingerprint. - if len(got.Conflicts) > 0 { - return nil, fmt.Errorf("the head does not merge into the trunk: files in conflict: %s", strings.Join(got.Conflicts, ", ")) - } - if got.Merge == "" { - return nil, fmt.Errorf("the fetch with a base answered no merge: %s", r) - } - } - f = got - } - var ( - out any - err error - ) - if a.From != "" { - if s.Copy == nil { - return nil, Refuse("this build module copies no artifact (%s)", a.Artifact) - } - Phase(ctx, "copy "+a.Artifact) - out, err = s.Copy(ctx, map[string]any{"module": job.Module, "artifact": a.Artifact, "from": a.From}) - } else { - if s.Build == nil { - return nil, Refuse("this build module builds no artifact (%s)", a.Artifact) - } - args := map[string]any{"workspace": f.Workspace, "module": job.Path, "artifact": a.Artifact} - if job.Publish { - args["publish"] = true - } - Phase(ctx, "build "+a.Artifact) - out, err = s.Build(ctx, args) - } - if err != nil { - return nil, fmt.Errorf("%s: %w", a.Artifact, err) - } - r, err := raw(out) - if err != nil { - return nil, err - } - var built struct { - Digest string `json:"digest"` - Reference string `json:"reference"` - } - if err := json.Unmarshal(r, &built); err != nil || built.Digest == "" { - return nil, fmt.Errorf("the build of %s answered no digest: %s", a.Artifact, r) - } - answer.Artifacts = append(answer.Artifacts, delivery.AskBuilt{Artifact: a.Artifact, Digest: built.Digest, Reference: built.Reference}) - } - return answer, nil -} - -func (s AskSteps) check(ctx context.Context, job delivery.CheckJob) (any, error) { - if _, err := s.onToolchain(ctx, job.Toolchain); err != nil { - return nil, err - } - r, f, err := s.fetch(ctx, job.Fetch) - if err != nil { - return nil, err - } - defer s.release(f.Workspace) - answer := delivery.CheckAnswer{Fetch: r} - if len(f.Conflicts) > 0 { - return answer, nil // the asker reads the conflict from the fetch's answer: a verdict, not an error - } - if s.Test == nil { - return nil, Refuse("this build module tests nothing") - } - runner := "" - if job.Runner != "" && (job.RunnerWhen == "" || changesUnder(f.Changed, job.RunnerWhen)) { - runner = job.Runner - } - // Every level runs, also after one that did not pass: one check names every failure, not only the first (the operator, - // 2026-11-02: "A failed check shows only its first fault"). - for _, level := range job.Levels { - la := delivery.LevelAnswer{Level: level} - _, err := s.level(ctx, job, level, f, runner, &la) - answer.Levels = append(answer.Levels, la) - if err != nil { - return nil, err - } - } - return answer, nil -} - -// level runs one level of a check ask and says whether it passed. -func (s AskSteps) level(ctx context.Context, job delivery.CheckJob, level string, f fetched, runner string, la *delivery.LevelAnswer) (bool, error) { - withRunner := func(args map[string]any) map[string]any { - if runner != "" { - args["runner"] = runner - } - if len(job.NodeNames) > 0 { - args[delivery.NodeNamesArg] = job.NodeNames // the names check's (ADR 0567 §12) - } - return args - } - test := func(args map[string]any) (bool, error) { - what := "the whole level" - if dir, _ := args["module"].(string); dir != "" { - what = dir - } else if pkgs, _ := args["packages"].([]string); len(pkgs) > 0 { - what = fmt.Sprintf("%d packages", len(pkgs)) - } - Phase(ctx, fmt.Sprintf("%s: tests of %s", level, what)) - out, err := s.Test(ctx, args) - if err != nil { - return false, err - } - r, err := raw(out) - if err != nil { - return false, err - } - la.Tests = append(la.Tests, r) - var t struct { - Passed bool `json:"passed"` - } - _ = json.Unmarshal(r, &t) - return t.Passed, nil - } - // The packages (or Node directories) whose tests the change touches run first (issue 1289; ADR 0444), and then every - // other test step of the level, also after one that did not pass, so one check names every failing test of the level - // and not only the first (issue 1921; the operator, 2026-11-02); the level passes only when each of its steps did. - all := true - if s.Digest == nil { - // A module with no digest (Node) tests each module directory of the tree, those whose tests changed first. - for _, priority := range []bool{true, false} { - for _, dir := range f.Modules { - if changesTestIn(f.Changed, dir) != priority { - continue - } - passed, err := test(withRunner(map[string]any{"workspace": f.Workspace, "module": dir, "level": level})) - if err != nil { - return false, fmt.Errorf("the %s level of %s: %w", level, dir, err) - } - all = all && passed - } - } - return all, nil - } - todo, whole, keepRunner := s.plan(ctx, job, level, f, runner, withRunner, la) - if !keepRunner { - runner = "" // an installed module that ignores the runner judges the level as it judges any other (ADR 0535 §2) - } - if whole && len(job.Packages) > 0 { - todo, whole = job.Packages, false // a placed ask tests its packages alone, whatever the digest said (ADR 0552 §4) - } - for _, priority := range []bool{true, false} { - args := withRunner(map[string]any{"workspace": f.Workspace, "level": level}) - if job.Run != "" { - args["run"] = job.Run - } - if whole { - if priority { - continue // nothing to put first: the level runs whole after - } - } else { - var run []string - for _, p := range todo { - if changesTestOf(f.Changed, p) == priority { - run = append(run, p) - } - } - if len(run) == 0 { - continue - } - args["packages"] = run - } - passed, err := test(args) - if err != nil { - return false, fmt.Errorf("the %s level: %w", level, err) - } - all = all && passed // the others still run after a failure (issue 1921) - } - return all, nil -} - -// release removes an ask's workspace, saying a failure: the module's sweep removes it later. -func (s AskSteps) release(workspace string) { - if workspace == "" || s.Release == nil { - return - } - if err := s.Release(workspace); err != nil && s.Logf != nil { - s.Logf("the workspace %s is left to the sweep: %v", workspace, err) - } -} - -// plan digests the level and takes the kept verdicts: the packages left to test, or all when the level runs whole, and -// whether the runner stays. A try of some tests runs whole, digesting nothing (a partial run proves no package). Under the -// candidate's runner the digest is asked as the candidate: when the candidate answers it, every package runs and nothing -// is digested for keeping (ADR 0535 §4); when the installed module answers it (it does not take the runner), the level is -// planned as any other and the runner is dropped. The level runs whole and takes nothing when the digest names no package -// or does not cover the siblings the fetch placed (ADR 0444, L5). -func (s AskSteps) plan(ctx context.Context, job delivery.CheckJob, level string, f fetched, runner string, withRunner func(map[string]any) map[string]any, la *delivery.LevelAnswer) ([]string, bool, bool) { - if job.Run != "" { - return nil, true, true - } - Phase(ctx, level+": digest") - out, err := s.Digest(ctx, withRunner(map[string]any{"workspace": f.Workspace, "level": level})) - if err != nil { - if s.Logf != nil { - s.Logf("no digest of the %s level, so it runs whole: %v", level, err) - } - return nil, true, true - } - r, err := raw(out) - if err != nil { - return nil, true, true - } - var d delivery.LevelDigest // packages kept as a sorted list, never keyed by a package path (issue 1930) - if err := json.Unmarshal(r, &d); err != nil { - return nil, true, true - } - if runner != "" { - if strings.HasPrefix(d.JudgedBy, "candidate ") { - la.Judged = d.JudgedBy - return nil, true, true // the candidate decides which verdicts would be taken: all run, nothing kept - } - runner = "" - } - if len(d.Packages) == 0 || !slices.Equal(d.Beside, besideOf(job.Fetch)) { - return nil, true, runner != "" // a digest that names nothing or not the siblings keys no verdict: the level runs whole - } - la.Digest = &d - only := map[string]bool{} - for _, p := range job.Packages { - only[p] = true - } - var todo []string - for _, pd := range d.Packages { - p := pd.Package - if len(only) > 0 && !only[p] { - continue // a placed ask's other packages were judged by the first ask (ADR 0552 §4) - } - if job.Kept && s.Kept != nil { - tests, found, err := s.Kept(ctx, level, pd.Digest) - if err != nil && s.Logf != nil { - s.Logf("the kept verdict of %s at %s cannot be read, so it runs: %v", p, level, err) - } - if err == nil && found { - la.Kept = append(la.Kept, delivery.KeptTaken{Package: p, Digest: pd.Digest, Tests: tests}) - continue - } - } - if needs := d.NeedsOf(p); len(delivery.Lacks(needs, s.Has)) > 0 { - // Its tests would only skip here (issue 890): it is left for a node that has what they need. - la.Unmet = append(la.Unmet, delivery.PackageNeeds{Package: p, Needs: needs}) - if s.Logf != nil { - s.Logf("the %s level of %s is left to a node with %s: this node lacks %s", level, p, - strings.Join(needs, ", "), strings.Join(delivery.Lacks(needs, s.Has), ", ")) - } - continue - } - todo = append(todo, p) - } - return todo, false, false -} - -// besideOf are the siblings a fetch's arguments place, as a digest echoes them: ` `, sorted. -func besideOf(fetch map[string]any) []string { - raw, err := json.Marshal(fetch["beside"]) - if err != nil { - return nil - } - var refs []struct { - Repository string `json:"repository"` - Commit string `json:"commit"` - } - _ = json.Unmarshal(raw, &refs) - var out []string - for _, r := range refs { - out = append(out, r.Repository+" "+r.Commit) - } - sort.Strings(out) - return out -} - -// changesUnder says whether a path the merge changes is under prefix. -func changesUnder(changed []string, prefix string) bool { - for _, p := range changed { - if strings.HasPrefix(p, prefix) { - return true - } - } - return false -} - -// isTestFile says whether a repository-relative path is a test file: Go's _test.go, and a TypeScript module's .test. -// and .spec. files and test directories. -func isTestFile(p string) bool { - switch { - case strings.HasSuffix(p, "_test.go"): - return true - case strings.Contains(p, ".test.") || strings.Contains(p, ".spec."): - return true - } - for _, seg := range strings.Split(path.Dir(p), "/") { - if seg == "__tests__" || seg == "test" || seg == "tests" { - return true - } - } - return false -} - -// changesTestOf says whether the merge changes a test file of the Go package with the import path pkg: the file's -// directory and the import path agree on their last segments. -func changesTestOf(changed []string, pkg string) bool { - for _, f := range changed { - if !strings.HasSuffix(f, "_test.go") { - continue - } - dir := path.Dir(f) - if dir == pkg || strings.HasSuffix(pkg, "/"+dir) || strings.HasSuffix(dir, "/"+pkg) { - return true - } - } - return false -} - -// changesTestIn says whether the merge changes a test file inside the directory of a Node module. -func changesTestIn(changed []string, dir string) bool { - for _, f := range changed { - if !isTestFile(f) || strings.HasSuffix(f, "_test.go") { - continue - } - if dir == "." || dir == "" || strings.HasPrefix(f, strings.TrimSuffix(dir, "/")+"/") { - return true - } - } - return false -} diff --git a/go/buildstep/askrun_test.go b/go/buildstep/askrun_test.go deleted file mode 100644 index 5616b18..0000000 --- a/go/buildstep/askrun_test.go +++ /dev/null @@ -1,325 +0,0 @@ -package buildstep - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "slices" - "strings" - "testing" - "time" - - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -const runCommit = "0123456789abcdef0123456789abcdef01234567" - -// recorder is a build module's step tools that record each call. -type recorder struct { - calls []string - fetch map[string]any - tests map[string]bool // package or module directory -> passed - digests map[string]string - needs []delivery.PackageNeeds -} - -func (r *recorder) steps() AskSteps { - call := func(name string, answer func(args map[string]any) any) StepFunc { - return func(ctx context.Context, args map[string]any) (any, error) { - b, _ := json.Marshal(args) - r.calls = append(r.calls, name+" "+string(b)) - return answer(args), nil - } - } - return AskSteps{ - Fetch: call("fetch", func(map[string]any) any { return r.fetch }), - Build: call("build", func(args map[string]any) any { - return map[string]any{"digest": "sha256:" + args["artifact"].(string), "reference": "artifact-store://" + args["artifact"].(string)} - }), - Copy: call("copy", func(args map[string]any) any { return map[string]any{"digest": "sha256:copied"} }), - Digest: call("digest", func(map[string]any) any { - return map[string]any{"level": "unit", "packages": r.digests, "needs": r.needs} - }), - Test: call("test", func(args map[string]any) any { - passed := true - if pkgs, ok := args["packages"].([]string); ok { - for _, p := range pkgs { - passed = passed && r.tests[p] - } - } - if dir, ok := args["module"].(string); ok { - passed = r.tests[dir] - } - return map[string]any{"passed": passed, "tests": 1} - }), - Kept: func(ctx context.Context, level, digest string) (int, bool, error) { return 7, digest == "d-kept", nil }, - } -} - -func ask(job any) delivery.StepAsk { - a := delivery.StepAsk{ID: NewID(), Kind: delivery.AskCheck, Language: delivery.AskGo, Asker: "t", At: time.Now()} - switch j := job.(type) { - case delivery.BuildJob: - a.Build = &j - case delivery.CheckJob: - a.Check = &j - } - return a -} - -// 1887/A3: a build ask fetches once, builds each artifact from the workspace with publish, copies a pinned one without a -// fetch, and answers each artifact's digest and reference. -func TestRunAskBuilds(t *testing.T) { - r := &recorder{fetch: map[string]any{"workspace": "/w/1"}} - steps := r.steps() - var released []string - steps.Release = func(ws string) error { released = append(released, ws); return nil } - // 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"}}}) - 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 + `","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 { - t.Fatalf("calls:\n%s\nwant:\n%s", got, want) - } - if len(released) != 1 || released[0] != "/w/1" { - t.Errorf("released %v, want the one workspace", released) - } - answer := out.(delivery.BuildAnswer) - if len(answer.Artifacts) != 3 || answer.Artifacts[1].Reference != "artifact-store://bundle" { - t.Fatalf("answer %+v", answer) - } -} - -// A build ask for another toolchain is declined (ADR 0584 clause 2), so the node sits out and the asker puts it again. -func TestRunAskRefusesAnotherToolchain(t *testing.T) { - r := &recorder{fetch: map[string]any{"workspace": "/w"}} - s := r.steps() - s.Toolchain = func(context.Context) (delivery.ToolchainUsed, error) { - return delivery.ToolchainUsed{Version: "go1.27", Recipe: "3"}, nil - } - _, err := RunAsk(context.Background(), s, ask(delivery.BuildJob{Module: "m", Path: ".", Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}, - Artifacts: []delivery.AskArtifact{{Artifact: "bundle"}}, Toolchain: &delivery.ToolchainUsed{Version: "go1.26", Recipe: "3"}})) - var dec *Declining - if !errors.As(err, &dec) || len(dec.Lacks) != 1 || len(r.calls) != 0 { - t.Fatalf("err %v, calls %v", err, r.calls) - } - // A check ask names the toolchain too (ADR 0584), and a node on another declines it before it fetches. - _, err = RunAsk(context.Background(), s, ask(delivery.CheckJob{Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}, Levels: []string{delivery.LevelUnit}, - Toolchain: &delivery.ToolchainUsed{Version: "go1.26", Recipe: "3"}})) - if !errors.As(err, &dec) || len(r.calls) != 0 { - t.Fatalf("a check ask: err %v, calls %v", err, r.calls) - } -} - -// 1887/A3, A5a: a check ask runs the levels of one check after one fetch: it digests the unit level, takes the kept -// verdicts, tests the packages whose tests changed first and then the others, also after one of them failed, and runs -// every level, also after one that did not pass, so one check names every failure (the operator, 2026-11-02). -func TestRunAskChecksTheLevelsOfOneCheck(t *testing.T) { - r := &recorder{ - fetch: map[string]any{"workspace": "/w", "changed": []string{"pkg/b/b_test.go"}}, - digests: map[string]string{"x/pkg/a": "d-a", "x/pkg/b": "d-b", "x/pkg/k": "d-kept"}, - tests: map[string]bool{"x/pkg/a": true, "x/pkg/b": false}, - } - out, err := RunAsk(context.Background(), r.steps(), ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit, delivery.LevelIntegration}, Kept: true, - Runner: "workspace", RunnerWhen: "modules/mesh-build-go/", Fetch: map[string]any{"repository": "novox/x", "commit": runCommit, "base": runCommit}})) - if err != nil { - t.Fatal(err) - } - 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"} -digest {"level":"integration","workspace":"/w"} -test {"level":"integration","packages":["x/pkg/b"],"workspace":"/w"} -test {"level":"integration","packages":["x/pkg/a"],"workspace":"/w"}` - if got != want { - t.Fatalf("calls:\n%s\nwant:\n%s", got, want) - } - a := out.(delivery.CheckAnswer) - if len(a.Levels) != 2 { - t.Fatalf("levels %+v, want both levels", a.Levels) - } - u := a.Levels[0] - if u.Level != delivery.LevelUnit || len(u.Kept) != 1 || u.Kept[0].Package != "x/pkg/k" || u.Kept[0].Tests != 7 || len(u.Tests) != 2 || u.Digest == nil { - t.Fatalf("the unit level %+v", u) - } - - // ADR 0567 §12: a check's node names reach the digest and test tools, for the names check (a kept verdict misses when - // they change). - r.calls = nil - if _, err := RunAsk(context.Background(), r.steps(), ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit}, NodeNames: []string{"anchor"}, - Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}})); err != nil || !strings.Contains(strings.Join(r.calls, "\n"), `digest {"level":"unit","node-names":["anchor"]`) || - !strings.Contains(strings.Join(r.calls, "\n"), `test {"level":"unit","node-names":["anchor"]`) { - t.Fatalf("the node names are not handed to the digest and test tools: %v, %v", r.calls, err) - } - - // Issue 890/AC2: a package declaring node-build-node is not tested on a node that lacks it but named unmet, and is - // tested on a node that has it. - for _, has := range []bool{false, true} { - r.calls, r.tests["x/pkg/b"] = nil, true - r.needs = []delivery.PackageNeeds{{Package: "x/pkg/a", Needs: []string{delivery.NeedNodeBuildNode}}} - steps := r.steps() - if has { - steps.Has = func(n string) bool { return n == delivery.NeedNodeBuildNode } - } - out, err := RunAsk(context.Background(), steps, ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit}, Kept: true, - Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}})) - if err != nil { - t.Fatal(err) - } - u := out.(delivery.CheckAnswer).Levels[0] - tested := strings.Contains(strings.Join(r.calls, "\n"), `"x/pkg/a"`) - if tested != has || (len(u.Unmet) == 0) == !has || (!has && (u.Unmet[0].Package != "x/pkg/a" || delivery.UnmetNeeds([]delivery.LevelAnswer{u})[0] != delivery.NeedNodeBuildNode)) { - t.Fatalf("has %v: tested x/pkg/a %v, unmet %+v, calls %v", has, tested, u.Unmet, r.calls) - } - } - // A placed ask naming its packages tests those alone and takes no other's kept verdict, also when the level would - // run whole (ADR 0552 §4). - for _, digests := range []map[string]string{r.digests, nil} { - r.calls, r.digests = nil, digests - steps := r.steps() - steps.Has = func(string) bool { return true } - out, err := RunAsk(context.Background(), steps, ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit}, Kept: true, Packages: []string{"x/pkg/a"}, - Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}})) - if err != nil { - t.Fatal(err) - } - u := out.(delivery.CheckAnswer).Levels[0] - if last := r.calls[len(r.calls)-1]; !strings.HasSuffix(last, `"packages":["x/pkg/a"],"workspace":"/w"}`) || len(u.Kept) != 0 || len(u.Tests) != 1 { - t.Fatalf("digests %v: calls %v, kept %+v: want x/pkg/a alone tested, nothing taken kept", digests != nil, r.calls, u.Kept) - } - } -} - -// ADR 0535 §2, §4: a merge that changes the build module's own code is judged by the candidate's runner: its digest says -// the candidate ran, and every package runs with nothing kept; an installed module that ignores the runner is planned as -// any other, the runner dropped. A digest that does not cover the fetch's siblings runs the level whole too. -func TestRunAskRunsWholeUnderTheRunnerOrWithoutTheSiblings(t *testing.T) { - for _, tc := range []struct{ judged, want string }{ - {"candidate 999", `digest {"level":"unit","runner":"workspace","workspace":"/w"} -test {"level":"unit","runner":"workspace","workspace":"/w"}`}, - {"installed", `digest {"level":"unit","runner":"workspace","workspace":"/w"}`}, - } { - r := &recorder{fetch: map[string]any{"workspace": "/w", "changed": []string{"modules/mesh-build-go/x.go"}}, - digests: map[string]string{"x/pkg/a": "d-kept"}, tests: map[string]bool{}} - steps := r.steps() - digest := steps.Digest - steps.Digest = func(ctx context.Context, args map[string]any) (any, error) { - _, _ = digest(ctx, args) - return map[string]any{"level": "unit", "packages": r.digests, "judged_by": tc.judged}, nil - } - out, err := RunAsk(context.Background(), steps, ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit}, Kept: true, - Runner: "workspace", RunnerWhen: "modules/mesh-build-go/", Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}})) - if err != nil { - t.Fatal(err) - } - if got := strings.Join(r.calls[1:], "\n"); got != tc.want { - t.Errorf("judged by %s: calls\n%s\nwant\n%s", tc.judged, got, tc.want) - } - l := out.(delivery.CheckAnswer).Levels[0] - if tc.judged == "installed" && (len(l.Kept) != 1 || l.Judged != "") { - t.Errorf("an installed module's level %+v, want the kept verdict taken", l) - } - if tc.judged != "installed" && (len(l.Kept) != 0 || l.Digest != nil || l.Judged != tc.judged) { - t.Errorf("the candidate's level %+v, want nothing kept and the program named", l) - } - } - r := &recorder{fetch: map[string]any{"workspace": "/w"}, digests: map[string]string{"x/pkg/a": "d-kept"}, tests: map[string]bool{}} - out, err := RunAsk(context.Background(), r.steps(), ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit}, Kept: true, - Fetch: map[string]any{"repository": "novox/x", "commit": runCommit, "beside": []any{map[string]any{"repository": "novox/mesh-sdk", "commit": runCommit}}}})) - if err != nil { - t.Fatal(err) - } - if l := out.(delivery.CheckAnswer).Levels[0]; len(l.Kept) != 0 || l.Digest != nil { - t.Fatalf("a digest without the siblings kept %+v", l) - } -} - -// A Node check tests each module directory, the one whose tests changed first; a conflict ends the ask with the fetch -// alone, a verdict for the asker. -func TestRunAskChecksNodeModulesAndConflicts(t *testing.T) { - 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": true}} - s := r.steps() - s.Digest = nil - if _, err := RunAsk(context.Background(), s, ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit}, Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}})); err != nil { - t.Fatal(err) - } - if fmt.Sprint(r.calls[1:]) != `[test {"level":"unit","module":"b","workspace":"/w"} test {"level":"unit","module":"a","workspace":"/w"}]` { - t.Fatalf("calls %v", r.calls) - } - r = &recorder{fetch: map[string]any{"workspace": "/w", "conflicts": []string{"go.mod"}}} - out, err := RunAsk(context.Background(), r.steps(), ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit}, Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}})) - if err != nil || len(r.calls) != 1 || len(out.(delivery.CheckAnswer).Levels) != 0 { - t.Fatalf("a conflict: %v %v", err, r.calls) - } -} - -// Issue 1289: the packages whose tests changed run first. Since 2026-11-02 (the operator: "A failed check shows only its -// first fault") a failure among them ends nothing: every package of the level and every level still runs, so one check -// names every failing test (issue 1921). A Node check does the same with its module directories. -func TestRunAskRunsEveryPackageAndLevelAfterAFailure(t *testing.T) { - for _, row := range []struct { - name string - changed []string - tests map[string]bool - want []string // the packages tested at each level, in order - }{ - {"the change's own test fails: the rest still runs", []string{"pkg/b/b_test.go"}, map[string]bool{"x/pkg/a": false, "x/pkg/b": false}, []string{"x/pkg/b", "x/pkg/a"}}, - {"the change's own test passes: the rest runs, also after a failure", []string{"pkg/b/b_test.go"}, map[string]bool{"x/pkg/a": false, "x/pkg/b": true, "x/pkg/c": false}, []string{"x/pkg/b", "x/pkg/a x/pkg/c"}}, - {"no test changed: every failure is named", nil, map[string]bool{"x/pkg/a": false, "x/pkg/b": false}, []string{"x/pkg/a x/pkg/b"}}, - } { - r := &recorder{fetch: map[string]any{"workspace": "/w", "changed": row.changed}, tests: row.tests, - digests: map[string]string{"x/pkg/a": "d-a", "x/pkg/b": "d-b"}} - if _, ok := row.tests["x/pkg/c"]; ok { - r.digests["x/pkg/c"] = "d-c" - } - out, err := RunAsk(context.Background(), r.steps(), ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit, delivery.LevelIntegration}, - Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}})) - if err != nil { - t.Fatal(err) - } - var got []string - for _, c := range r.calls[1:] { - if strings.HasPrefix(c, "test ") { - var a struct{ Packages []string } - _ = json.Unmarshal([]byte(strings.TrimPrefix(c, "test ")), &a) - got = append(got, strings.Join(a.Packages, " ")) - } - } - if want := append(slices.Clone(row.want), row.want...); fmt.Sprint(got) != fmt.Sprint(want) { - t.Errorf("%s: tested %v, want %v", row.name, got, want) - } - if a := out.(delivery.CheckAnswer); len(a.Levels) != 2 || len(a.Levels[0].Tests) != len(row.want) { - t.Errorf("%s: levels %+v, want both, each with every test result", row.name, a.Levels) - } - } - - r := &recorder{fetch: map[string]any{"workspace": "/w", "modules": []string{"a", "b"}, "changed": []string{"b/test/x.test.ts"}}, - tests: map[string]bool{"a": true, "b": false}} - s := r.steps() - s.Digest = nil - out, err := RunAsk(context.Background(), s, ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit, delivery.LevelIntegration}, - Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}})) - if err != nil { - t.Fatal(err) - } - if a := out.(delivery.CheckAnswer); len(r.calls) != 5 || len(a.Levels) != 2 || len(a.Levels[0].Tests) != 2 { - t.Fatalf("calls %v, levels %+v: want b then a at each of the two levels", r.calls, a.Levels) - } -} diff --git a/go/buildstep/blob.go b/go/buildstep/blob.go deleted file mode 100644 index 127c6ac..0000000 --- a/go/buildstep/blob.go +++ /dev/null @@ -1,170 +0,0 @@ -package buildstep - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/hex" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "strings" - "syscall" - "time" -) - -// RegistryWait is how long a push waits for an artifact store that refuses connections, longer than its nightly -// collection holds it still (about a minute and a half, issue 457); RegistryFirstPause is the first pause, doubling -// to RegistryMostPause. Variables so a test shortens them. -var ( - RegistryWait = 5 * time.Minute - RegistryFirstPause = time.Second - RegistryMostPause = 10 * time.Second -) - -// BlobReference is where a pushed bundle or archive is, as a done key names it and the controller delivers from -// (novox/hq ADR 0498 clause 1): artifact-store:////blobs/, the store's address left out -// (issue 102). -func BlobReference(module, artifact, digest string) string { - return "artifact-store://" + module + "/" + artifact + "/blobs/" + digest -} - -// PushBlob puts body into the artifact store at registry (host:port) as a blob of repository (/) by -// its digest (`sha256:` and hex), from the build module's own process, after the build step ended (novox/hq ADR 0498 -// clauses 1 and 2): a blob the store already holds is done without an upload; otherwise it is uploaded (POST -// blobs/uploads/, then PUT with the digest). A store refusing connections is waited out up to RegistryWait; any other -// refusal fails, naming the store's status and its own words, for the caller's `not stored:` error. Bytes that are not -// their digest are refused before anything is sent. -func PushBlob(ctx context.Context, client *http.Client, registry, repository, digest string, body []byte) error { - sum := sha256.Sum256(body) - if got := "sha256:" + hex.EncodeToString(sum[:]); got != digest { - return fmt.Errorf("the blob is %s, not the digest %s it was to be pushed as", got, digest) - } - if client == nil { - client = http.DefaultClient - } - base := "http://" + registry + "/v2/" + repository + "/blobs/" - var present bool - if err := waitForStore(ctx, registry, func() error { - var err error - present, err = blobPresent(ctx, client, base+digest) - return err - }); err != nil { - return err - } - if present { - return nil - } - var location string - if err := waitForStore(ctx, registry, func() error { - var err error - location, err = startUpload(ctx, client, base+"uploads/") - return err - }); err != nil { - return err - } - target, err := url.Parse(base) - if err != nil { - return err - } - loc, err := target.Parse(location) - if err != nil { - return fmt.Errorf("the store answered the upload's location %q, which is not a URL: %w", location, err) - } - q := loc.Query() - q.Set("digest", digest) - loc.RawQuery = q.Encode() - req, err := http.NewRequestWithContext(ctx, http.MethodPut, loc.String(), bytes.NewReader(body)) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/octet-stream") - resp, err := client.Do(req) - if err != nil { - return fmt.Errorf("putting %s of %s: %w", digest, repository, err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusCreated { - return storeSaid(resp, "putting "+digest+" of "+repository) - } - return nil -} - -func blobPresent(ctx context.Context, client *http.Client, at string) (bool, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodHead, at, nil) - if err != nil { - return false, err - } - resp, err := client.Do(req) - if err != nil { - return false, err - } - resp.Body.Close() - switch resp.StatusCode { - case http.StatusOK: - return true, nil - case http.StatusNotFound: - return false, nil - } - return false, fmt.Errorf("asking the store for %s: it answered %s", at, resp.Status) -} - -func startUpload(ctx context.Context, client *http.Client, at string) (string, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodPost, at, nil) - if err != nil { - return "", err - } - resp, err := client.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusAccepted { - return "", storeSaid(resp, "starting an upload at "+at) - } - location := resp.Header.Get("Location") - if location == "" { - return "", fmt.Errorf("starting an upload at %s: the store named no location", at) - } - return location, nil -} - -// storeSaid is the store's refusal: its status and the first of its own words. -func storeSaid(resp *http.Response, what string) error { - words, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) - return fmt.Errorf("%s: the store answered %s: %s", what, resp.Status, strings.TrimSpace(string(words))) -} - -// refused is whether an error is a connection refused. -func refused(err error) bool { - return err != nil && (errors.Is(err, syscall.ECONNREFUSED) || strings.Contains(err.Error(), "connection refused")) -} - -// waitForStore runs try, and while it fails because the store refuses connections, tries again with a growing pause -// until RegistryWait has passed. A refusal is the one error waited for: nothing was sent, so trying again does nothing -// twice. -func waitForStore(ctx context.Context, registry string, try func() error) error { - err := try() - if !refused(err) { - return err - } - started, pause := time.Now(), RegistryFirstPause - for { - left := RegistryWait - time.Since(started) - if left <= 0 { - return fmt.Errorf("the artifact store at %s refused every connection for %s, longer than its nightly collection holds it still, so it is down, not paused: %w", - registry, time.Since(started).Round(time.Millisecond), err) - } - select { - case <-ctx.Done(): - return fmt.Errorf("stopped while waiting for the artifact store at %s: %w (last: %v)", registry, ctx.Err(), err) - case <-time.After(min(pause, left)): - } - pause = min(pause*2, RegistryMostPause) - if err = try(); !refused(err) { - return err - } - } -} diff --git a/go/buildstep/blob_test.go b/go/buildstep/blob_test.go deleted file mode 100644 index b77afb0..0000000 --- a/go/buildstep/blob_test.go +++ /dev/null @@ -1,151 +0,0 @@ -package buildstep - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "io" - "net" - "net/http" - "net/http/httptest" - "strings" - "sync" - "testing" - "time" - - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -func digestOfBytes(b []byte) string { - h := sha256.Sum256(b) - return "sha256:" + hex.EncodeToString(h[:]) -} - -// registry is an artifact store as far as a blob push needs one: HEAD a blob, start an upload, PUT it with its digest. -type registry struct { - mu sync.Mutex - blobs map[string][]byte - calls []string - put int // the status a PUT answers, 201 when 0 -} - -func (r *registry) ServeHTTP(w http.ResponseWriter, req *http.Request) { - r.mu.Lock() - defer r.mu.Unlock() - r.calls = append(r.calls, req.Method+" "+req.URL.Path) - switch { - case req.Method == http.MethodHead && strings.Contains(req.URL.Path, "/blobs/sha256:"): - if _, ok := r.blobs[req.URL.Path[strings.LastIndex(req.URL.Path, "/")+1:]]; ok { - w.WriteHeader(http.StatusOK) - return - } - w.WriteHeader(http.StatusNotFound) - case req.Method == http.MethodPost && strings.HasSuffix(req.URL.Path, "/blobs/uploads/"): - w.Header().Set("Location", strings.TrimSuffix(req.URL.Path, "/")+"/u1?state=x") - w.WriteHeader(http.StatusAccepted) - case req.Method == http.MethodPut && strings.Contains(req.URL.Path, "/blobs/uploads/u1"): - if r.put != 0 { - w.WriteHeader(r.put) - _, _ = io.WriteString(w, `{"errors":[{"code":"DIGEST_INVALID","message":"provided digest did not match uploaded content"}]}`) - return - } - body, _ := io.ReadAll(req.Body) - d := req.URL.Query().Get("digest") - if req.URL.Query().Get("state") != "x" || d != digestOfBytes(body) { - w.WriteHeader(http.StatusBadRequest) - return - } - r.blobs[d] = body - w.WriteHeader(http.StatusCreated) - default: - w.WriteHeader(http.StatusMethodNotAllowed) - } -} - -// A done build's bundle or archive is pushed to the artifact store by its build module (novox/hq ADR 0498 clauses 1 -// and 2): a blob the store already holds is done with no upload; one it lacks is uploaded by digest; its reference is -// the artifact-store form the builder's key and the controller check. -func TestABlobIsPushedOnceByItsDigest(t *testing.T) { - reg := ®istry{blobs: map[string][]byte{}} - srv := httptest.NewServer(reg) - defer srv.Close() - addr := strings.TrimPrefix(srv.URL, "http://") - body := []byte("the packed bundle") - d := digestOfBytes(body) - if err := PushBlob(context.Background(), srv.Client(), addr, "mail/code", d, body); err != nil { - t.Fatal(err) - } - if string(reg.blobs[d]) != string(body) { - t.Fatalf("the store holds %q", reg.blobs[d]) - } - reg.calls = nil - if err := PushBlob(context.Background(), srv.Client(), addr, "mail/code", d, body); err != nil { - t.Fatal(err) - } - if len(reg.calls) != 1 || !strings.HasPrefix(reg.calls[0], "HEAD ") { - t.Fatalf("a blob the store holds was uploaded again: %v", reg.calls) - } - ref := BlobReference("mail", "code", d) - if ref != "artifact-store://mail/code/blobs/"+d { - t.Fatalf("BlobReference = %q", ref) - } - b := delivery.BuilderBuild{State: delivery.BuildDone, Manifest: strings.Repeat("4", 64), - Artifacts: []delivery.BuiltArtifact{{Name: "code", Kind: "bundle", Digest: d, Reference: ref}}} - if _, err := delivery.EncodeBuilderBuild(b); err != nil { - t.Fatalf("the key refuses the reference BlobReference gives: %v", err) - } -} - -// A store that refuses the upload fails the push naming the store's own words; bytes that are not the digest are -// refused before anything is sent. -func TestAPushTheStoreRefusesSaysTheStoresWords(t *testing.T) { - reg := ®istry{blobs: map[string][]byte{}, put: http.StatusBadRequest} - srv := httptest.NewServer(reg) - defer srv.Close() - addr := strings.TrimPrefix(srv.URL, "http://") - body := []byte("x") - err := PushBlob(context.Background(), srv.Client(), addr, "mail/code", digestOfBytes(body), body) - if err == nil || !strings.Contains(err.Error(), "provided digest did not match") || !strings.Contains(err.Error(), "400") { - t.Fatalf("a refused upload said %v", err) - } - reg.calls = nil - err = PushBlob(context.Background(), srv.Client(), addr, "mail/code", digestOfBytes([]byte("other")), body) - if err == nil || len(reg.calls) != 0 { - t.Fatalf("bytes that are not their digest: %v, calls %v", err, reg.calls) - } -} - -// A store refusing connections (its nightly collection holds it still) is waited out, up to RegistryWait (issue 457). -func TestAPushWaitsOutAStoreRefusingConnections(t *testing.T) { - l, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatal(err) - } - addr := l.Addr().String() - l.Close() // refusing now - reg := ®istry{blobs: map[string][]byte{}} - go func() { - time.Sleep(300 * time.Millisecond) - l2, err := net.Listen("tcp", addr) - if err != nil { - return - } - _ = http.Serve(l2, reg) - }() - beforeWait, beforePause := RegistryWait, RegistryFirstPause - RegistryWait, RegistryFirstPause = 3*time.Second, 100*time.Millisecond - defer func() { RegistryWait, RegistryFirstPause = beforeWait, beforePause }() - body := []byte("late") - if err := PushBlob(context.Background(), http.DefaultClient, addr, "mail/code", digestOfBytes(body), body); err != nil { - t.Fatalf("a store that came back was not waited for: %v", err) - } - - l3, _ := net.Listen("tcp", "127.0.0.1:0") - gone := l3.Addr().String() - l3.Close() - RegistryWait = 300 * time.Millisecond - err = PushBlob(context.Background(), http.DefaultClient, gone, "mail/code", digestOfBytes(body), body) - if err == nil || !strings.Contains(err.Error(), "refused every connection") { - t.Fatalf("a store that stayed down said %v", err) - } -} diff --git a/go/buildstep/buildhours.go b/go/buildstep/buildhours.go deleted file mode 100644 index 6e3dee4..0000000 --- a/go/buildstep/buildhours.go +++ /dev/null @@ -1,129 +0,0 @@ -package buildstep - -import ( - "encoding/json" - "fmt" - "os" - "strings" - "time" -) - -// **A node may build only within its build hours** (the operator, 2026-10-26 21:18: the laptop builds only outside -// office hours, "from 18 o'clock until 6 in the morning"). A build module's `build-hours` setting is the node's window -// in its local time, a config value set on each node's layer of mesh-config (ADR 0567 §12: no repository names a node); -// outside it the capacity judged by is 0: steps running finish, no new one is taken. An empty value builds at any hour. -// The puller judges it at every judgement and wakes on one timer to the window's next edge (a clock edge, not a poll of -// another part). - -// BuildHoursSetting is the build modules' setting of the node's build window: HH:MM-HH:MM in the node's local time; an -// end before its start crosses midnight (18:00-06:00). -const BuildHoursSetting = "build-hours" - -// BuildWindow is one node's build hours: minutes after local midnight, Start inclusive, End exclusive. -type BuildWindow struct { - Start, End int -} - -// String is the window as the setting writes it. -func (w BuildWindow) String() string { - return fmt.Sprintf("%02d:%02d-%02d:%02d", w.Start/60, w.Start%60, w.End/60, w.End%60) -} - -// Open says whether t, in its own location, falls in the window. -func (w BuildWindow) Open(t time.Time) bool { - m := t.Hour()*60 + t.Minute() - if w.Start < w.End { - return m >= w.Start && m < w.End - } - return m >= w.Start || m < w.End -} - -// NextEdge is the first time after t, in t's location, at which the window opens or closes. -func (w BuildWindow) NextEdge(t time.Time) time.Time { - var next time.Time - for day := 0; day <= 1; day++ { - for _, m := range []int{w.Start, w.End} { - e := time.Date(t.Year(), t.Month(), t.Day()+day, m/60, m%60, 0, 0, t.Location()) - if e.After(t) && (next.IsZero() || e.Before(next)) { - next = e - } - } - } - return next -} - -// ParseBuildWindow reads a build-hours value, HH:MM-HH:MM: nil when it is empty. -func ParseBuildWindow(setting string) (*BuildWindow, error) { - setting = strings.TrimSpace(setting) - if setting == "" { - return nil, nil - } - from, to, ok := strings.Cut(setting, "-") - if !ok { - return nil, fmt.Errorf("the build hours %q are not HH:MM-HH:MM", setting) - } - start, err := minuteOf(from) - if err != nil { - return nil, fmt.Errorf("the build hours %q: %v", setting, err) - } - end, err := minuteOf(to) - if err != nil { - return nil, fmt.Errorf("the build hours %q: %v", setting, err) - } - if start == end { - return nil, fmt.Errorf("the build hours %q open and close at the same minute", setting) - } - return &BuildWindow{Start: start, End: end}, nil -} - -// minuteOf reads HH:MM as minutes after midnight. -func minuteOf(s string) (int, error) { - var h, m int - s = strings.TrimSpace(s) - if n, err := fmt.Sscanf(s, "%d:%d", &h, &m); err != nil || n != 2 || h < 0 || h > 23 || m < 0 || m > 59 || len(s) != 5 { - return 0, fmt.Errorf("%q is not a time HH:MM", s) - } - return h*60 + m, nil -} - -// NodeBuildHours is the node's build window, read from the module's settings file at path at every call, and why it is -// none when the value cannot be read. No path, no file value or an empty one is no window and no fault. A value that -// cannot be read is no window too, so the node builds at any hour; the setting is a preference of hours, not a safety -// bound (a node that must never build has capacity 0). It is loud: the fault is said once per distinct fault through -// logf, and the puller writes it in the capacity record that build_usage shows. -func NodeBuildHours(path, node string, logf func(string, ...any)) func() (*BuildWindow, string) { - said := "" - say := func(why string) { - if logf != nil && why != said { - said = why - logf("%s, so %s builds at any hour", why, node) - } - } - fault := func(why string) (*BuildWindow, string) { - say(why) - return nil, why - } - return func() (*BuildWindow, string) { - if path == "" { - return nil, "" - } - raw, err := os.ReadFile(path) - if err != nil { - return fault(fmt.Sprintf("the setting %s cannot be read: %v", BuildHoursSetting, err)) - } - var s map[string]json.RawMessage - if err := json.Unmarshal(raw, &s); err != nil { - return fault(fmt.Sprintf("the settings file %s is not JSON: %v", path, err)) - } - setting := "" - if v, ok := s[BuildHoursSetting]; ok && json.Unmarshal(v, &setting) != nil { - return fault(fmt.Sprintf("the setting %s %s is not a string", BuildHoursSetting, v)) - } - w, err := ParseBuildWindow(setting) - if err != nil { - return fault(err.Error()) - } - said = "" - return w, "" - } -} diff --git a/go/buildstep/capacity.go b/go/buildstep/capacity.go index 5b4fad5..098f657 100644 --- a/go/buildstep/capacity.go +++ b/go/buildstep/capacity.go @@ -1,61 +1,7 @@ package buildstep -import ( - "encoding/json" - "fmt" - "os" -) - // CapacitySetting is the build modules' setting of the node's build capacity (novox/hq ADR 0525 §5, ADR 0567 §12): how // many asks the node takes at once, an integer config value set on each node's layer of mesh-config. No repository names // a node: each build module declares it with the default 0, which pulls nothing, and the controller (or mesh-config) // lays the node's value into the module's settings file. const CapacitySetting = "capacity" - -// NodeCapacity is the node's build capacity, read from the module's settings file at path at every call, so a change -// takes effect at the next pull. No path, no file value, or a value that is not a whole number of at least 0 gives 0 and -// a value that cannot be read says why: pulling on a setting nobody can read could build on a node that must not. -func NodeCapacity(path, node string, logf func(string, ...any)) func() int { - say := func(format string, args ...any) { - if logf != nil { - logf(format, args...) - } - } - return func() int { - n, err := ReadCapacity(path) - if err != nil { - say("%v, so %s pulls nothing", err, node) - return 0 - } - return n - } -} - -// ReadCapacity is the capacity the settings file at path holds, and why it cannot be read when it cannot (then 0). -func ReadCapacity(path string) (int, error) { - if path == "" { - return 0, nil - } - raw, err := os.ReadFile(path) - if err != nil { - return 0, fmt.Errorf("the build capacity cannot be read: %w", err) - } - var s map[string]json.RawMessage - if err := json.Unmarshal(raw, &s); err != nil { - return 0, fmt.Errorf("the settings file %s is not JSON: %w", path, err) - } - return capacityOf(s[CapacitySetting]) -} - -// capacityOf is the capacity a config value holds: absent or null is 0; anything but a whole number of at least 0 is -// refused, naming it. -func capacityOf(value json.RawMessage) (int, error) { - if len(value) == 0 || string(value) == "null" { - return 0, nil - } - var n int - if err := json.Unmarshal(value, &n); err != nil || n < 0 { - return 0, fmt.Errorf("the build capacity %s is not a whole number of asks at once, 0 or more", value) - } - return n, nil -} diff --git a/go/buildstep/capacity_cpu.go b/go/buildstep/capacity_cpu.go deleted file mode 100644 index 413bfb9..0000000 --- a/go/buildstep/capacity_cpu.go +++ /dev/null @@ -1,141 +0,0 @@ -package buildstep - -import ( - "encoding/json" - "fmt" - "math" - "os" - "runtime" - "strconv" - "strings" - - "git.novox.be/novox/mesh-sdk/go/buildcpu" - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -// **A build seat's capacity follows the CPU its node gives builds** (issue 1806's follow-up; issue 1890, 2026-10-26 -// 19:41: under the 50 % cap the workstation's mesh-build-go kept capacity 3, three full mesh-catalog checks shared 12 cores, and -// each step ran past the 30-minute step bound with no test run). The configured capacity is an upper bound; the node's -// build slice bounds it again by the cores a step needs: floor(the slice's quota in cores / cores-per-step), one at -// least. The quota is read live from the cgroup at every judgement, so a changed cap is followed at the next take -// without a restart, and the result is said (the seat's current verb, the status tools, `capacity.` in the pauses -// state that the delivery view's build_usage reads). - -// CoresPerStepSetting is a build module's setting of the processors one of its steps needs: a number above zero, the -// same on every node (a step's need is the module's, not the node's). Each build module declares it with its own default -// (DefaultCoresPerStep). -const CoresPerStepSetting = "cores-per-step" - -// DefaultCoresPerStep are the build modules' defaults by language, from measured steps (2026-10-26): a full mesh-catalog -// check on mesh-build-go takes what it is given (three at once took 965 % of the workstation's 1200 % slice, about 4 cores each, -// and ran past the 30-minute step bound), so a Go step is given 6, half again what starved; a Node step (tsc, esbuild, a -// test runner: median 15 s) and an image build (buildah, its RUN lines mostly fetches) need 2. -var DefaultCoresPerStep = map[string]float64{delivery.AskGo: 6, delivery.AskNode: 2, delivery.AskImage: 2} - -// Quota is the build slice's CPU quota as this process's cgroup holds it, in percent of one processor (1200 is 12 -// cores), and where it was read: the cgroup's cpu.max (buildcpu.ReadInForce, the tighter of its unit's and its slice's), -// else the share the composer gave (buildcpu.ShareWord) over the machine's processors, else 0 and why none is known. -func Quota() (percent int, from string) { - f := buildcpu.ReadInForce() - if f.Quota > 0 { - path := f.Slice.Path - if path == "" { - path = f.Cgroup - } - return f.Quota, "the cpu.max of " + path - } - if n, err := strconv.Atoi(strings.TrimSpace(os.Getenv(buildcpu.ShareWord))); err == nil && n >= 1 && n <= 100 { - return buildcpu.Quota(n, runtime.NumCPU()), fmt.Sprintf("the share %d%% the composer gave (%s), of %d processors; no cpu.max is read", n, buildcpu.ShareWord, runtime.NumCPU()) - } - return 0, "no CPU quota: neither a cpu.max on its cgroup nor a share from the composer" -} - -// EffectiveCapacity is the capacity a seat's holder judges by, from its configured capacity, its node's quota (percent of -// one processor, 0 when none is known) and the cores one of its steps needs. Without a quota it is the configured one; -// with one, min(configured, floor(quota cores / coresPerStep)), and one at least while the configured one is above -// zero. A coresPerStep of zero or less is taken as 1. -func EffectiveCapacity(configured, quotaPercent int, quotaFrom string, coresPerStep float64) delivery.Capacity { - if coresPerStep <= 0 || math.IsNaN(coresPerStep) || math.IsInf(coresPerStep, 0) { - coresPerStep = 1 - } - c := delivery.Capacity{Configured: configured, Effective: configured, QuotaFrom: quotaFrom, CoresPerStep: coresPerStep} - switch { - case configured <= 0: - c.Effective = 0 - c.Why = fmt.Sprintf("its configured capacity is %d, so it pulls nothing", configured) - return c - case quotaPercent <= 0: - c.Why = fmt.Sprintf("its configured capacity %d; %s, so the CPU bounds nothing", configured, quotaFrom) - return c - } - c.QuotaCores = float64(quotaPercent) / 100 - c.ByCPU = int(math.Floor(c.QuotaCores/coresPerStep + 1e-9)) - if c.ByCPU < 1 { - c.ByCPU = 1 - } - byCPU := fmt.Sprintf("%g cores of the build slice (%s) / %g cores a step = %d", c.QuotaCores, quotaFrom, coresPerStep, c.ByCPU) - if c.ByCPU < configured { - c.Effective = c.ByCPU - c.Why = fmt.Sprintf("%d, bounded by the CPU: %s, under its configured %d", c.Effective, byCPU, configured) - } else { - c.Why = fmt.Sprintf("%d, its configured capacity; the CPU allows %s", configured, byCPU) - } - return c -} - -// NodeCoresPerStep is the module's cores-per-step, read from its settings file at path at every call so a change is -// followed at the next judgement; fallback when there is no path, no value or one that is not a number above zero, said -// once per distinct fault through logf. -func NodeCoresPerStep(path string, fallback float64, logf func(string, ...any)) func() float64 { - said := "" - say := func(why string) { - if logf != nil && why != said { - said = why - logf("%s, so a step is taken to need %g cores", why, fallback) - } - } - return func() float64 { - if path == "" { - return fallback - } - raw, err := os.ReadFile(path) - if err != nil { - say(fmt.Sprintf("the setting %s cannot be read: %v", CoresPerStepSetting, err)) - return fallback - } - var s map[string]json.RawMessage - if err := json.Unmarshal(raw, &s); err != nil { - say(fmt.Sprintf("the settings file %s is not JSON: %v", path, err)) - return fallback - } - v, ok := s[CoresPerStepSetting] - if !ok { - return fallback // not declared yet (a module before its manifest names it): the default, nothing to say - } - n, err := coresOf(v) - if err != nil { - say(fmt.Sprintf("the setting %s %s: %v", CoresPerStepSetting, v, err)) - return fallback - } - said = "" - return n - } -} - -// coresOf reads a cores-per-step value: a JSON number or a string holding one, above zero. -func coresOf(v json.RawMessage) (float64, error) { - var n float64 - if err := json.Unmarshal(v, &n); err != nil { - var s string - if err := json.Unmarshal(v, &s); err != nil { - return 0, fmt.Errorf("is neither a number nor a string") - } - if n, err = strconv.ParseFloat(strings.TrimSpace(s), 64); err != nil { - return 0, fmt.Errorf("is not a number") - } - } - if n <= 0 || math.IsNaN(n) || math.IsInf(n, 0) { - return 0, fmt.Errorf("is not above zero") - } - return n, nil -} diff --git a/go/buildstep/capacity_test.go b/go/buildstep/capacity_test.go deleted file mode 100644 index aef58c6..0000000 --- a/go/buildstep/capacity_test.go +++ /dev/null @@ -1,135 +0,0 @@ -package buildstep - -import ( - "os" - "path/filepath" - "strings" - "testing" - "time" -) - -// 1887/A7: the node's build capacity is the build module's setting, read at every pull: the control-node at 0 pulls -// nothing, a change takes effect at the next read, and a setting that cannot be read pulls nothing. -// ADR 0553 §4: a step's threads are the composer's share over the module's own capacity; without the word, none. And -// issue 1806's follow-up: the capacity judged by is the configured one bounded by the build slice's cores over the cores -// a step needs (the three build nodes as they stand on 2026-10-26), and cores-per-step is read from the settings file. -func TestNodeCapacityAndLimits(t *testing.T) { - // ADR 0567 §12: the capacity is the node's integer, its default 0, and no default names a node. - if got := NodeCapacity("", "laptop", nil)(); got != 0 { - t.Errorf("no settings file = %d, want 0", got) - } - file := filepath.Join(t.TempDir(), "settings.json") - capacity := NodeCapacity(file, "anchor", nil) - for value, want := range map[string]int{`{"capacity":1}`: 1, `{"capacity":4}`: 4, `{}`: 0, `{"capacity":null}`: 0, - `{"capacity":"anchor=3"}`: 0, `{"capacity":-1}`: 0, `{"capacity":1.5}`: 0} { - os.WriteFile(file, []byte(value), 0o600) - if got := capacity(); got != want { - t.Errorf("%s = %d, want %d", value, got, want) - } - } - if got := NodeCapacity(filepath.Join(t.TempDir(), "none"), "anchor", nil)(); got != 0 { - t.Errorf("a missing file = %d, want 0", got) - } - t.Setenv("MESH_BUILD_CPU_SHARE", "") - if l := NodeLimits(3); l.Threads != 0 || l.Slots != 3 { - t.Errorf("without the word: %+v", l) - } - t.Setenv("MESH_BUILD_CPU_SHARE", "75") - if l := NodeLimits(3); l.Percent != 75 || l.Slots != 3 || l.Threads < 1 { - t.Errorf("from the word: %+v", l) - } - for _, c := range []struct { - name string - configured, quota int - cores float64 - effective, byCPU int - }{ - {"shanks: 12 cores, configured 3", 3, 1200, 6, 2, 2}, - {"ace: 18 cores, configured 2", 2, 1800, 6, 2, 3}, - {"g14: 8 cores, configured 2", 2, 800, 6, 1, 1}, - {"node on shanks: 12 cores at 2", 3, 1200, 2, 3, 6}, - {"no quota: the configured one", 3, 0, 6, 3, 0}, - {"configured 0 pulls nothing", 0, 1200, 6, 0, 0}, - {"a quota under one step still builds one", 2, 100, 6, 1, 1}, - {"no cores a step is one", 2, 150, 0, 1, 1}, - } { - got := EffectiveCapacity(c.configured, c.quota, "test", c.cores) - if got.Effective != c.effective || got.ByCPU != c.byCPU || got.Why == "" { - t.Errorf("%s: %+v, want effective %d, by CPU %d", c.name, got, c.effective, c.byCPU) - } - } - file = filepath.Join(t.TempDir(), "settings.json") - var said []string - cores := NodeCoresPerStep(file, 6, func(f string, a ...any) { said = append(said, f) }) - for _, c := range []struct { - file string - want float64 - }{{`{"capacity":2}`, 6}, {`{"cores-per-step":4}`, 4}, {`{"cores-per-step":"3.5"}`, 3.5}, {`{"cores-per-step":0}`, 6}, {`{"cores-per-step":0}`, 6}} { - os.WriteFile(file, []byte(c.file), 0o600) - if got := cores(); got != c.want { - t.Errorf("cores-per-step from %s = %g, want %g", c.file, got, c.want) - } - } - if len(said) != 1 { - t.Errorf("a value not above zero is said once, said %d times", len(said)) - } - - // The operator, 2026-10-26 21:18: g14 builds only from 18:00 to 06:00, its local time. Outside its build hours the - // capacity judged by is 0, said with the window and when it opens; a node not named, or a bad value, builds at any hour. - // And a node its module proves cannot build (ace without /dev/net/tun, 2026-10-28) judges by 0 at every hour, saying - // why, so its asks go to a node that can. - cet := time.FixedZone("CET", 3600) - day := func(hh, mm int) time.Time { return time.Date(2026, 10, 26, hh, mm, 0, 0, cet) } - was := now - defer func() { now = was }() - for _, c := range []struct { - name, setting, node string - at time.Time - effective int - edge string // the window's next edge, HH:MM; "" for no window - bad bool - ineligible string // what the module's Eligible says; "" for eligible - }{ - {"the laptop in the office", "18:00-06:00", "laptop", day(10, 0), 0, "18:00", false, ""}, - {"the laptop in the evening", "18:00-06:00", "laptop", day(18, 0), 2, "06:00", false, ""}, - {"the laptop past midnight", "18:00-06:00", "laptop", day(5, 59), 2, "06:00", false, ""}, - {"the laptop at the close", "18:00-06:00", "laptop", day(6, 0), 0, "18:00", false, ""}, - {"a day window", "09:00-17:30", "anchor", day(17, 29), 2, "17:30", false, ""}, - {"no window", "", "anchor", day(10, 0), 2, "", false, ""}, - {"a bad time", "18:00-6", "laptop", day(10, 0), 2, "", true, ""}, - {"no span", "18:00-18:00", "laptop", day(10, 0), 2, "", true, ""}, - {"the old form naming a node", "laptop=18:00-06:00", "laptop", day(10, 0), 2, "", true, ""}, - {"a node without /dev/net/tun", "", "anchor", day(10, 0), 0, "", false, "/dev/net/tun cannot be opened: no such device"}, - {"not eligible within its hours", "18:00-06:00", "laptop", day(19, 0), 0, "06:00", false, "/dev/net/tun cannot be opened: no such device"}, - } { - w, err := ParseBuildWindow(c.setting) - if (err != nil) != c.bad { - t.Errorf("%s: parse %v", c.name, err) - } - if edge := ""; w != nil { - edge = w.NextEdge(c.at).Format("15:04") - if edge != c.edge { - t.Errorf("%s: next edge %s, want %s", c.name, edge, c.edge) - } - } else if c.edge != "" { - t.Errorf("%s: no window, want one", c.name) - } - file := filepath.Join(t.TempDir(), "settings.json") - os.WriteFile(file, []byte(`{"build-hours":"`+c.setting+`"}`), 0o600) - now = func() time.Time { return c.at } - p := &Puller{Language: "go", Node: c.node, Capacity: func() int { return 2 }, BuildHours: NodeBuildHours(file, c.node, nil), - Eligible: func() string { return c.ineligible }} - got := p.CapacityDetail() - outside := c.effective == 0 && c.ineligible == "" - if got.Effective != c.effective || got.OutsideHours != outside || (outside && !strings.Contains(got.Why, "outside its build hours 18:00-06:00 CET until 18:00")) { - t.Errorf("%s: %+v, want capacity %d", c.name, got, c.effective) - } - if got.Ineligible != c.ineligible || (c.ineligible != "" && !strings.HasPrefix(got.Why, "0, not eligible: "+c.ineligible+" (otherwise ")) { - t.Errorf("%s: not eligible is not said: %+v", c.name, got) - } - // A value that cannot be read builds at any hour, loudly: the record names the value and says why. - if c.bad && (!strings.Contains(got.BuildHoursFault, c.setting) || !strings.Contains(got.Why, "at any hour, because its build hours cannot be read")) { - t.Errorf("%s: a bad value is not said in the record: %+v", c.name, got) - } - } -} diff --git a/go/buildstep/conformance/captured/README.md b/go/buildstep/conformance/captured/README.md deleted file mode 100644 index 3c882c9..0000000 --- a/go/buildstep/conformance/captured/README.md +++ /dev/null @@ -1,73 +0,0 @@ -# The conformance kit's captured runner output - -Every file here is the real output of a test runner, never written by hand (issue 1901: a fixture that agrees with the -mistake proves the mistake). The toolchains are the build nodes' own, as `mesh-build-go.build_go_toolchains` and -`mesh-build-node.build_node_toolchains` reported them on 2026-10-26: go1.27.3-X:nodwarf5 on every build node, node -v26.12.1 (npm 12.2.1) on shanks, novox and g14, and typescript 5.9.3 from mesh-build-node's toolchain/package.json. -To capture a case again, make its tiny project in a fresh directory as below and run the command shown; the kit's case -table (conformance.go) says what each failure must say. - -## go/ — `go test -json` - -- `passfirst.jsonl` and `subtests.jsonl` (2026-10-26, go1.27.3-X:nodwarf5, for issue 1901's thorough fix): in - `/tmp/1901k/go/`, `GOTOOLCHAIN=local GOFLAGS=-mod=mod go test -json -count=1 ./... > ../.jsonl 2>&1`. - - passfirst: module `example.com/passfirst`, `go 1.26`. Package `a` has `TestTheFirstPackagePasses` (empty). Package `b` - has `TestTheSecondPackagePassesFirst` (empty) and `TestTheSecondPackageFails`, which calls - `t.Logf("judged against the seat as it stands")` then `t.Fatalf("the seat has 2 holders, want 1")`: a passing package - and a passing test before the failing one. - - subtests: module `example.com/subtests`; `TestTheTable` runs `t.Run("passes", func(t *testing.T) {})` and - `t.Run("fails", func(t *testing.T) { t.Errorf("got 3 machines, want 4") })`: the parent fails only by its subtest. -- `subpanic.jsonl` (2026-10-26, go1.27.3-X:nodwarf5, review round 1 of issue 1901's fix), the same way: module - `example.com/subpanic`; `TestTable` runs `t.Run("passes", func(t *testing.T) {})` and `t.Run("panics", ...)`, which - writes to a nil map. go test -json files the subtest's panic under the parent `TestTable`. -- `logthenfatal.jsonl`, `panicking`, `timeout`, `deeptimeout`, `buildfail`, `exiting`, `killed`, `passthenfail`, - `mainexit`, `race`, `racemain` were captured for issue 1901's first and third meetings (2026-10-23 and 2026-10-25, - go1.27.3) in mesh-catalog's mesh-build-go (`testdata/captured/go-test-json-*.jsonl`, named there `go-test-json-pkg-` - and `go-test-json-log-then-fatal`) and moved here unchanged. From that README: each is the standard output and error of - `go test -json -count=1 ./...` (the timeouts with `-timeout 2s`; race and racemain with `-race`, CGO_ENABLED=1) in a - throwaway one-file module that fails without a test's own assertion: panicking (a nil map write in TestPanics), timeout - (TestWaitsForever sleeps past the timeout), deeptimeout (two parallel tests and sixty blocked goroutines past the - timeout, so the goroutine dump pushes the panic line out of the last 4 KB), buildfail (a type error in a test file), - exiting (os.Exit(3) in TestExits), killed (TestKilled sends itself SIGKILL), passthenfail (TestMain panics in a - goroutine after m.Run passed), mainexit (TestMain exits 2 after m.Run passed: go test prints "PASS" and then - "FAIL "), race (TestRacesOnTheSeat writes a package variable from itself and a goroutine), racemain (TestMain does - so after m.Run passed). logthenfatal is a test `TestThree` in package `x` that logs a line with t.Logf and then fails - with a multi-line t.Fatalf naming three subscriptions. - -## node/ — `npm test` running `node --test` under the TAP reporter - -Captured 2026-10-26 with node v26.12.1 and npm 12.2.1 (`/usr/bin/node`, `/usr/bin/npm`), in `/tmp/1901k/node/`, the -way mesh-build-node runs a test script: - - PATH=/usr/bin:$PATH NODE_OPTIONS="--test-reporter=tap --test-reporter-destination=/tmp/1901k/node/.tap" \ - npm test > ../.stdout 2> ../.stderr - -`.tap` is the report file, `.console` the stdout followed by the stderr. Each project's package.json is -`{"name":"","private":true,"type":"module","scripts":{"test":"node --test --experimental-strip-types 'test/*.test.ts'"}}` -(the script every TypeScript package of the catalogue and the SDK runs), with these test files: - -- two-files: `test/a.test.ts` holds `test('passes in the first file', () => {})`; `test/b.test.ts` holds - `test('passes in the second file first', () => {})` and `test('fails in the second file', ...)`, which logs - `judged against the seat as it stands` and asserts `assert.strictEqual(1 + 1, 3, 'one plus one is not three')`. -- suite: `test/a.test.ts` has `describe('the seat', ...)` with `it('passes inside', () => {})` and `it('fails inside', ...)` - asserting `assert.deepStrictEqual({ holders: 2 }, { holders: 1 })`. -- crash: `test/a.test.ts` holds `test('passes before the crash', () => {})`; `test/b.test.ts` holds - `test('throws after it returned', ...)`, which throws `new Error('the bus closed under the test')` in a `setTimeout` of - 1 ms, and `test('waits after the throw', ...)`, which waits 50 ms: node fails the file with an uncaught exception. -- logcrash (review round 1): `test/a.test.ts` holds `test('passes and logs', ...)`, which logs - `retrying after Error: the store was not ready yet`; `test/b.test.ts` is crash's. A passing test's console line - holding "Error" comes before the crash's own. -- timeout: `test/a.test.ts` holds `test('passes before the wait', () => {})` and - `test('waits for an answer that never comes', { timeout: 200 }, async () => { await new Promise(() => {}); })`. -- syntax: `test/a.test.ts` holds `test('passes in the first file', () => {})`; `test/b.test.ts` is - `import test from 'node:test';` and `test('never loads', () => {` (cut off): the file does not load. - -## tsc/ — the type-check - -`typeerror.console` (2026-10-26, typescript 5.9.3 under node v26.12.1): in `/tmp/1901k/tsc/typeerror`, `ok.ts` is -`export const holders: number = 1;` and `seat.ts` is `import { holders } from './ok.js'; export const name: string = holders;`; -the command is mesh-build-node's type-check line without a tsconfig.json: - - node /bin/tsc --noEmit --module NodeNext --moduleResolution NodeNext --target ES2022 --rootDir . ok.ts seat.ts - -and the file is its stdout followed by its stderr. diff --git a/go/buildstep/conformance/captured/go/buildfail.jsonl b/go/buildstep/conformance/captured/go/buildfail.jsonl deleted file mode 100644 index 2b7967d..0000000 --- a/go/buildstep/conformance/captured/go/buildfail.jsonl +++ /dev/null @@ -1,6 +0,0 @@ -{"ImportPath":"example.com/buildfail [example.com/buildfail.test]","Action":"build-output","Output":"# example.com/buildfail [example.com/buildfail.test]\n"} -{"ImportPath":"example.com/buildfail [example.com/buildfail.test]","Action":"build-output","Output":"./p_test.go:5:40: cannot use \"seat\" (untyped string constant) as int value in variable declaration\n"} -{"ImportPath":"example.com/buildfail [example.com/buildfail.test]","Action":"build-fail"} -{"Time":"2026-10-25T15:34:33.261137737+01:00","Action":"start","Package":"example.com/buildfail"} -{"Time":"2026-10-25T15:34:33.261255019+01:00","Action":"output","Package":"example.com/buildfail","Output":"FAIL\texample.com/buildfail [build failed]\n","OutputType":"frame"} -{"Time":"2026-10-25T15:34:33.261268099+01:00","Action":"fail","Package":"example.com/buildfail","Elapsed":0,"FailedBuild":"example.com/buildfail [example.com/buildfail.test]"} diff --git a/go/buildstep/conformance/captured/go/deeptimeout.jsonl b/go/buildstep/conformance/captured/go/deeptimeout.jsonl deleted file mode 100644 index 4018250..0000000 --- a/go/buildstep/conformance/captured/go/deeptimeout.jsonl +++ /dev/null @@ -1,417 +0,0 @@ -{"Time":"2026-10-25T15:35:18.991630993+01:00","Action":"start","Package":"example.com/deeptimeout"} -{"Time":"2026-10-25T15:35:18.993895188+01:00","Action":"run","Package":"example.com/deeptimeout","Test":"TestHoldsTheSeat"} -{"Time":"2026-10-25T15:35:18.993963909+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestHoldsTheSeat","Output":"=== RUN TestHoldsTheSeat\n","OutputType":"frame"} -{"Time":"2026-10-25T15:35:18.99399484+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestHoldsTheSeat","Output":"=== PAUSE TestHoldsTheSeat\n","OutputType":"frame"} -{"Time":"2026-10-25T15:35:18.9939982+01:00","Action":"pause","Package":"example.com/deeptimeout","Test":"TestHoldsTheSeat"} -{"Time":"2026-10-25T15:35:18.99400173+01:00","Action":"run","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus"} -{"Time":"2026-10-25T15:35:18.99400456+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"=== RUN TestWaitsForTheBus\n","OutputType":"frame"} -{"Time":"2026-10-25T15:35:18.99400838+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"=== PAUSE TestWaitsForTheBus\n","OutputType":"frame"} -{"Time":"2026-10-25T15:35:18.99401133+01:00","Action":"pause","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus"} -{"Time":"2026-10-25T15:35:18.99401441+01:00","Action":"cont","Package":"example.com/deeptimeout","Test":"TestHoldsTheSeat"} -{"Time":"2026-10-25T15:35:18.99401704+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestHoldsTheSeat","Output":"=== CONT TestHoldsTheSeat\n","OutputType":"frame"} -{"Time":"2026-10-25T15:35:18.994074141+01:00","Action":"cont","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus"} -{"Time":"2026-10-25T15:35:18.994102612+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"=== CONT TestWaitsForTheBus\n","OutputType":"frame"} -{"Time":"2026-10-25T15:35:21.00935195+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"panic: test timed out after 2s\n"} -{"Time":"2026-10-25T15:35:21.00938383+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\trunning tests:\n"} -{"Time":"2026-10-25T15:35:21.009394391+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t\tTestHoldsTheSeat (2s)\n"} -{"Time":"2026-10-25T15:35:21.009398021+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t\tTestWaitsForTheBus (2s)\n"} -{"Time":"2026-10-25T15:35:21.009401801+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.009406481+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 81 [running]:\n"} -{"Time":"2026-10-25T15:35:21.009409911+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"testing.(*M).startAlarm.func1()\n"} -{"Time":"2026-10-25T15:35:21.009413361+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/usr/lib/go/src/testing/testing.go:2961 +0x34a\n"} -{"Time":"2026-10-25T15:35:21.009418341+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by time.goFunc\n"} -{"Time":"2026-10-25T15:35:21.009421781+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/usr/lib/go/src/time/sleep.go:182 +0x2d\n"} -{"Time":"2026-10-25T15:35:21.009424991+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.009428341+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 1 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.009431721+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"testing.tRunner.func1()\n"} -{"Time":"2026-10-25T15:35:21.009441811+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/usr/lib/go/src/testing/testing.go:2144 +0x425\n"} -{"Time":"2026-10-25T15:35:21.009445511+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"testing.tRunner(0x282e9702008, 0x282e9646bc8)\n"} -{"Time":"2026-10-25T15:35:21.009449641+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/usr/lib/go/src/testing/testing.go:2201 +0x123\n"} -{"Time":"2026-10-25T15:35:21.009453471+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"testing.runTests({0x55a45d, 0x17}, {0x55a45d, 0x17}, 0x282e95940f0, {0x6f19f0, 0x2, 0x2}, {0xc2afa2ca3b3c0c8d, 0x7737f168, ...})\n"} -{"Time":"2026-10-25T15:35:21.009462092+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/usr/lib/go/src/testing/testing.go:2742 +0x510\n"} -{"Time":"2026-10-25T15:35:21.009467222+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"testing.(*M).Run(0x282e96581e0)\n"} -{"Time":"2026-10-25T15:35:21.009470662+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/usr/lib/go/src/testing/testing.go:2602 +0x6af\n"} -{"Time":"2026-10-25T15:35:21.009473692+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"main.main()\n"} -{"Time":"2026-10-25T15:35:21.009476942+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t_testmain.go:48 +0x9b\n"} -{"Time":"2026-10-25T15:35:21.009480092+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.009483242+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 7 [sleep]:\n"} -{"Time":"2026-10-25T15:35:21.009486502+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"time.Sleep(0xdf8475800)\n"} -{"Time":"2026-10-25T15:35:21.009490682+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/usr/lib/go/src/runtime/time.go:368 +0x165\n"} -{"Time":"2026-10-25T15:35:21.009493812+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat(0x282e9702248?)\n"} -{"Time":"2026-10-25T15:35:21.009498842+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:14 +0x9e\n"} -{"Time":"2026-10-25T15:35:21.009502242+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"testing.tRunner(0x282e9702248, 0x6d5e78)\n"} -{"Time":"2026-10-25T15:35:21.009505742+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/usr/lib/go/src/testing/testing.go:2195 +0xea\n"} -{"Time":"2026-10-25T15:35:21.009509292+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by testing.(*T).Run in goroutine 1\n"} -{"Time":"2026-10-25T15:35:21.009512752+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/usr/lib/go/src/testing/testing.go:2260 +0x4d4\n"} -{"Time":"2026-10-25T15:35:21.009515792+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.009518623+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 8 [sleep]:\n"} -{"Time":"2026-10-25T15:35:21.009521303+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"time.Sleep(0xdf8475800)\n"} -{"Time":"2026-10-25T15:35:21.009524423+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/usr/lib/go/src/runtime/time.go:368 +0x165\n"} -{"Time":"2026-10-25T15:35:21.009531403+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestWaitsForTheBus(0x282e9702488?)\n"} -{"Time":"2026-10-25T15:35:21.009534943+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:19 +0x25\n"} -{"Time":"2026-10-25T15:35:21.009538403+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"testing.tRunner(0x282e9702488, 0x6d5e80)\n"} -{"Time":"2026-10-25T15:35:21.009541953+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/usr/lib/go/src/testing/testing.go:2195 +0xea\n"} -{"Time":"2026-10-25T15:35:21.009545233+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by testing.(*T).Run in goroutine 1\n"} -{"Time":"2026-10-25T15:35:21.009548323+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/usr/lib/go/src/testing/testing.go:2260 +0x4d4\n"} -{"Time":"2026-10-25T15:35:21.009551343+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.009554263+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 9 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.009557523+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.009561063+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.009564513+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.009571113+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.009574233+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.009577433+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 10 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.009580823+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.009584174+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.009587504+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.009590974+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.009594004+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.009597184+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 11 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.009600394+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.009603874+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.009610954+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.009614714+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.009618104+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.009621364+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 12 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.009624704+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.009628324+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.009631954+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.009635404+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.009638734+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.009641904+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 13 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.009645325+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.009648645+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.009652065+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.009655625+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.009658885+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.009663385+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 14 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.009666735+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.009670235+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.009676385+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.009680205+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.009683315+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.009686475+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 15 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.009689785+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.009697235+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.009700795+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.009704985+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.009708085+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.009711346+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 16 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.009714586+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.009717946+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.009721676+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.009725626+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.009728866+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.009732106+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 17 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.009735376+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.009738956+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.009742326+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.009746436+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.009750666+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.009754126+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 18 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.009758286+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.009761846+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.009765776+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.009769816+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.009773167+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.009780507+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 19 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.009784207+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.009788007+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.009791627+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.009796347+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.009799427+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.009802937+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 20 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.009806667+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.009809987+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.009813827+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.009818087+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.009821397+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.009824227+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 21 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.009827607+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.009831307+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.009835167+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.009839528+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.009842728+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.009845888+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 22 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.009849168+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.009852548+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.009855958+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.009863648+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.009866938+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.009870088+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 23 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.009879828+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.009883728+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.009887608+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.009891548+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.009895238+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.009898748+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 24 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.009902349+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.009907109+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.009911519+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.009915609+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.009918939+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.009922329+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 25 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.009928329+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.009931909+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.009935489+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.009939429+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.009942699+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.009946019+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 26 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.009949559+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.009953489+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.00996244+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.00996647+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.00996987+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.00997314+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 27 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.00997664+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.00998035+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.00998418+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.00998793+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.00999131+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.00999534+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 28 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.00999894+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.01000268+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.0100087+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.01001258+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.01001605+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.01002007+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 29 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.01002393+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010027531+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010031351+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010035521+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010038781+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010042281+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 30 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010046091+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010054261+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010057881+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010061781+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010064931+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010068261+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 31 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010072151+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010075981+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010079841+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010098802+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010103202+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010106382+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 32 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010109852+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010113242+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010116952+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010120672+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010124112+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010127162+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 33 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010130662+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010134442+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010138142+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010141972+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010145362+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010152262+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 34 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010156353+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010159913+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010163533+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010167403+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010170533+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010173953+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 35 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010177453+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010181033+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010184693+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010188583+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010191753+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010195033+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 36 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010198603+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010202393+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010206123+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010210583+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010213503+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010216914+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 37 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010220134+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010223644+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010227154+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010235154+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010238354+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010241754+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 38 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010270284+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010275144+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010278714+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010282825+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010285795+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010288935+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 39 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010292405+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010295915+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010299545+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010311835+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010315565+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010318975+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 40 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010322655+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010326225+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010329815+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010333745+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010337095+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010340515+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 41 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010344066+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010347706+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010355596+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010359046+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010362036+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010365076+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 42 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010367956+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010370906+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010375366+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010378386+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010381006+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010383666+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 43 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010386676+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010389786+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010392636+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010395746+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010398466+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010401166+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 44 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010404226+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010407186+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010410117+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010413237+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010416157+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010418897+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 45 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010423157+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010430107+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010433197+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010451207+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010454217+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010456967+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 46 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010459877+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010463967+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010467067+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010470597+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010473418+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010476108+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 47 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010478998+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010481958+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010484978+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010488258+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010490888+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010493628+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 48 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010496588+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010499498+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010502498+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010505768+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010508558+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010514148+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 49 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010517178+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010520238+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010523478+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010526818+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010529718+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010532508+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 50 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010535639+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010538749+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010541939+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010546689+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010549869+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010552989+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 51 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010556299+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010560079+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010563479+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010567189+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010570299+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010573079+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 52 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010576319+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010579599+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010583099+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010590519+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010594029+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010596989+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 53 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.01059986+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.01060433+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.01060789+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.01061136+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.01061393+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.01061667+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 54 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.01061984+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.0106227+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.01062566+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.01062876+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.01063131+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.01063428+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 55 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.01063719+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.01063997+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.01064278+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.01064582+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.01064836+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.01065114+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 56 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.01065437+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.01065724+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010671481+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010675671+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010678431+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010691171+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 57 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010694531+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010697641+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010700781+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010704001+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010706611+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010709421+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 58 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010712331+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010715351+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010718431+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010721691+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010724371+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010727362+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 59 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010730592+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010733662+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010736712+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010740162+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010742922+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010745772+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 60 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010752222+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010755402+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010758542+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010761822+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010764592+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010767992+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 61 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010771062+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010774282+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010779382+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010782942+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010785722+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010788573+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 62 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010791893+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010794943+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010798083+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010801683+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010804293+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010807283+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 63 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010810443+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010813553+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010816793+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010820233+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010823063+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010829493+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 64 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010832683+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010835953+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010839223+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010842463+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010845333+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010848423+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 65 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010851663+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010854964+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010858364+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010861714+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010864914+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010867984+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 66 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010870944+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010874114+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010877464+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010880504+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010883144+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010886194+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 67 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010889154+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010892234+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010895524+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010902154+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.010904984+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"} -{"Time":"2026-10-25T15:35:21.010907984+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 68 [chan receive]:\n"} -{"Time":"2026-10-25T15:35:21.010911064+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"} -{"Time":"2026-10-25T15:35:21.010914074+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"} -{"Time":"2026-10-25T15:35:21.010917365+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"} -{"Time":"2026-10-25T15:35:21.010920505+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"} -{"Time":"2026-10-25T15:35:21.013209581+01:00","Action":"output","Package":"example.com/deeptimeout","Output":"FAIL\texample.com/deeptimeout\t2.021s\n","OutputType":"frame"} -{"Time":"2026-10-25T15:35:21.013258851+01:00","Action":"fail","Package":"example.com/deeptimeout","Elapsed":2.022} diff --git a/go/buildstep/conformance/captured/go/exiting.jsonl b/go/buildstep/conformance/captured/go/exiting.jsonl deleted file mode 100644 index ec4ce02..0000000 --- a/go/buildstep/conformance/captured/go/exiting.jsonl +++ /dev/null @@ -1,10 +0,0 @@ -{"Time":"2026-10-25T15:34:33.768742837+01:00","Action":"start","Package":"example.com/exiting"} -{"Time":"2026-10-25T15:34:33.773822287+01:00","Action":"run","Package":"example.com/exiting","Test":"TestFine"} -{"Time":"2026-10-25T15:34:33.773896608+01:00","Action":"output","Package":"example.com/exiting","Test":"TestFine","Output":"=== RUN TestFine\n","OutputType":"frame"} -{"Time":"2026-10-25T15:34:33.773940319+01:00","Action":"output","Package":"example.com/exiting","Test":"TestFine","Output":"--- PASS: TestFine (0.00s)\n","OutputType":"frame"} -{"Time":"2026-10-25T15:34:33.773945359+01:00","Action":"pass","Package":"example.com/exiting","Test":"TestFine","Elapsed":0} -{"Time":"2026-10-25T15:34:33.773952529+01:00","Action":"run","Package":"example.com/exiting","Test":"TestExits"} -{"Time":"2026-10-25T15:34:33.773956439+01:00","Action":"output","Package":"example.com/exiting","Test":"TestExits","Output":"=== RUN TestExits\n","OutputType":"frame"} -{"Time":"2026-10-25T15:34:33.773959829+01:00","Action":"output","Package":"example.com/exiting","Test":"TestExits","Output":" p_test.go:11: closing the store\n"} -{"Time":"2026-10-25T15:34:33.775158538+01:00","Action":"output","Package":"example.com/exiting","Output":"FAIL\texample.com/exiting\t0.006s\n","OutputType":"frame"} -{"Time":"2026-10-25T15:34:33.775186509+01:00","Action":"fail","Package":"example.com/exiting","Elapsed":0.006} diff --git a/go/buildstep/conformance/captured/go/killed.jsonl b/go/buildstep/conformance/captured/go/killed.jsonl deleted file mode 100644 index f4ba525..0000000 --- a/go/buildstep/conformance/captured/go/killed.jsonl +++ /dev/null @@ -1,7 +0,0 @@ -{"Time":"2026-10-25T15:34:34.328258934+01:00","Action":"start","Package":"example.com/killed"} -{"Time":"2026-10-25T15:34:34.331465435+01:00","Action":"run","Package":"example.com/killed","Test":"TestKilled"} -{"Time":"2026-10-25T15:34:34.331699248+01:00","Action":"output","Package":"example.com/killed","Test":"TestKilled","Output":"=== RUN TestKilled\n","OutputType":"frame"} -{"Time":"2026-10-25T15:34:34.331853111+01:00","Action":"output","Package":"example.com/killed","Test":"TestKilled","Output":" p_test.go:10: the kernel is about to kill this\n"} -{"Time":"2026-10-25T15:34:34.334563213+01:00","Action":"output","Package":"example.com/killed","Test":"TestKilled","Output":"signal: killed\n"} -{"Time":"2026-10-25T15:34:34.334611114+01:00","Action":"output","Package":"example.com/killed","Output":"FAIL\texample.com/killed\t0.006s\n","OutputType":"frame"} -{"Time":"2026-10-25T15:34:34.334627134+01:00","Action":"fail","Package":"example.com/killed","Elapsed":0.006} diff --git a/go/buildstep/conformance/captured/go/logthenfatal.jsonl b/go/buildstep/conformance/captured/go/logthenfatal.jsonl deleted file mode 100644 index c59fba2..0000000 --- a/go/buildstep/conformance/captured/go/logthenfatal.jsonl +++ /dev/null @@ -1,13 +0,0 @@ -{"Action":"start","Package":"x"} -{"Action":"run","Package":"x","Test":"TestThree"} -{"Action":"output","Package":"x","Test":"TestThree","Output":"=== RUN TestThree\n","OutputType":"frame"} -{"Action":"output","Package":"x","Test":"TestThree","Output":" a_test.go:4: judged against /var/lib/mesh-build-go-work/id/beside/mesh-catalog as cloned\n"} -{"Action":"output","Package":"x","Test":"TestThree","Output":" a_test.go:5: 3 subscription(s) derive to a subject no emitter owns:\n","OutputType":"error"} -{"Action":"output","Package":"x","Test":"TestThree","Output":" a.b.c\n","OutputType":"error-continue"} -{"Action":"output","Package":"x","Test":"TestThree","Output":" d.e.f\n","OutputType":"error-continue"} -{"Action":"output","Package":"x","Test":"TestThree","Output":" g.h.i\n","OutputType":"error-continue"} -{"Action":"output","Package":"x","Test":"TestThree","Output":"--- FAIL: TestThree (0.00s)\n","OutputType":"frame"} -{"Action":"fail","Package":"x","Test":"TestThree","Elapsed":0} -{"Action":"output","Package":"x","Output":"FAIL\n","OutputType":"frame"} -{"Action":"output","Package":"x","Output":"FAIL\tx\t0.002s\n","OutputType":"frame"} -{"Action":"fail","Package":"x","Elapsed":0.002} diff --git a/go/buildstep/conformance/captured/go/mainexit.jsonl b/go/buildstep/conformance/captured/go/mainexit.jsonl deleted file mode 100644 index 5f106ba..0000000 --- a/go/buildstep/conformance/captured/go/mainexit.jsonl +++ /dev/null @@ -1,8 +0,0 @@ -{"Time":"2026-10-25T15:34:35.274953775+01:00","Action":"start","Package":"example.com/mainexit"} -{"Time":"2026-10-25T15:34:35.278129565+01:00","Action":"run","Package":"example.com/mainexit","Test":"TestFine"} -{"Time":"2026-10-25T15:34:35.278191516+01:00","Action":"output","Package":"example.com/mainexit","Test":"TestFine","Output":"=== RUN TestFine\n","OutputType":"frame"} -{"Time":"2026-10-25T15:34:35.278341979+01:00","Action":"output","Package":"example.com/mainexit","Test":"TestFine","Output":"--- PASS: TestFine (0.00s)\n","OutputType":"frame"} -{"Time":"2026-10-25T15:34:35.278351739+01:00","Action":"pass","Package":"example.com/mainexit","Test":"TestFine","Elapsed":0} -{"Time":"2026-10-25T15:34:35.278359039+01:00","Action":"output","Package":"example.com/mainexit","Output":"PASS\n","OutputType":"frame"} -{"Time":"2026-10-25T15:34:35.279516487+01:00","Action":"output","Package":"example.com/mainexit","Output":"FAIL\texample.com/mainexit\t0.004s\n","OutputType":"frame"} -{"Time":"2026-10-25T15:34:35.279539298+01:00","Action":"fail","Package":"example.com/mainexit","Elapsed":0.005} diff --git a/go/buildstep/conformance/captured/go/panicking.jsonl b/go/buildstep/conformance/captured/go/panicking.jsonl deleted file mode 100644 index 7609d5d..0000000 --- a/go/buildstep/conformance/captured/go/panicking.jsonl +++ /dev/null @@ -1,27 +0,0 @@ -{"Time":"2026-10-25T15:34:33.031091436+01:00","Action":"start","Package":"example.com/panicking"} -{"Time":"2026-10-25T15:34:33.036353279+01:00","Action":"run","Package":"example.com/panicking","Test":"TestBefore"} -{"Time":"2026-10-25T15:34:33.036441781+01:00","Action":"output","Package":"example.com/panicking","Test":"TestBefore","Output":"=== RUN TestBefore\n","OutputType":"frame"} -{"Time":"2026-10-25T15:34:33.036568163+01:00","Action":"output","Package":"example.com/panicking","Test":"TestBefore","Output":"--- PASS: TestBefore (0.00s)\n","OutputType":"frame"} -{"Time":"2026-10-25T15:34:33.036609863+01:00","Action":"pass","Package":"example.com/panicking","Test":"TestBefore","Elapsed":0} -{"Time":"2026-10-25T15:34:33.036641304+01:00","Action":"run","Package":"example.com/panicking","Test":"TestPanics"} -{"Time":"2026-10-25T15:34:33.036645914+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"=== RUN TestPanics\n","OutputType":"frame"} -{"Time":"2026-10-25T15:34:33.036698995+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":" p_test.go:8: about to look up the seat\n"} -{"Time":"2026-10-25T15:34:33.036773996+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"--- FAIL: TestPanics (0.00s)\n","OutputType":"frame"} -{"Time":"2026-10-25T15:34:33.03897418+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"panic: assignment to entry in nil map [recovered, repanicked]\n"} -{"Time":"2026-10-25T15:34:33.040332172+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"\n"} -{"Time":"2026-10-25T15:34:33.040414493+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"goroutine 8 [running]:\n"} -{"Time":"2026-10-25T15:34:33.040521475+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"testing.tRunner.func1.2({0x6b8120, 0x6eff80})\n"} -{"Time":"2026-10-25T15:34:33.040601826+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"\t/usr/lib/go/src/testing/testing.go:2125 +0x232\n"} -{"Time":"2026-10-25T15:34:33.040641947+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"testing.tRunner.func1()\n"} -{"Time":"2026-10-25T15:34:33.040720648+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"\t/usr/lib/go/src/testing/testing.go:2128 +0x329\n"} -{"Time":"2026-10-25T15:34:33.04083286+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"panic({0x6b8120?, 0x6eff80?})\n"} -{"Time":"2026-10-25T15:34:33.040912921+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"\t/usr/lib/go/src/runtime/panic.go:859 +0x125\n"} -{"Time":"2026-10-25T15:34:33.040981652+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"example.com/panicking.TestPanics(0x228226cd0488?)\n"} -{"Time":"2026-10-25T15:34:33.041059903+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"\t/tmp/fix-1901b/panicking/p_test.go:10 +0x53\n"} -{"Time":"2026-10-25T15:34:33.041132704+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"testing.tRunner(0x228226cd0488, 0x6d5bc8)\n"} -{"Time":"2026-10-25T15:34:33.041215586+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"\t/usr/lib/go/src/testing/testing.go:2195 +0xea\n"} -{"Time":"2026-10-25T15:34:33.041221966+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"created by testing.(*T).Run in goroutine 1\n"} -{"Time":"2026-10-25T15:34:33.041225556+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"\t/usr/lib/go/src/testing/testing.go:2260 +0x4d4\n"} -{"Time":"2026-10-25T15:34:33.041984088+01:00","Action":"fail","Package":"example.com/panicking","Test":"TestPanics","Elapsed":0} -{"Time":"2026-10-25T15:34:33.041996638+01:00","Action":"output","Package":"example.com/panicking","Output":"FAIL\texample.com/panicking\t0.010s\n","OutputType":"frame"} -{"Time":"2026-10-25T15:34:33.042044079+01:00","Action":"fail","Package":"example.com/panicking","Elapsed":0.011} diff --git a/go/buildstep/conformance/captured/go/passfirst.jsonl b/go/buildstep/conformance/captured/go/passfirst.jsonl deleted file mode 100644 index a8286d9..0000000 --- a/go/buildstep/conformance/captured/go/passfirst.jsonl +++ /dev/null @@ -1,22 +0,0 @@ -{"Time":"2026-10-26T19:40:29.574068887+01:00","Action":"start","Package":"example.com/passfirst/a"} -{"Time":"2026-10-26T19:40:29.574347185+01:00","Action":"start","Package":"example.com/passfirst/b"} -{"Time":"2026-10-26T19:40:29.576503518+01:00","Action":"run","Package":"example.com/passfirst/a","Test":"TestTheFirstPackagePasses"} -{"Time":"2026-10-26T19:40:29.576557657+01:00","Action":"output","Package":"example.com/passfirst/a","Test":"TestTheFirstPackagePasses","Output":"=== RUN TestTheFirstPackagePasses\n","OutputType":"frame"} -{"Time":"2026-10-26T19:40:29.576589367+01:00","Action":"output","Package":"example.com/passfirst/a","Test":"TestTheFirstPackagePasses","Output":"--- PASS: TestTheFirstPackagePasses (0.00s)\n","OutputType":"frame"} -{"Time":"2026-10-26T19:40:29.576594637+01:00","Action":"pass","Package":"example.com/passfirst/a","Test":"TestTheFirstPackagePasses","Elapsed":0} -{"Time":"2026-10-26T19:40:29.576602487+01:00","Action":"output","Package":"example.com/passfirst/a","Output":"PASS\n","OutputType":"frame"} -{"Time":"2026-10-26T19:40:29.576614477+01:00","Action":"run","Package":"example.com/passfirst/b","Test":"TestTheSecondPackagePassesFirst"} -{"Time":"2026-10-26T19:40:29.576626897+01:00","Action":"output","Package":"example.com/passfirst/b","Test":"TestTheSecondPackagePassesFirst","Output":"=== RUN TestTheSecondPackagePassesFirst\n","OutputType":"frame"} -{"Time":"2026-10-26T19:40:29.576631187+01:00","Action":"output","Package":"example.com/passfirst/b","Test":"TestTheSecondPackagePassesFirst","Output":"--- PASS: TestTheSecondPackagePassesFirst (0.00s)\n","OutputType":"frame"} -{"Time":"2026-10-26T19:40:29.576635567+01:00","Action":"pass","Package":"example.com/passfirst/b","Test":"TestTheSecondPackagePassesFirst","Elapsed":0} -{"Time":"2026-10-26T19:40:29.576638427+01:00","Action":"run","Package":"example.com/passfirst/b","Test":"TestTheSecondPackageFails"} -{"Time":"2026-10-26T19:40:29.576640647+01:00","Action":"output","Package":"example.com/passfirst/b","Test":"TestTheSecondPackageFails","Output":"=== RUN TestTheSecondPackageFails\n","OutputType":"frame"} -{"Time":"2026-10-26T19:40:29.576643477+01:00","Action":"output","Package":"example.com/passfirst/b","Test":"TestTheSecondPackageFails","Output":" b_test.go:8: judged against the seat as it stands\n"} -{"Time":"2026-10-26T19:40:29.576646497+01:00","Action":"output","Package":"example.com/passfirst/b","Test":"TestTheSecondPackageFails","Output":" b_test.go:9: the seat has 2 holders, want 1\n","OutputType":"error"} -{"Time":"2026-10-26T19:40:29.576650087+01:00","Action":"output","Package":"example.com/passfirst/b","Test":"TestTheSecondPackageFails","Output":"--- FAIL: TestTheSecondPackageFails (0.00s)\n","OutputType":"frame"} -{"Time":"2026-10-26T19:40:29.576652797+01:00","Action":"fail","Package":"example.com/passfirst/b","Test":"TestTheSecondPackageFails","Elapsed":0} -{"Time":"2026-10-26T19:40:29.576655177+01:00","Action":"output","Package":"example.com/passfirst/b","Output":"FAIL\n","OutputType":"frame"} -{"Time":"2026-10-26T19:40:29.577365541+01:00","Action":"output","Package":"example.com/passfirst/a","Output":"ok \texample.com/passfirst/a\t0.003s\n"} -{"Time":"2026-10-26T19:40:29.577382561+01:00","Action":"pass","Package":"example.com/passfirst/a","Elapsed":0.003} -{"Time":"2026-10-26T19:40:29.577380641+01:00","Action":"output","Package":"example.com/passfirst/b","Output":"FAIL\texample.com/passfirst/b\t0.003s\n","OutputType":"frame"} -{"Time":"2026-10-26T19:40:29.5774131+01:00","Action":"fail","Package":"example.com/passfirst/b","Elapsed":0.003} diff --git a/go/buildstep/conformance/captured/go/passthenfail.jsonl b/go/buildstep/conformance/captured/go/passthenfail.jsonl deleted file mode 100644 index 55fa930..0000000 --- a/go/buildstep/conformance/captured/go/passthenfail.jsonl +++ /dev/null @@ -1,15 +0,0 @@ -{"Time":"2026-10-25T15:34:34.919624192+01:00","Action":"start","Package":"example.com/passthenfail"} -{"Time":"2026-10-25T15:34:34.922861224+01:00","Action":"run","Package":"example.com/passthenfail","Test":"TestFine"} -{"Time":"2026-10-25T15:34:34.922932965+01:00","Action":"output","Package":"example.com/passthenfail","Test":"TestFine","Output":"=== RUN TestFine\n","OutputType":"frame"} -{"Time":"2026-10-25T15:34:34.922969205+01:00","Action":"output","Package":"example.com/passthenfail","Test":"TestFine","Output":"--- PASS: TestFine (0.00s)\n","OutputType":"frame"} -{"Time":"2026-10-25T15:34:34.922979956+01:00","Action":"pass","Package":"example.com/passthenfail","Test":"TestFine","Elapsed":0} -{"Time":"2026-10-25T15:34:34.922989416+01:00","Action":"output","Package":"example.com/passthenfail","Output":"PASS\n","OutputType":"frame"} -{"Time":"2026-10-25T15:34:34.925022528+01:00","Action":"output","Package":"example.com/passthenfail","Output":"panic: the bus closed after the tests: nats: connection closed\n"} -{"Time":"2026-10-25T15:34:34.925043388+01:00","Action":"output","Package":"example.com/passthenfail","Output":"\n"} -{"Time":"2026-10-25T15:34:34.925046848+01:00","Action":"output","Package":"example.com/passthenfail","Output":"goroutine 20 [running]:\n"} -{"Time":"2026-10-25T15:34:34.925051338+01:00","Action":"output","Package":"example.com/passthenfail","Output":"example.com/passthenfail.TestMain.func1()\n"} -{"Time":"2026-10-25T15:34:34.925055878+01:00","Action":"output","Package":"example.com/passthenfail","Output":"\t/tmp/fix-1901b/passthenfail/p_test.go:13 +0x25\n"} -{"Time":"2026-10-25T15:34:34.925061428+01:00","Action":"output","Package":"example.com/passthenfail","Output":"created by example.com/passthenfail.TestMain in goroutine 1\n"} -{"Time":"2026-10-25T15:34:34.925065028+01:00","Action":"output","Package":"example.com/passthenfail","Output":"\t/tmp/fix-1901b/passthenfail/p_test.go:13 +0x25\n"} -{"Time":"2026-10-25T15:34:34.925390203+01:00","Action":"output","Package":"example.com/passthenfail","Output":"FAIL\texample.com/passthenfail\t0.005s\n","OutputType":"frame"} -{"Time":"2026-10-25T15:34:34.925402904+01:00","Action":"fail","Package":"example.com/passthenfail","Elapsed":0.006} diff --git a/go/buildstep/conformance/captured/go/race.jsonl b/go/buildstep/conformance/captured/go/race.jsonl deleted file mode 100644 index 93e9ac1..0000000 --- a/go/buildstep/conformance/captured/go/race.jsonl +++ /dev/null @@ -1,53 +0,0 @@ -{"Time":"2026-10-25T15:50:42.908557309+01:00","Action":"start","Package":"example.com/race"} -{"Time":"2026-10-25T15:50:42.924298679+01:00","Action":"run","Package":"example.com/race","Test":"TestBefore"} -{"Time":"2026-10-25T15:50:42.92438195+01:00","Action":"output","Package":"example.com/race","Test":"TestBefore","Output":"=== RUN TestBefore\n","OutputType":"frame"} -{"Time":"2026-10-25T15:50:42.924648842+01:00","Action":"output","Package":"example.com/race","Test":"TestBefore","Output":"--- PASS: TestBefore (0.00s)\n","OutputType":"frame"} -{"Time":"2026-10-25T15:50:42.924685133+01:00","Action":"pass","Package":"example.com/race","Test":"TestBefore","Elapsed":0} -{"Time":"2026-10-25T15:50:42.924728573+01:00","Action":"run","Package":"example.com/race","Test":"TestRacesOnTheSeat"} -{"Time":"2026-10-25T15:50:42.924733573+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":"=== RUN TestRacesOnTheSeat\n","OutputType":"frame"} -{"Time":"2026-10-25T15:50:42.92637298+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":"==================\n"} -{"Time":"2026-10-25T15:50:42.92641567+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":"WARNING: DATA RACE\n"} -{"Time":"2026-10-25T15:50:42.926444061+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":"Write at 0x000000833468 by goroutine 10:\n"} -{"Time":"2026-10-25T15:50:42.926462621+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" example.com/race.TestRacesOnTheSeat.func1()\n"} -{"Time":"2026-10-25T15:50:42.926467121+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" /tmp/fix-1901b/race/p_test.go:13 +0x24\n"} -{"Time":"2026-10-25T15:50:42.926483061+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":"\n"} -{"Time":"2026-10-25T15:50:42.926509741+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":"Previous write at 0x000000833468 by goroutine 9:\n"} -{"Time":"2026-10-25T15:50:42.926530142+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" example.com/race.TestRacesOnTheSeat()\n"} -{"Time":"2026-10-25T15:50:42.926534952+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" /tmp/fix-1901b/race/p_test.go:14 +0x30\n"} -{"Time":"2026-10-25T15:50:42.926550462+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" testing.tRunner()\n"} -{"Time":"2026-10-25T15:50:42.926554612+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" /usr/lib/go/src/testing/testing.go:2195 +0x21c\n"} -{"Time":"2026-10-25T15:50:42.926571142+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" testing.(*T).Run.gowrap1()\n"} -{"Time":"2026-10-25T15:50:42.926575812+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" /usr/lib/go/src/testing/testing.go:2260 +0x38\n"} -{"Time":"2026-10-25T15:50:42.926592202+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":"\n"} -{"Time":"2026-10-25T15:50:42.926607792+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":"Goroutine 10 (running) created at:\n"} -{"Time":"2026-10-25T15:50:42.926624292+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" example.com/race.TestRacesOnTheSeat()\n"} -{"Time":"2026-10-25T15:50:42.926628923+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" /tmp/fix-1901b/race/p_test.go:13 +0x24\n"} -{"Time":"2026-10-25T15:50:42.926643853+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" testing.tRunner()\n"} -{"Time":"2026-10-25T15:50:42.926648103+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" /usr/lib/go/src/testing/testing.go:2195 +0x21c\n"} -{"Time":"2026-10-25T15:50:42.926674843+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" testing.(*T).Run.gowrap1()\n"} -{"Time":"2026-10-25T15:50:42.926680673+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" /usr/lib/go/src/testing/testing.go:2260 +0x38\n"} -{"Time":"2026-10-25T15:50:42.926695433+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":"\n"} -{"Time":"2026-10-25T15:50:42.926711063+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":"Goroutine 9 (running) created at:\n"} -{"Time":"2026-10-25T15:50:42.926726614+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" testing.(*T).Run()\n"} -{"Time":"2026-10-25T15:50:42.926730814+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" /usr/lib/go/src/testing/testing.go:2260 +0xb12\n"} -{"Time":"2026-10-25T15:50:42.926746484+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" testing.runTests.func1()\n"} -{"Time":"2026-10-25T15:50:42.926750764+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" /usr/lib/go/src/testing/testing.go:2744 +0x84\n"} -{"Time":"2026-10-25T15:50:42.926765354+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" testing.tRunner()\n"} -{"Time":"2026-10-25T15:50:42.926769844+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" /usr/lib/go/src/testing/testing.go:2195 +0x21c\n"} -{"Time":"2026-10-25T15:50:42.926791794+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" testing.runTests()\n"} -{"Time":"2026-10-25T15:50:42.926796704+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" /usr/lib/go/src/testing/testing.go:2742 +0x9e9\n"} -{"Time":"2026-10-25T15:50:42.926811544+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" testing.(*M).Run()\n"} -{"Time":"2026-10-25T15:50:42.926816694+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" /usr/lib/go/src/testing/testing.go:2602 +0xf44\n"} -{"Time":"2026-10-25T15:50:42.926831695+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" main.main()\n"} -{"Time":"2026-10-25T15:50:42.926835935+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" _testmain.go:50 +0x164\n"} -{"Time":"2026-10-25T15:50:42.926850905+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":"==================\n"} -{"Time":"2026-10-25T15:50:42.935320241+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" testing.go:1867: race detected during execution of test\n","OutputType":"error"} -{"Time":"2026-10-25T15:50:42.935520113+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":"--- FAIL: TestRacesOnTheSeat (0.01s)\n","OutputType":"frame"} -{"Time":"2026-10-25T15:50:42.935592973+01:00","Action":"fail","Package":"example.com/race","Test":"TestRacesOnTheSeat","Elapsed":0.01} -{"Time":"2026-10-25T15:50:42.935722225+01:00","Action":"run","Package":"example.com/race","Test":"TestAfter"} -{"Time":"2026-10-25T15:50:42.935728845+01:00","Action":"output","Package":"example.com/race","Test":"TestAfter","Output":"=== RUN TestAfter\n","OutputType":"frame"} -{"Time":"2026-10-25T15:50:42.935995928+01:00","Action":"output","Package":"example.com/race","Test":"TestAfter","Output":"--- PASS: TestAfter (0.00s)\n","OutputType":"frame"} -{"Time":"2026-10-25T15:50:42.936006328+01:00","Action":"pass","Package":"example.com/race","Test":"TestAfter","Elapsed":0} -{"Time":"2026-10-25T15:50:42.936009978+01:00","Action":"output","Package":"example.com/race","Output":"FAIL\n","OutputType":"frame"} -{"Time":"2026-10-25T15:50:42.937730745+01:00","Action":"output","Package":"example.com/race","Output":"FAIL\texample.com/race\t0.028s\n","OutputType":"frame"} -{"Time":"2026-10-25T15:50:42.937802066+01:00","Action":"fail","Package":"example.com/race","Elapsed":0.029} diff --git a/go/buildstep/conformance/captured/go/racemain.jsonl b/go/buildstep/conformance/captured/go/racemain.jsonl deleted file mode 100644 index 8a2a5c0..0000000 --- a/go/buildstep/conformance/captured/go/racemain.jsonl +++ /dev/null @@ -1,27 +0,0 @@ -{"Time":"2026-10-25T15:50:43.551701745+01:00","Action":"start","Package":"example.com/racemain"} -{"Time":"2026-10-25T15:50:43.570709708+01:00","Action":"run","Package":"example.com/racemain","Test":"TestFine"} -{"Time":"2026-10-25T15:50:43.570806059+01:00","Action":"output","Package":"example.com/racemain","Test":"TestFine","Output":"=== RUN TestFine\n","OutputType":"frame"} -{"Time":"2026-10-25T15:50:43.57085525+01:00","Action":"output","Package":"example.com/racemain","Test":"TestFine","Output":"--- PASS: TestFine (0.00s)\n","OutputType":"frame"} -{"Time":"2026-10-25T15:50:43.57086149+01:00","Action":"pass","Package":"example.com/racemain","Test":"TestFine","Elapsed":0} -{"Time":"2026-10-25T15:50:43.57086974+01:00","Action":"output","Package":"example.com/racemain","Output":"PASS\n","OutputType":"frame"} -{"Time":"2026-10-25T15:50:43.572155623+01:00","Action":"output","Package":"example.com/racemain","Output":"==================\n"} -{"Time":"2026-10-25T15:50:43.572168083+01:00","Action":"output","Package":"example.com/racemain","Output":"WARNING: DATA RACE\n"} -{"Time":"2026-10-25T15:50:43.572171933+01:00","Action":"output","Package":"example.com/racemain","Output":"Write at 0x000000836428 by goroutine 9:\n"} -{"Time":"2026-10-25T15:50:43.572176703+01:00","Action":"output","Package":"example.com/racemain","Output":" example.com/racemain.TestMain.func1()\n"} -{"Time":"2026-10-25T15:50:43.572180053+01:00","Action":"output","Package":"example.com/racemain","Output":" /tmp/fix-1901b/racemain/p_test.go:15 +0x24\n"} -{"Time":"2026-10-25T15:50:43.572184923+01:00","Action":"output","Package":"example.com/racemain","Output":"\n"} -{"Time":"2026-10-25T15:50:43.572189613+01:00","Action":"output","Package":"example.com/racemain","Output":"Previous write at 0x000000836428 by main goroutine:\n"} -{"Time":"2026-10-25T15:50:43.572192993+01:00","Action":"output","Package":"example.com/racemain","Output":" example.com/racemain.TestMain()\n"} -{"Time":"2026-10-25T15:50:43.572198573+01:00","Action":"output","Package":"example.com/racemain","Output":" /tmp/fix-1901b/racemain/p_test.go:16 +0x44\n"} -{"Time":"2026-10-25T15:50:43.572202433+01:00","Action":"output","Package":"example.com/racemain","Output":" main.main()\n"} -{"Time":"2026-10-25T15:50:43.572205623+01:00","Action":"output","Package":"example.com/racemain","Output":" _testmain.go:48 +0x171\n"} -{"Time":"2026-10-25T15:50:43.572208443+01:00","Action":"output","Package":"example.com/racemain","Output":"\n"} -{"Time":"2026-10-25T15:50:43.572211573+01:00","Action":"output","Package":"example.com/racemain","Output":"Goroutine 9 (running) created at:\n"} -{"Time":"2026-10-25T15:50:43.572214803+01:00","Action":"output","Package":"example.com/racemain","Output":" example.com/racemain.TestMain()\n"} -{"Time":"2026-10-25T15:50:43.572218043+01:00","Action":"output","Package":"example.com/racemain","Output":" /tmp/fix-1901b/racemain/p_test.go:15 +0x37\n"} -{"Time":"2026-10-25T15:50:43.572225414+01:00","Action":"output","Package":"example.com/racemain","Output":" main.main()\n"} -{"Time":"2026-10-25T15:50:43.572228654+01:00","Action":"output","Package":"example.com/racemain","Output":" _testmain.go:48 +0x171\n"} -{"Time":"2026-10-25T15:50:43.572231664+01:00","Action":"output","Package":"example.com/racemain","Output":"==================\n"} -{"Time":"2026-10-25T15:50:44.581262249+01:00","Action":"output","Package":"example.com/racemain","Output":"Found 1 data race(s)\n"} -{"Time":"2026-10-25T15:50:44.584693204+01:00","Action":"output","Package":"example.com/racemain","Output":"FAIL\texample.com/racemain\t1.031s\n","OutputType":"frame"} -{"Time":"2026-10-25T15:50:44.584737425+01:00","Action":"fail","Package":"example.com/racemain","Elapsed":1.033} diff --git a/go/buildstep/conformance/captured/go/subpanic.jsonl b/go/buildstep/conformance/captured/go/subpanic.jsonl deleted file mode 100644 index ec85af7..0000000 --- a/go/buildstep/conformance/captured/go/subpanic.jsonl +++ /dev/null @@ -1,30 +0,0 @@ -{"Time":"2026-10-26T20:15:37.791161045+01:00","Action":"start","Package":"example.com/subpanic"} -{"Time":"2026-10-26T20:15:37.793205666+01:00","Action":"run","Package":"example.com/subpanic","Test":"TestTable"} -{"Time":"2026-10-26T20:15:37.793257158+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"=== RUN TestTable\n","OutputType":"frame"} -{"Time":"2026-10-26T20:15:37.793288149+01:00","Action":"run","Package":"example.com/subpanic","Test":"TestTable/passes"} -{"Time":"2026-10-26T20:15:37.793290669+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable/passes","Output":"=== RUN TestTable/passes\n","OutputType":"frame"} -{"Time":"2026-10-26T20:15:37.793298009+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable/passes","Output":"--- PASS: TestTable/passes (0.00s)\n","OutputType":"frame"} -{"Time":"2026-10-26T20:15:37.793311679+01:00","Action":"pass","Package":"example.com/subpanic","Test":"TestTable/passes","Elapsed":0} -{"Time":"2026-10-26T20:15:37.79331977+01:00","Action":"run","Package":"example.com/subpanic","Test":"TestTable/panics"} -{"Time":"2026-10-26T20:15:37.79332377+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable/panics","Output":"=== RUN TestTable/panics\n","OutputType":"frame"} -{"Time":"2026-10-26T20:15:37.79332773+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable/panics","Output":"--- FAIL: TestTable/panics (0.00s)\n","OutputType":"frame"} -{"Time":"2026-10-26T20:15:37.79333111+01:00","Action":"fail","Package":"example.com/subpanic","Test":"TestTable/panics","Elapsed":0} -{"Time":"2026-10-26T20:15:37.79333383+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"--- FAIL: TestTable (0.00s)\n","OutputType":"frame"} -{"Time":"2026-10-26T20:15:37.795322529+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"panic: assignment to entry in nil map [recovered, repanicked]\n"} -{"Time":"2026-10-26T20:15:37.795331489+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"\n"} -{"Time":"2026-10-26T20:15:37.795340579+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"goroutine 9 [running]:\n"} -{"Time":"2026-10-26T20:15:37.79534387+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"testing.tRunner.func1.2({0x6b7010, 0x6eef80})\n"} -{"Time":"2026-10-26T20:15:37.79534657+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"\t/usr/lib/go/src/testing/testing.go:2125 +0x232\n"} -{"Time":"2026-10-26T20:15:37.79534907+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"testing.tRunner.func1()\n"} -{"Time":"2026-10-26T20:15:37.79535172+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"\t/usr/lib/go/src/testing/testing.go:2128 +0x329\n"} -{"Time":"2026-10-26T20:15:37.79535625+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"panic({0x6b7010?, 0x6eef80?})\n"} -{"Time":"2026-10-26T20:15:37.79535879+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"\t/usr/lib/go/src/runtime/panic.go:859 +0x125\n"} -{"Time":"2026-10-26T20:15:37.79536129+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"example.com/subpanic.TestTable.func2(0x3b39436666c8?)\n"} -{"Time":"2026-10-26T20:15:37.79536452+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"\t/tmp/1901k/go/subpanic/p_test.go:7 +0x28\n"} -{"Time":"2026-10-26T20:15:37.79536782+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"testing.tRunner(0x3b39436666c8, 0x6d4b58)\n"} -{"Time":"2026-10-26T20:15:37.7953704+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"\t/usr/lib/go/src/testing/testing.go:2195 +0xea\n"} -{"Time":"2026-10-26T20:15:37.79537289+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"created by testing.(*T).Run in goroutine 7\n"} -{"Time":"2026-10-26T20:15:37.795381681+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"\t/usr/lib/go/src/testing/testing.go:2260 +0x4d4\n"} -{"Time":"2026-10-26T20:15:37.795731441+01:00","Action":"fail","Package":"example.com/subpanic","Test":"TestTable","Elapsed":0} -{"Time":"2026-10-26T20:15:37.795759332+01:00","Action":"output","Package":"example.com/subpanic","Output":"FAIL\texample.com/subpanic\t0.004s\n","OutputType":"frame"} -{"Time":"2026-10-26T20:15:37.795783503+01:00","Action":"fail","Package":"example.com/subpanic","Elapsed":0.005} diff --git a/go/buildstep/conformance/captured/go/subtests.jsonl b/go/buildstep/conformance/captured/go/subtests.jsonl deleted file mode 100644 index b12cb5a..0000000 --- a/go/buildstep/conformance/captured/go/subtests.jsonl +++ /dev/null @@ -1,17 +0,0 @@ -{"Time":"2026-10-26T19:40:29.768101399+01:00","Action":"start","Package":"example.com/subtests"} -{"Time":"2026-10-26T19:40:29.770109733+01:00","Action":"run","Package":"example.com/subtests","Test":"TestTheTable"} -{"Time":"2026-10-26T19:40:29.770152272+01:00","Action":"output","Package":"example.com/subtests","Test":"TestTheTable","Output":"=== RUN TestTheTable\n","OutputType":"frame"} -{"Time":"2026-10-26T19:40:29.770189372+01:00","Action":"run","Package":"example.com/subtests","Test":"TestTheTable/passes"} -{"Time":"2026-10-26T19:40:29.770191882+01:00","Action":"output","Package":"example.com/subtests","Test":"TestTheTable/passes","Output":"=== RUN TestTheTable/passes\n","OutputType":"frame"} -{"Time":"2026-10-26T19:40:29.770197442+01:00","Action":"output","Package":"example.com/subtests","Test":"TestTheTable/passes","Output":"--- PASS: TestTheTable/passes (0.00s)\n","OutputType":"frame"} -{"Time":"2026-10-26T19:40:29.770204352+01:00","Action":"pass","Package":"example.com/subtests","Test":"TestTheTable/passes","Elapsed":0} -{"Time":"2026-10-26T19:40:29.770209622+01:00","Action":"run","Package":"example.com/subtests","Test":"TestTheTable/fails"} -{"Time":"2026-10-26T19:40:29.770211832+01:00","Action":"output","Package":"example.com/subtests","Test":"TestTheTable/fails","Output":"=== RUN TestTheTable/fails\n","OutputType":"frame"} -{"Time":"2026-10-26T19:40:29.770214722+01:00","Action":"output","Package":"example.com/subtests","Test":"TestTheTable/fails","Output":" s_test.go:7: got 3 machines, want 4\n","OutputType":"error"} -{"Time":"2026-10-26T19:40:29.770218032+01:00","Action":"output","Package":"example.com/subtests","Test":"TestTheTable/fails","Output":"--- FAIL: TestTheTable/fails (0.00s)\n","OutputType":"frame"} -{"Time":"2026-10-26T19:40:29.770220732+01:00","Action":"fail","Package":"example.com/subtests","Test":"TestTheTable/fails","Elapsed":0} -{"Time":"2026-10-26T19:40:29.770223522+01:00","Action":"output","Package":"example.com/subtests","Test":"TestTheTable","Output":"--- FAIL: TestTheTable (0.00s)\n","OutputType":"frame"} -{"Time":"2026-10-26T19:40:29.770226292+01:00","Action":"fail","Package":"example.com/subtests","Test":"TestTheTable","Elapsed":0} -{"Time":"2026-10-26T19:40:29.770228582+01:00","Action":"output","Package":"example.com/subtests","Output":"FAIL\n","OutputType":"frame"} -{"Time":"2026-10-26T19:40:29.770561979+01:00","Action":"output","Package":"example.com/subtests","Output":"FAIL\texample.com/subtests\t0.002s\n","OutputType":"frame"} -{"Time":"2026-10-26T19:40:29.770581629+01:00","Action":"fail","Package":"example.com/subtests","Elapsed":0.002} diff --git a/go/buildstep/conformance/captured/go/timeout.jsonl b/go/buildstep/conformance/captured/go/timeout.jsonl deleted file mode 100644 index 12a2c12..0000000 --- a/go/buildstep/conformance/captured/go/timeout.jsonl +++ /dev/null @@ -1,43 +0,0 @@ -{"Time":"2026-10-25T15:34:36.049580618+01:00","Action":"start","Package":"example.com/timeout"} -{"Time":"2026-10-25T15:34:36.053250986+01:00","Action":"run","Package":"example.com/timeout","Test":"TestQuick"} -{"Time":"2026-10-25T15:34:36.053354018+01:00","Action":"output","Package":"example.com/timeout","Test":"TestQuick","Output":"=== RUN TestQuick\n","OutputType":"frame"} -{"Time":"2026-10-25T15:34:36.053459789+01:00","Action":"output","Package":"example.com/timeout","Test":"TestQuick","Output":"--- PASS: TestQuick (0.00s)\n","OutputType":"frame"} -{"Time":"2026-10-25T15:34:36.05348499+01:00","Action":"pass","Package":"example.com/timeout","Test":"TestQuick","Elapsed":0} -{"Time":"2026-10-25T15:34:36.0535114+01:00","Action":"run","Package":"example.com/timeout","Test":"TestWaitsForever"} -{"Time":"2026-10-25T15:34:36.05351549+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"=== RUN TestWaitsForever\n","OutputType":"frame"} -{"Time":"2026-10-25T15:34:36.053567451+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":" p_test.go:11: waiting for the bus\n"} -{"Time":"2026-10-25T15:34:38.064693307+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"panic: test timed out after 2s\n"} -{"Time":"2026-10-25T15:34:38.06487122+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\trunning tests:\n"} -{"Time":"2026-10-25T15:34:38.06491684+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\t\tTestWaitsForever (2s)\n"} -{"Time":"2026-10-25T15:34:38.065034382+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\n"} -{"Time":"2026-10-25T15:34:38.065108573+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"goroutine 33 [running]:\n"} -{"Time":"2026-10-25T15:34:38.065154754+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"testing.(*M).startAlarm.func1()\n"} -{"Time":"2026-10-25T15:34:38.065386208+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\t/usr/lib/go/src/testing/testing.go:2961 +0x34a\n"} -{"Time":"2026-10-25T15:34:38.065431878+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"created by time.goFunc\n"} -{"Time":"2026-10-25T15:34:38.0655151+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\t/usr/lib/go/src/time/sleep.go:182 +0x2d\n"} -{"Time":"2026-10-25T15:34:38.06553471+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\n"} -{"Time":"2026-10-25T15:34:38.065847805+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"goroutine 1 [chan receive]:\n"} -{"Time":"2026-10-25T15:34:38.066038298+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"testing.(*T).Run(0x3a0209bc8008, {0x5580aa?, 0x3a0209a9caa0?}, 0x6d5b10)\n"} -{"Time":"2026-10-25T15:34:38.066047488+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\t/usr/lib/go/src/testing/testing.go:2268 +0x4f2\n"} -{"Time":"2026-10-25T15:34:38.066051368+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"testing.runTests.func1(0x3a0209bc8008)\n"} -{"Time":"2026-10-25T15:34:38.066056128+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\t/usr/lib/go/src/testing/testing.go:2744 +0x37\n"} -{"Time":"2026-10-25T15:34:38.066059478+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"testing.tRunner(0x3a0209bc8008, 0x3a0209a9cbc8)\n"} -{"Time":"2026-10-25T15:34:38.066064958+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\t/usr/lib/go/src/testing/testing.go:2195 +0xea\n"} -{"Time":"2026-10-25T15:34:38.066069599+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"testing.runTests({0x559017, 0x13}, {0x559017, 0x13}, 0x3a0209b8e0d8, {0x6f19f0, 0x2, 0x2}, {0xc2afa2bf832a3f56, 0x773908b5, ...})\n"} -{"Time":"2026-10-25T15:34:38.066083309+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\t/usr/lib/go/src/testing/testing.go:2742 +0x510\n"} -{"Time":"2026-10-25T15:34:38.066086879+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"testing.(*M).Run(0x3a0209b8c1e0)\n"} -{"Time":"2026-10-25T15:34:38.066090269+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\t/usr/lib/go/src/testing/testing.go:2602 +0x6af\n"} -{"Time":"2026-10-25T15:34:38.066093429+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"main.main()\n"} -{"Time":"2026-10-25T15:34:38.066097569+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\t_testmain.go:48 +0x9b\n"} -{"Time":"2026-10-25T15:34:38.066100639+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\n"} -{"Time":"2026-10-25T15:34:38.066103769+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"goroutine 20 [sleep]:\n"} -{"Time":"2026-10-25T15:34:38.066106969+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"time.Sleep(0xdf8475800)\n"} -{"Time":"2026-10-25T15:34:38.066110279+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\t/usr/lib/go/src/runtime/time.go:368 +0x165\n"} -{"Time":"2026-10-25T15:34:38.066113639+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"example.com/timeout.TestWaitsForever(0x3a0209bc8488?)\n"} -{"Time":"2026-10-25T15:34:38.066117109+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\t/tmp/fix-1901b/timeout/p_test.go:12 +0x48\n"} -{"Time":"2026-10-25T15:34:38.066120239+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"testing.tRunner(0x3a0209bc8488, 0x6d5b10)\n"} -{"Time":"2026-10-25T15:34:38.066123509+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\t/usr/lib/go/src/testing/testing.go:2195 +0xea\n"} -{"Time":"2026-10-25T15:34:38.066126839+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"created by testing.(*T).Run in goroutine 1\n"} -{"Time":"2026-10-25T15:34:38.066129989+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\t/usr/lib/go/src/testing/testing.go:2260 +0x4d4\n"} -{"Time":"2026-10-25T15:34:38.066986453+01:00","Action":"output","Package":"example.com/timeout","Output":"FAIL\texample.com/timeout\t2.017s\n","OutputType":"frame"} -{"Time":"2026-10-25T15:34:38.067002113+01:00","Action":"fail","Package":"example.com/timeout","Elapsed":2.017} diff --git a/go/buildstep/conformance/captured/node/crash.console b/go/buildstep/conformance/captured/node/crash.console deleted file mode 100644 index 90a8ef6..0000000 --- a/go/buildstep/conformance/captured/node/crash.console +++ /dev/null @@ -1,2 +0,0 @@ -npm notice run test -npm notice run node --test --experimental-strip-types 'test/*.test.ts' diff --git a/go/buildstep/conformance/captured/node/crash.tap b/go/buildstep/conformance/captured/node/crash.tap deleted file mode 100644 index bea3c65..0000000 --- a/go/buildstep/conformance/captured/node/crash.tap +++ /dev/null @@ -1,41 +0,0 @@ -TAP version 13 -# Subtest: passes before the crash -ok 1 - passes before the crash - --- - duration_ms: 0.746694 - type: 'test' - ... -# Subtest: throws after it returned -ok 2 - throws after it returned - --- - duration_ms: 0.763364 - type: 'test' - ... -# Subtest: waits after the throw -ok 3 - waits after the throw - --- - duration_ms: 49.953893 - type: 'test' - ... -# Error: Test "throws after it returned" at test/b.test.ts:2:1 generated asynchronous activity after the test ended. This activity created the error "Error: the bus closed under the test" and would have caused the test to fail, but instead triggered an uncaughtException event. -# Subtest: test/b.test.ts -not ok 2 - test/b.test.ts - --- - duration_ms: 155.864774 - type: 'test' - location: '/tmp/1901k/node/crash/test/b.test.ts:1:1' - failureType: 'testCodeFailure' - exitCode: 1 - signal: ~ - error: 'test failed' - code: 'ERR_TEST_FAILURE' - ... -1..4 -# tests 4 -# suites 0 -# pass 3 -# fail 1 -# cancelled 0 -# skipped 0 -# todo 0 -# duration_ms 168.479173 diff --git a/go/buildstep/conformance/captured/node/logcrash.console b/go/buildstep/conformance/captured/node/logcrash.console deleted file mode 100644 index 90a8ef6..0000000 --- a/go/buildstep/conformance/captured/node/logcrash.console +++ /dev/null @@ -1,2 +0,0 @@ -npm notice run test -npm notice run node --test --experimental-strip-types 'test/*.test.ts' diff --git a/go/buildstep/conformance/captured/node/logcrash.tap b/go/buildstep/conformance/captured/node/logcrash.tap deleted file mode 100644 index 89dbfb8..0000000 --- a/go/buildstep/conformance/captured/node/logcrash.tap +++ /dev/null @@ -1,42 +0,0 @@ -TAP version 13 -# retrying after Error: the store was not ready yet -# Subtest: passes and logs -ok 1 - passes and logs - --- - duration_ms: 1.184595 - type: 'test' - ... -# Subtest: throws after it returned -ok 2 - throws after it returned - --- - duration_ms: 0.748862 - type: 'test' - ... -# Subtest: waits after the throw -ok 3 - waits after the throw - --- - duration_ms: 51.019875 - type: 'test' - ... -# Error: Test "throws after it returned" at test/b.test.ts:2:1 generated asynchronous activity after the test ended. This activity created the error "Error: the bus closed under the test" and would have caused the test to fail, but instead triggered an uncaughtException event. -# Subtest: test/b.test.ts -not ok 2 - test/b.test.ts - --- - duration_ms: 172.91715 - type: 'test' - location: '/tmp/1901k/node/logcrash/test/b.test.ts:1:1' - failureType: 'testCodeFailure' - exitCode: 1 - signal: ~ - error: 'test failed' - code: 'ERR_TEST_FAILURE' - ... -1..4 -# tests 4 -# suites 0 -# pass 3 -# fail 1 -# cancelled 0 -# skipped 0 -# todo 0 -# duration_ms 185.744334 diff --git a/go/buildstep/conformance/captured/node/suite.console b/go/buildstep/conformance/captured/node/suite.console deleted file mode 100644 index 90a8ef6..0000000 --- a/go/buildstep/conformance/captured/node/suite.console +++ /dev/null @@ -1,2 +0,0 @@ -npm notice run test -npm notice run node --test --experimental-strip-types 'test/*.test.ts' diff --git a/go/buildstep/conformance/captured/node/suite.tap b/go/buildstep/conformance/captured/node/suite.tap deleted file mode 100644 index 06e9761..0000000 --- a/go/buildstep/conformance/captured/node/suite.tap +++ /dev/null @@ -1,61 +0,0 @@ -TAP version 13 -# Subtest: the seat - # Subtest: passes inside - ok 1 - passes inside - --- - duration_ms: 0.577396 - type: 'test' - ... - # Subtest: fails inside - not ok 2 - fails inside - --- - duration_ms: 1.621198 - type: 'test' - location: '/tmp/1901k/node/suite/test/a.test.ts:5:3' - failureType: 'testCodeFailure' - error: |- - Expected values to be strictly deep-equal: - + actual - expected - -  { - + holders: 2 - - holders: 1 -  } - - code: 'ERR_ASSERTION' - name: 'AssertionError' - expected: - holders: 1 - actual: - holders: 2 - operator: 'deepStrictEqual' - stack: |- - TestContext. (file:///tmp/1901k/node/suite/test/a.test.ts:5:37) - Test.runInAsyncScope (node:async_hooks:226:14) - Test.run (node:internal/test_runner/test:1402:25) - Suite.processPendingSubtests (node:internal/test_runner/test:974:18) - Test.postRun (node:internal/test_runner/test:1542:19) - Test.run (node:internal/test_runner/test:1467:12) - async Promise.all (index 0) - async Suite.run (node:internal/test_runner/test:1905:7) - async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) - ... - 1..2 -not ok 1 - the seat - --- - duration_ms: 3.295664 - type: 'suite' - location: '/tmp/1901k/node/suite/test/a.test.ts:3:1' - failureType: 'subtestsFailed' - error: '1 subtest failed' - code: 'ERR_TEST_FAILURE' - ... -1..1 -# tests 2 -# suites 1 -# pass 1 -# fail 1 -# cancelled 0 -# skipped 0 -# todo 0 -# duration_ms 115.948112 diff --git a/go/buildstep/conformance/captured/node/syntax.console b/go/buildstep/conformance/captured/node/syntax.console deleted file mode 100644 index 90a8ef6..0000000 --- a/go/buildstep/conformance/captured/node/syntax.console +++ /dev/null @@ -1,2 +0,0 @@ -npm notice run test -npm notice run node --test --experimental-strip-types 'test/*.test.ts' diff --git a/go/buildstep/conformance/captured/node/syntax.tap b/go/buildstep/conformance/captured/node/syntax.tap deleted file mode 100644 index ceadd12..0000000 --- a/go/buildstep/conformance/captured/node/syntax.tap +++ /dev/null @@ -1,47 +0,0 @@ -TAP version 13 -# Subtest: passes in the first file -ok 1 - passes in the first file - --- - duration_ms: 0.637465 - type: 'test' - ... -# node:internal/modules/run_main:111 -# triggerUncaughtException( -# ^ -# file:///tmp/1901k/node/syntax/test/b.test.ts:2 -# test('never loads', () => { -# SyntaxError [ERR_INVALID_TYPESCRIPT_SYNTAX]: Expected ',', got '' -#  at parseTypeScript (node:internal/modules/typescript:61:36) -#  at processTypeScriptCode (node:internal/modules/typescript:129:37) -#  at Object.stripTypeScriptModuleTypes (node:internal/modules/typescript:172:22) -#  at stripTypeScriptModuleTypes (node:internal/modules/esm/translators:35:49) -#  at ModuleLoader. (node:internal/modules/esm/translators:688:29) -#  at \#translate (node:internal/modules/esm/loader:441:20) -#  at afterLoad (node:internal/modules/esm/loader:509:29) -#  at ModuleLoader.loadAndTranslate (node:internal/modules/esm/loader:514:12) -#  at \#getOrCreateModuleJobAfterResolve (node:internal/modules/esm/loader:567:36) -#  at afterResolve (node:internal/modules/esm/loader:614:52) { -# code: 'ERR_INVALID_TYPESCRIPT_SYNTAX' -# } -# Node.js v26.12.1 -# Subtest: test/b.test.ts -not ok 2 - test/b.test.ts - --- - duration_ms: 87.629815 - type: 'test' - location: '/tmp/1901k/node/syntax/test/b.test.ts:1:1' - failureType: 'testCodeFailure' - exitCode: 1 - signal: ~ - error: 'test failed' - code: 'ERR_TEST_FAILURE' - ... -1..2 -# tests 2 -# suites 0 -# pass 1 -# fail 1 -# cancelled 0 -# skipped 0 -# todo 0 -# duration_ms 106.551435 diff --git a/go/buildstep/conformance/captured/node/timeout.console b/go/buildstep/conformance/captured/node/timeout.console deleted file mode 100644 index 90a8ef6..0000000 --- a/go/buildstep/conformance/captured/node/timeout.console +++ /dev/null @@ -1,2 +0,0 @@ -npm notice run test -npm notice run node --test --experimental-strip-types 'test/*.test.ts' diff --git a/go/buildstep/conformance/captured/node/timeout.tap b/go/buildstep/conformance/captured/node/timeout.tap deleted file mode 100644 index dd87e17..0000000 --- a/go/buildstep/conformance/captured/node/timeout.tap +++ /dev/null @@ -1,26 +0,0 @@ -TAP version 13 -# Subtest: passes before the wait -ok 1 - passes before the wait - --- - duration_ms: 0.683015 - type: 'test' - ... -# Subtest: waits for an answer that never comes -not ok 2 - waits for an answer that never comes - --- - duration_ms: 201.103775 - type: 'test' - location: '/tmp/1901k/node/timeout/test/a.test.ts:3:1' - failureType: 'testTimeoutFailure' - error: 'test timed out after 200ms' - code: 'ERR_TEST_FAILURE' - ... -1..2 -# tests 2 -# suites 0 -# pass 1 -# fail 0 -# cancelled 1 -# skipped 0 -# todo 0 -# duration_ms 308.920061 diff --git a/go/buildstep/conformance/captured/node/two-files.console b/go/buildstep/conformance/captured/node/two-files.console deleted file mode 100644 index 90a8ef6..0000000 --- a/go/buildstep/conformance/captured/node/two-files.console +++ /dev/null @@ -1,2 +0,0 @@ -npm notice run test -npm notice run node --test --experimental-strip-types 'test/*.test.ts' diff --git a/go/buildstep/conformance/captured/node/two-files.tap b/go/buildstep/conformance/captured/node/two-files.tap deleted file mode 100644 index 8e0a85b..0000000 --- a/go/buildstep/conformance/captured/node/two-files.tap +++ /dev/null @@ -1,49 +0,0 @@ -TAP version 13 -# Subtest: passes in the first file -ok 1 - passes in the first file - --- - duration_ms: 0.740124 - type: 'test' - ... -# judged against the seat as it stands -# Subtest: passes in the second file first -ok 2 - passes in the second file first - --- - duration_ms: 0.916463 - type: 'test' - ... -# Subtest: fails in the second file -not ok 3 - fails in the second file - --- - duration_ms: 1.719236 - type: 'test' - location: '/tmp/1901k/node/two-files/test/b.test.ts:4:1' - failureType: 'testCodeFailure' - error: |- - one plus one is not three - - 2 !== 3 - - code: 'ERR_ASSERTION' - name: 'AssertionError' - expected: 3 - actual: 2 - operator: 'strictEqual' - stack: |- - TestContext. (file:///tmp/1901k/node/two-files/test/b.test.ts:6:10) - Test.runInAsyncScope (node:async_hooks:226:14) - Test.run (node:internal/test_runner/test:1402:25) - Test.processPendingSubtests (node:internal/test_runner/test:974:18) - Test.postRun (node:internal/test_runner/test:1542:19) - Test.run (node:internal/test_runner/test:1467:12) - async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) - ... -1..3 -# tests 3 -# suites 0 -# pass 2 -# fail 1 -# cancelled 0 -# skipped 0 -# todo 0 -# duration_ms 135.931121 diff --git a/go/buildstep/conformance/captured/tsc/typeerror.console b/go/buildstep/conformance/captured/tsc/typeerror.console deleted file mode 100644 index ac7713e..0000000 --- a/go/buildstep/conformance/captured/tsc/typeerror.console +++ /dev/null @@ -1 +0,0 @@ -seat.ts(2,14): error TS2322: Type 'number' is not assignable to type 'string'. diff --git a/go/buildstep/conformance/conformance.go b/go/buildstep/conformance/conformance.go deleted file mode 100644 index 78596ff..0000000 --- a/go/buildstep/conformance/conformance.go +++ /dev/null @@ -1,201 +0,0 @@ -// Package conformance is the kit every build module's tests run (novox/hq issue 1901, its thorough fix): real output of -// the test runners, captured with the build nodes' own toolchains (captured/README.md), and what a build module must say -// of each failure in it under the failure contract (buildstep.Failure). A build module's test calls Run with its own parse -// path, the code that turns a runner's output into the failures of its test result; the kit fails the test when a -// failure is not named, its cause is missing or wrong, or a passing line stands in its place. -// -// A catalogue check (mesh-catalog checks/failurekit) refuses a build module with a test step whose tests do not call Run, -// and any build module whose tests do not call LeftRunning, the kit's case of steps a previous process left running -// (leftrunning.go, issue 1890). -package conformance - -import ( - "embed" - "fmt" - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/buildstep" -) - -// The runners the kit holds output of. -const ( - // GoTest is `go test -json -count=1 ./...`: Output.Stdout is its output (go1.27.3). - GoTest = "go test -json" - // NodeTest is a package's `npm test` running `node --test --experimental-strip-types` with - // NODE_OPTIONS=buildstep.NodeTestOptions(): Output.Report is the TAP file, Output.Stdout the script's own output - // and stderr, Output.Dir the package's directory as it was captured (node v26.12.1, npm 12.2.1). - NodeTest = "node --test" - // TypeCheck is tsc's type-check (`tsc --noEmit`, typescript 5.9.3): Output.Stdout is what it wrote; its failure - // names the test "type-check". - TypeCheck = "tsc" -) - -// Output is one captured run of a runner, as a build module's parse path takes it. -type Output struct { - Stdout, Stderr string - // Report is what a runner wrote to a report file of its own (node --test's TAP), "" for the others. - Report string - // Dir is the directory the run was captured in: a node test's file is said relative to it. - Dir string -} - -// Parse is a build module's own parse path: the failures its test result would carry for one run that failed. -type Parse func(Output) []buildstep.Failure - -// Case is one captured run and what its failure must say. -type Case struct { - Name string - Runner string - // Test is the failing test the failure names (for a package that stopped, the tests that were running; "" for none). - Test string - // File, when set, is where the failing test is. - File string - // Cause holds what the failure's cause must contain. - Cause []string - // Passing are the names of tests that passed in the run, and what they logged: no failure's words may hold them. - Passing []string -} - -//go:embed captured -var captured embed.FS - -// Cases are the kit's cases of a runner. -func Cases(runner string) []Case { - var out []Case - for _, c := range cases { - if c.Runner == runner { - out = append(out, c) - } - } - return out -} - -var cases = []Case{ - {Name: "passfirst", Runner: GoTest, Test: "TestTheSecondPackageFails", Cause: []string{"the seat has 2 holders, want 1"}, - Passing: []string{"TestTheFirstPackagePasses", "TestTheSecondPackagePassesFirst"}}, - {Name: "subtests", Runner: GoTest, Test: "TestTheTable/fails", Cause: []string{"got 3 machines, want 4"}, Passing: []string{"TestTheTable/passes"}}, - {Name: "logthenfatal", Runner: GoTest, Test: "TestThree", Cause: []string{"3 subscription(s) derive to a subject no emitter owns:"}}, - {Name: "panicking", Runner: GoTest, Test: "TestPanics", Cause: []string{"panic: assignment to entry in nil map"}}, - {Name: "timeout", Runner: GoTest, Test: "TestWaitsForever", Cause: []string{"panic: test timed out after 2s", "TestWaitsForever"}}, - {Name: "deeptimeout", Runner: GoTest, Test: "TestHoldsTheSeat, TestWaitsForTheBus", Cause: []string{"panic: test timed out after 2s", "TestHoldsTheSeat", "TestWaitsForTheBus"}}, - {Name: "buildfail", Runner: GoTest, Cause: []string{`cannot use "seat" (untyped string constant) as int value`}}, - {Name: "exiting", Runner: GoTest, Test: "TestExits", Cause: []string{"exited", "TestExits"}}, - {Name: "killed", Runner: GoTest, Test: "TestKilled", Cause: []string{"signal: killed"}}, - {Name: "passthenfail", Runner: GoTest, Cause: []string{"panic: the bus closed after the tests: nats: connection closed"}}, - {Name: "mainexit", Runner: GoTest, Cause: []string{"passed", "exited"}}, - {Name: "race", Runner: GoTest, Test: "TestRacesOnTheSeat", Cause: []string{"DATA RACE", "Write at", "example.com/race.TestRacesOnTheSeat.func1()", "p_test.go:13", "created at example.com/race.TestRacesOnTheSeat()"}}, - {Name: "subpanic", Runner: GoTest, Test: "TestTable/panics", Cause: []string{"panic: assignment to entry in nil map"}, Passing: []string{"TestTable/passes"}}, - {Name: "racemain", Runner: GoTest, Cause: []string{"DATA RACE", "Write at", "example.com/racemain.TestMain.func1()", "p_test.go:15", "created at example.com/racemain.TestMain()"}}, - - {Name: "two-files", Runner: NodeTest, Test: "fails in the second file", File: "test/b.test.ts:4:1", Cause: []string{"one plus one is not three"}, - Passing: []string{"passes in the first file", "passes in the second file first"}}, - {Name: "suite", Runner: NodeTest, Test: "fails inside", File: "test/a.test.ts:5:3", Cause: []string{"Expected values to be strictly deep-equal:"}, - Passing: []string{"passes inside"}}, - {Name: "crash", Runner: NodeTest, Test: "test/b.test.ts", File: "test/b.test.ts:1:1", Cause: []string{"the bus closed under the test"}, - Passing: []string{"passes before the crash"}}, - {Name: "logcrash", Runner: NodeTest, Test: "test/b.test.ts", File: "test/b.test.ts:1:1", Cause: []string{"the bus closed under the test"}, - Passing: []string{"passes and logs", "retrying after Error: the store was not ready yet"}}, - {Name: "timeout", Runner: NodeTest, Test: "waits for an answer that never comes", File: "test/a.test.ts:3:1", Cause: []string{"test timed out after 200ms"}, - Passing: []string{"passes before the wait"}}, - {Name: "syntax", Runner: NodeTest, Test: "test/b.test.ts", File: "test/b.test.ts:1:1", Cause: []string{"SyntaxError", "Expected ',', got ''"}, - Passing: []string{"passes in the first file"}}, - - {Name: "typeerror", Runner: TypeCheck, Test: "type-check", Cause: []string{"seat.ts(2,14): error TS2322: Type 'number' is not assignable to type 'string'."}}, -} - -// Load is the captured output of a case. -func Load(c Case) (Output, error) { - switch c.Runner { - case GoTest: - b, err := captured.ReadFile("captured/go/" + c.Name + ".jsonl") - return Output{Stdout: string(b)}, err - case NodeTest: - report, err := captured.ReadFile("captured/node/" + c.Name + ".tap") - if err != nil { - return Output{}, err - } - console, err := captured.ReadFile("captured/node/" + c.Name + ".console") - return Output{Report: string(report), Stdout: string(console), Dir: "/tmp/1901k/node/" + c.Name}, err - default: - b, err := captured.ReadFile("captured/tsc/" + c.Name + ".console") - return Output{Stdout: string(b), Dir: "/tmp/1901k/tsc/" + c.Name}, err - } -} - -// Run runs every case of runner through parse, each as a subtest, and fails a case whose failure does not keep the -// contract: exactly one failure, naming the failing test (and its file, where the runner says it), with a cause that -// holds what the case says and is no passing line, an output that starts with the cause, stays within its bound and -// holds no passing line and no passing test's name. -func Run(t *testing.T, runner string, parse Parse) { - t.Helper() - cs := Cases(runner) - if len(cs) == 0 { - t.Fatalf("the kit holds no case of the runner %q", runner) - } - for _, c := range cs { - t.Run(c.Name, func(t *testing.T) { - out, err := Load(c) - if err != nil { - t.Fatal(err) - } - for _, problem := range Judge(c, parse(out)) { - t.Error(problem) - } - }) - } -} - -// Judge says what is wrong with the failures a parse path made of a case; nothing when they keep the contract. -func Judge(c Case, failed []buildstep.Failure) []string { - var problems []string - say := func(s string) { problems = append(problems, c.Runner+" "+c.Name+": "+s) } - if len(failed) != 1 { - say(fmt.Sprintf("want one failure, got %d: %s", len(failed), describe(failed))) - if len(failed) == 0 { - return problems - } - } - f := failed[0] - if f.Test != c.Test { - say("the failure names the test " + quote(f.Test) + ", want " + quote(c.Test)) - } - if c.File != "" && f.File != c.File { - say("the failure's file is " + quote(f.File) + ", want " + quote(c.File)) - } - if why := buildstep.Hidden(f); why != "" { - say("the failure hides its cause: " + why) - } - for _, want := range c.Cause { - if !strings.Contains(f.Cause, want) { - say("the cause " + quote(f.Cause) + " does not hold " + quote(want)) - } - } - if first := strings.SplitN(f.Output, "\n", 2)[0]; strings.TrimSpace(first) != strings.TrimSpace(f.Cause) { - say("the output starts with " + quote(first) + ", not with the cause") - } - if len(f.Output) > buildstep.OutputBytes+buildstep.LineBytes+64 { - say(fmt.Sprintf("the output holds %d bytes, over its bound", len(f.Output))) - } - for _, l := range strings.Split(f.Output, "\n") { - if buildstep.PassingLine(l) { - say("the output keeps the passing line " + quote(l)) - } - } - for _, p := range c.Passing { - if strings.Contains(f.Output, p+"\n") || strings.HasSuffix(f.Output, p) || strings.Contains(f.Cause, p) || strings.Contains(f.Output, "- "+p) { - say("the failure's words hold the passing test " + quote(p)) - } - } - return problems -} - -func describe(failed []buildstep.Failure) string { - var s []string - for _, f := range failed { - s = append(s, quote(f.Package+" "+f.Test)+" ("+quote(f.Cause)+")") - } - return strings.Join(s, ", ") -} - -func quote(s string) string { return fmt.Sprintf("%q", s) } diff --git a/go/buildstep/conformance/conformance_test.go b/go/buildstep/conformance/conformance_test.go deleted file mode 100644 index e6380e4..0000000 --- a/go/buildstep/conformance/conformance_test.go +++ /dev/null @@ -1,167 +0,0 @@ -package conformance - -import ( - "context" - "encoding/json" - "strings" - "testing" - "time" - - "git.novox.be/novox/mesh-sdk/go/buildstep" -) - -// The failure contract (issue 1901): the SDK's own parsers keep it on every captured case of the kit (1901/AC6); the -// kit refuses what the build modules sent before it; the contract's own rules; and the Go reader's older guarantees. -func TestTheFailureContract(t *testing.T) { - t.Run("the SDK parsers keep it", func(t *testing.T) { - Run(t, GoTest, func(o Output) []buildstep.Failure { return buildstep.ReadGoTest(o.Stdout, ".").Failed }) - Run(t, NodeTest, func(o Output) []buildstep.Failure { return buildstep.ReadTAP(".", "test", o.Dir, o.Report, o.Stdout) }) - Run(t, TypeCheck, func(o Output) []buildstep.Failure { - return []buildstep.Failure{buildstep.ConsoleFailure(".", "type-check", o.Stdout+o.Stderr)} - }) - }) - - // 1890/AC7: the SDK's start sweep keeps the left-running case, and the kit refuses a sweep that ends nothing or ends - // everything. - t.Run("the left-running case", func(t *testing.T) { - LeftRunning(t, func(ctx context.Context, scan buildstep.Scan, node string, before time.Time, logf func(string, ...any)) { - buildstep.EndStaleAtStart(ctx, scan, node, before, logf) - }) - if p := JudgeLeftRunning(func(context.Context, buildstep.Scan, string, time.Time, func(string, ...any)) {}); len(p) != 11 { - t.Errorf("a sweep that ends nothing: %d problems, want 11 (three steps not ended and unsaid, one owner unnamed, two asks not deleted and unsaid):\n%s", len(p), strings.Join(p, "\n")) - } - wrongNode := func(ctx context.Context, scan buildstep.Scan, _ string, before time.Time, logf func(string, ...any)) { - buildstep.EndStaleAtStart(ctx, scan, "kitother", before, logf) - } - if p := JudgeLeftRunning(wrongNode); len(p) == 0 { - t.Error("a sweep of another node's steps is not refused") - } - }) - - // node's output cut to its first fifteen lines with no cause, whose first line was a passing test of the first file - // (mesh-build-node's lines() before the contract), and a Go failure keeping its first line. - t.Run("the kit refuses a failure that hides its cause", func(t *testing.T) { - firstLines := func(s string) string { - var keep []string - for _, l := range strings.Split(s, "\n") { - if l = strings.TrimSpace(l); l != "" && len(keep) < 15 { - keep = append(keep, l) - } - } - return strings.Join(keep, "\n") - } - for _, c := range []struct { - runner, name string - old func(Output) []buildstep.Failure - }{ - {NodeTest, "two-files", func(o Output) []buildstep.Failure { - return []buildstep.Failure{{Package: ".", Test: "test", Output: firstLines(o.Report)}} - }}, - {GoTest, "passfirst", func(o Output) []buildstep.Failure { - return []buildstep.Failure{{Package: "example.com/passfirst/b", Test: "TestTheSecondPackageFails", Cause: "ok \texample.com/passfirst/a", Output: "--- PASS: TestTheSecondPackagePassesFirst"}} - }}, - } { - for _, k := range Cases(c.runner) { - if k.Name != c.name { - continue - } - out, err := Load(k) - if err != nil { - t.Fatal(err) - } - if problems := Judge(k, c.old(out)); len(problems) == 0 { - t.Errorf("%s %s: the kit passed a failure that hides its cause", c.runner, c.name) - } - } - } - }) - - // What a passing line is (a test's own line starting "ok " is none), when a failure hides its cause, and that a long - // failure keeps its last lines within the bound and starts with a real line for an older checker, which reads only - // the first line of the output cut to 120 characters (1901/AC1, AC3). - t.Run("its rules", func(t *testing.T) { - for _, c := range []struct { - line string - passing bool - }{ - {"✔ the same merge has the same id (8.0ms)", true}, {"ok 1 - passes", true}, {" ok 3 - inside", true}, - {"ok \texample.com/a\t0.01s", true}, {"PASS", true}, {"--- PASS: TestA (0.00s)", true}, - {" --- PASS: TestA/sub (0.00s)", true}, {"✖ fails (1ms)", false}, {"not ok 2 - fails", false}, - {"okay, the seat moved", false}, {"ok then: the store refused the write", false}, {"ok the seat", false}, {"FAIL", false}, - } { - if buildstep.PassingLine(c.line) != c.passing { - t.Errorf("PassingLine(%q) = %v, want %v", c.line, !c.passing, c.passing) - } - } - if buildstep.Hidden(buildstep.Failure{Cause: "the seat has 2 holders"}) != "" || buildstep.Hidden(buildstep.Failure{}) == "" || - buildstep.Hidden(buildstep.Failure{Cause: "✔ passes (1ms)"}) == "" { - t.Error("Hidden does not tell a real cause from none and from a passing line") - } - var lines []string - for i := 0; i < 400; i++ { - lines = append(lines, " x_test.go:1: log line "+strings.Repeat("y", 40)) - } - lines = append(lines, " x_test.go:9: the real cause", "--- FAIL: TestX (0.00s)") - oldFirst := func(out string) string { // the older checker's rule (judge.go firstLine before issue 1901) - for _, l := range strings.Split(out, "\n") { - if l = strings.TrimSpace(l); l != "" { - return l[:min(len(l), 120)] - } - } - return "" - } - for _, cause := range []string{"the real cause", ""} { - out := buildstep.Clip(lines, cause) - if len(out) > buildstep.OutputBytes+buildstep.LineBytes { - t.Errorf("1901/AC1: kept %d bytes, bound is %d", len(out), buildstep.OutputBytes) - } - if !strings.HasSuffix(out, "the real cause\n--- FAIL: TestX (0.00s)") || !strings.Contains(out, "\n... (") { - t.Errorf("1901/AC1: the last lines or the marker are lost: %q...%q", out[:40], out[len(out)-60:]) - } - if got := oldFirst(out); strings.HasPrefix(got, "... (") || got == "" || (cause != "" && got != cause) { - t.Errorf("1901/AC3: cause %q: an older checker would read %q", cause, got) - } - } - }) - - // The Go reader (moved from mesh-build-go): an older checker, which knows no "cause" field, still reads a failure and - // gets at least the lines it kept (1901/AC3); a test's indented log line saying "panic:" is not the cause, go's own at - // column 0 is (1901/AC4); and each package counts its own skipped tests (issue 890). - t.Run("the Go reader", func(t *testing.T) { - read := func(name string) string { - raw, err := captured.ReadFile("captured/go/" + name + ".jsonl") - if err != nil { - t.Fatal(err) - } - return string(raw) - } - b, _ := json.Marshal(buildstep.ReadGoTest(read("logthenfatal"), ".").Failed) - var old []struct { - Output string `json:"output,omitempty"` - } - if err := json.Unmarshal(b, &old); err != nil || len(old) != 1 || !strings.Contains(old[0].Output, "judged against") || !strings.Contains(old[0].Output, "3 subscription(s)") { - t.Errorf("1901/AC3: an older checker reads %+v, %v", old, err) - } - lines := strings.SplitAfter(read("deeptimeout"), "\n") - added := `{"Action":"output","Package":"example.com/deeptimeout","Test":"TestHoldsTheSeat","Output":" p_test.go:9: the child said:\n"}` + "\n" + - `{"Action":"output","Package":"example.com/deeptimeout","Test":"TestHoldsTheSeat","Output":" panic: child retried and recovered\n"}` + "\n" + - `{"Action":"output","Package":"example.com/deeptimeout","Test":"TestHoldsTheSeat","Output":" WARNING: DATA RACE\n"}` + "\n" - if got := buildstep.ReadGoTest(strings.Join(lines[:10], "")+added+strings.Join(lines[10:], ""), ".").Failed; len(got) != 1 || !strings.HasPrefix(got[0].Cause, "panic: test timed out after 2s") { - t.Errorf("1901/AC4: failures = %+v, want the timeout's panic as the cause", got) - } - res := buildstep.ReadGoTest(`{"Action":"run","Package":"m/a","Test":"TestA"} -{"Action":"skip","Package":"m/a","Test":"TestA","Elapsed":0} -{"Action":"run","Package":"m/b","Test":"TestB"} -{"Action":"pass","Package":"m/b","Test":"TestB","Elapsed":0} -{"Action":"pass","Package":"m/a","Elapsed":0} -{"Action":"pass","Package":"m/b","Elapsed":0} -`, ".") - got := map[string]int{} - for _, p := range res.Packages { - got[p.Package] = p.Skipped - } - if res.Skipped != 1 || got["m/a"] != 1 || got["m/b"] != 0 { - t.Errorf("skipped = %d, per package %v, want 1 with m/a 1 and m/b 0", res.Skipped, got) - } - }) -} diff --git a/go/buildstep/conformance/leftrunning.go b/go/buildstep/conformance/leftrunning.go deleted file mode 100644 index a8812c4..0000000 --- a/go/buildstep/conformance/leftrunning.go +++ /dev/null @@ -1,212 +0,0 @@ -package conformance - -import ( - "context" - "encoding/json" - "fmt" - "sort" - "strings" - "sync" - "testing" - "time" - - stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/buildstep" - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -// **A step left running by a previous process** (novox/hq issue 1890, note 21): when a delivery replaced mesh-build-go -// on a node while a check ask ran, the new process's start sweep let the ask's key stand as running, and it held one -// of the node's build slots until a hand end. Every build module runs this case (LeftRunning) through the start sweep -// its main runs: the hand-started steps a previous process left are ended at once and loud, its asks' keys deleted (the -// stream redelivers the ask, novox/hq ADR 0525 §6, and its asker keeps waiting for that), what this process runs kept. A -// catalogue check (mesh-catalog checks/failurekit) refuses a build module whose tests do not call LeftRunning. - -// Sweep is a build module's own start sweep, as its main runs it once at its start: it ends node's steps in scan that -// no process of the module runs any more (buildstep.EndStaleAtStart, here with the runtime already serving), before -// being the start of the process, saying each end with logf. -type Sweep func(ctx context.Context, scan buildstep.Scan, node string, before time.Time, logf func(string, ...any)) - -// LeftNode is the node the case's steps are of. -const LeftNode = "kitnode" - -// leftCase is one step in the steps state before the sweep, and whether the sweep must end it. -type leftCase struct { - id, tool, owner, state string - node string - age time.Duration // started this long before the process began (negative: after it) - ended bool - deleted bool // a gone process's ask: its key is deleted, not ended -} - -// gone is a previous process's owner on the same node. -var gone = "b00t-of-the-kit.4242.mvp0000" - -func leftCases() []leftCase { - return []leftCase{ - {id: "mvpwlkzi7e6ca8", tool: "check ask", owner: gone, age: 5 * time.Minute, deleted: true}, // note 21's ghost - {id: "mvpwlkzi7e6ca9", tool: "delivery ask", owner: gone, age: -time.Second, deleted: true}, - {id: "gonehand1", tool: "build_x_test", owner: gone, age: time.Minute, ended: true}, - {id: "oldhand1", tool: "build_x_fetch", age: time.Hour, ended: true}, // no owner: an older build's - {id: "mvpwlkzi7e6caa", tool: "check ask", age: time.Hour, ended: true}, // no owner, past the bound - {id: "mvpwlkzi7e6cab", tool: "check ask", age: 5 * time.Minute}, // no owner, the stream's still - {id: "mvpwlkzi7e6cac", tool: "check ask", owner: buildstep.Owner, age: time.Minute}, // this process's - {id: "minehand1", tool: "build_x_test", owner: buildstep.Owner, age: -time.Second}, - {id: "donehand1", tool: "build_x_test", owner: gone, age: time.Hour, state: delivery.StepPassed}, - {id: "otherhand1", tool: "build_x_test", owner: gone, age: time.Hour, node: "kitother"}, - } -} - -// LeftRunning runs the case through sweep and fails the test for each step the sweep ended or kept wrongly, or ended -// without saying it. -func LeftRunning(t *testing.T, sweep Sweep) { - t.Helper() - for _, problem := range JudgeLeftRunning(sweep) { - t.Error(problem) - } -} - -// JudgeLeftRunning says what is wrong with what sweep did to the case; nothing when it keeps the rule. -func JudgeLeftRunning(sweep Sweep) []string { - st := &steps{vals: map[string][]byte{}, revs: map[string]uint64{}} - before := time.Now() - for _, c := range leftCases() { - node, state := c.node, c.state - if node == "" { - node = LeftNode - } - if state == "" { - state = delivery.StepRunning - } - key, _ := delivery.StepKey(node, c.id) - raw, err := delivery.EncodeStep(delivery.Step{ID: c.id, Tool: c.tool, Node: node, State: state, Started: before.Add(-c.age).UTC(), Owner: c.owner}) - if err != nil { - return []string{"the kit's step " + c.id + ": " + err.Error()} - } - st.set(key, raw) - } - served := make(chan struct{}) - close(served) - was := buildstep.StaleServed - buildstep.StaleServed = func() <-chan struct{} { return served } - defer func() { buildstep.StaleServed = was }() - var mu sync.Mutex - var said []string - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - sweep(ctx, st.scan(), LeftNode, before, func(f string, a ...any) { - mu.Lock() - defer mu.Unlock() - said = append(said, fmt.Sprintf(f, a...)) - }) - mu.Lock() - log := strings.Join(said, "\n") - mu.Unlock() - var problems []string - for _, c := range leftCases() { - node := c.node - if node == "" { - node = LeftNode - } - key, _ := delivery.StepKey(node, c.id) - raw := st.get(key) - if c.deleted { - if raw != nil { - problems = append(problems, fmt.Sprintf("left running: the ask %s of the gone owner %q is still held after the sweep (%s); "+ - "its key is deleted, never ended: the stream redelivers it (ADR 0525 §6)", key, c.owner, raw)) - } - if !strings.Contains(log, key) || !strings.Contains(log, c.owner) { - problems = append(problems, "left running: the delete of "+key+" is not said with its owner "+c.owner) - } - continue - } - if raw == nil { - problems = append(problems, "left running: the step "+key+" is gone after the sweep, want it kept or ended") - continue - } - s, err := delivery.DecodeStep(raw) - if err != nil { - problems = append(problems, key+": "+err.Error()) - continue - } - want := c.state - if want == "" { - want = delivery.StepRunning - } - if c.ended { - want = delivery.StepFailed - } - if s.State != want { - problems = append(problems, fmt.Sprintf("left running: the step %s (%s, owner %q, %s before the start) is %s after the sweep, want %s", key, c.tool, c.owner, c.age, s.State, want)) - } - if c.ended && !strings.Contains(log, key) { - problems = append(problems, "left running: the end of "+key+" is not said") - } - if c.ended && c.owner != "" && !strings.Contains(log, c.owner) { - problems = append(problems, "left running: the end of "+key+" does not name its owner "+c.owner) - } - } - sort.Strings(problems) - return problems -} - -// steps is a steps state with revisions, whose Update lands only at the revision read (stdio.ErrChanged otherwise). -type steps struct { - mu sync.Mutex - vals map[string][]byte - revs map[string]uint64 -} - -func (s *steps) set(key string, raw []byte) { - s.mu.Lock() - defer s.mu.Unlock() - s.vals[key], s.revs[key] = raw, s.revs[key]+1 -} - -func (s *steps) get(key string) []byte { - s.mu.Lock() - defer s.mu.Unlock() - return s.vals[key] -} - -func (s *steps) scan() buildstep.Scan { - return buildstep.Scan{ - Keys: func() ([]string, error) { - s.mu.Lock() - defer s.mu.Unlock() - var out []string - for k := range s.vals { - out = append(out, k) - } - return out, nil - }, - Get: func(key string) (json.RawMessage, uint64, error) { - s.mu.Lock() - defer s.mu.Unlock() - return s.vals[key], s.revs[key], nil - }, - Delete: func(key string, revision uint64) error { - s.mu.Lock() - defer s.mu.Unlock() - if s.revs[key] != revision { - return stdio.ErrChanged - } - delete(s.vals, key) - s.revs[key]++ - return nil - }, - Update: func(key string, value any, revision uint64) (uint64, error) { - raw, err := json.Marshal(value) - if err != nil { - return 0, err - } - s.mu.Lock() - defer s.mu.Unlock() - if s.revs[key] != revision { - return 0, stdio.ErrChanged - } - s.vals[key], s.revs[key] = raw, revision+1 - return revision + 1, nil - }, - } -} diff --git a/go/buildstep/cpu.go b/go/buildstep/cpu.go deleted file mode 100644 index 39b5e3e..0000000 --- a/go/buildstep/cpu.go +++ /dev/null @@ -1,14 +0,0 @@ -package buildstep - -import ( - "os" - - "git.novox.be/novox/mesh-sdk/go/buildcpu" -) - -// NodeLimits is what one step of a build module is held to (novox/hq ADR 0553 §4): the node's processor share from the -// word the composer gives the module (buildcpu.ShareWord), divided by the module's own capacity, own, over the machine's -// processors. Without the word, no thread limit: Threads is 0. -func NodeLimits(own int) buildcpu.Limits { - return buildcpu.FromWords(os.Getenv, own) -} diff --git a/go/buildstep/digestshape_test.go b/go/buildstep/digestshape_test.go deleted file mode 100644 index aa2b757..0000000 --- a/go/buildstep/digestshape_test.go +++ /dev/null @@ -1,234 +0,0 @@ -package buildstep - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "sort" - "strings" - "sync" - "testing" - "time" - - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -// credentialField is the node runtime's rule for a state value (mesh-tools node-tools/internal/bus/state.go, novox/hq -// ADR 0201), copied whole: the first field anywhere in the value whose name, lower-cased and without - _ and spaces, ends -// in a credential word, or "". A step's end is refused when it answers a field. -func credentialField(value json.RawMessage) string { - endings := []string{"password", "passwd", "secret", "token", "credential", "credentials", - "authorization", "apikey", "privatekey", "accesskey", "cookie"} - var v any - if json.Unmarshal(value, &v) != nil { - return "" - } - var walk func(any) string - walk = func(v any) string { - switch t := v.(type) { - case map[string]any: - keys := make([]string, 0, len(t)) - for k := range t { - keys = append(keys, k) - } - sort.Strings(keys) - for _, k := range keys { - norm := strings.NewReplacer("-", "", "_", "", " ", "").Replace(strings.ToLower(k)) - for _, end := range endings { - if strings.HasSuffix(norm, end) { - return k - } - } - if found := walk(t[k]); found != "" { - return found - } - } - case []any: - for _, x := range t { - if found := walk(x); found != "" { - return found - } - } - } - return "" - } - return walk(v) -} - -// runtimeState is a steps state that refuses a value as the node runtime does, and keeps every value it took. -type runtimeState struct { - mu sync.Mutex - took map[string][]delivery.Step - refused int -} - -func newRuntimeState() *runtimeState { return &runtimeState{took: map[string][]delivery.Step{}} } - -func (s *runtimeState) put(key string, value any) (uint64, error) { - raw, _ := value.(json.RawMessage) - if f := credentialField(raw); f != "" { - s.mu.Lock() - s.refused++ - s.mu.Unlock() - return 0, fmt.Errorf("steps.%s carries a field %q, which names a credential", key, f) - } - st, err := delivery.DecodeStep(raw) - if err != nil { - return 0, err - } - s.mu.Lock() - defer s.mu.Unlock() - s.took[key] = append(s.took[key], st) - return uint64(len(s.took[key])), nil -} - -func (s *runtimeState) last(key string) (delivery.Step, bool) { - s.mu.Lock() - defer s.mu.Unlock() - t := s.took[key] - if len(t) == 0 { - return delivery.Step{}, false - } - return t[len(t)-1], true -} - -func fastEnds(t *testing.T) { - attempts, retry := EndAttempts, EndRetry - EndAttempts, EndRetry = 2, time.Millisecond - t.Cleanup(func() { EndAttempts, EndRetry = attempts, retry }) -} - -// 1930/AC1: given a check of a repository with the package .../internal/token, when its ask's answer is stored as the -// step's end, then no field is keyed by a package path: the runtime's credential rule takes the value, and the package -// and its digest read back from the stored end. -func TestADigestOfAPackageNamedTokenIsStoredAsAList(t *testing.T) { - fastEnds(t) - r := &recorder{ - fetch: map[string]any{"workspace": "/w"}, - digests: map[string]string{"x/internal/token": "d-token", "x/pkg/a": "d-a"}, - tests: map[string]bool{"x/internal/token": true, "x/pkg/a": true}, - } - out, err := RunAsk(context.Background(), r.steps(), ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit}, - Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}})) - if err != nil { - t.Fatal(err) - } - old, _ := json.Marshal(map[string]any{"levels": []any{map[string]any{"level": "unit", "digest": map[string]any{"packages": r.digests}}}}) - if credentialField(old) != "x/internal/token" { - t.Fatalf("the rule does not refuse the form before issue 1930 (%s): the test proves nothing", old) - } - st := newRuntimeState() - if err := finish(st.put, "shanks.a1", delivery.Step{ID: "a1", Tool: "check ask", Node: "shanks", State: delivery.StepRunning, Started: time.Now()}, out, nil, t.Logf); err != nil { - t.Fatal(err) - } - s, ok := st.last("shanks.a1") - if !ok || s.State != delivery.StepPassed || st.refused != 0 { - t.Fatalf("the end %+v (written %v, refused %d), want it passed and taken at once", s, ok, st.refused) - } - var a delivery.CheckAnswer - if err := json.Unmarshal(s.Result, &a); err != nil { - t.Fatal(err) - } - if len(a.Levels) != 1 || a.Levels[0].Digest == nil { - t.Fatalf("levels %+v, want the unit level with its digest", a.Levels) - } - want := []delivery.PackageDigest{{Package: "x/internal/token", Digest: "d-token"}, {Package: "x/pkg/a", Digest: "d-a"}} - if got := a.Levels[0].Digest.Packages; fmt.Sprint(got) != fmt.Sprint(want) { - t.Errorf("packages %v, want %v", got, want) - } - if !strings.Contains(string(s.Result), `"package":"x/internal/token"`) { - t.Errorf("the stored end does not name the package as a value: %s", s.Result) - } -} - -// 1930/AC2: given an end the state refuses every time, when finish writes it, then it logs the refusal and writes a small -// failed end without the result that names the write's error; and when no end can be written at all it answers an error. -func TestARefusedEndIsSaidAndEndsTheStepFailed(t *testing.T) { - fastEnds(t) - big := errors.New("the value carries a field \"x/internal/token\", which names a credential") - var refused int - var stored []delivery.Step - put := func(key string, value any) (uint64, error) { - st, _ := delivery.DecodeStep(value.(json.RawMessage)) - if len(st.Result) > 0 { - refused++ - return 0, big - } - stored = append(stored, st) - return 1, nil - } - var logged []string - logf := func(format string, args ...any) { logged = append(logged, fmt.Sprintf(format, args...)) } - err := finish(put, "shanks.a2", delivery.Step{ID: "a2", Tool: "check ask", Node: "shanks", State: delivery.StepRunning, Started: time.Now()}, map[string]any{"levels": 1}, nil, logf) - if err != nil { - t.Fatalf("finish answered %v, want the small end written", err) - } - if refused != EndAttempts || len(stored) != 1 { - t.Fatalf("refused %d, stored %d, want %d refusals and one small end", refused, len(stored), EndAttempts) - } - s := stored[0] - if s.State != delivery.StepFailed || len(s.Result) != 0 || !strings.Contains(s.Reason, "could not be written") || - !strings.Contains(s.Reason, big.Error()) || !strings.Contains(s.Reason, "passed") { - t.Errorf("the small end %+v, want failed, no result, naming the write's error and that it passed", s) - } - if len(logged) == 0 || !strings.Contains(logged[0], big.Error()) { - t.Errorf("logged %q, want the refusal said", logged) - } - none := func(string, any) (uint64, error) { return 0, errors.New("the bus is gone") } - if err := finish(none, "shanks.a3", delivery.Step{ID: "a3", Tool: "check ask", Node: "shanks", Started: time.Now()}, nil, nil, logf); err == nil || !strings.Contains(err.Error(), "the bus is gone") { - t.Errorf("finish with no write possible answered %v, want the error", err) - } -} - -// 1930/AC2: given an ask whose end the state refuses, when it runs, then the ask is acknowledged only once its small -// failed end is written; when no end can be written at all, it is given back, never acknowledged as done. -func TestAnAskWhoseEndCannotBeWrittenIsNotAcked(t *testing.T) { - fastEnds(t) - for _, tc := range []struct { - name string - smallOK bool - wantAcked bool - }{{"the small end written", true, true}, {"no end written", false, false}} { - t.Run(tc.name, func(t *testing.T) { - src := NewFakeAsks() - a := testAsk(delivery.AskCheck) - _ = src.PublishAsk(a) - var mu sync.Mutex - var ends []delivery.Step - put := func(key string, value any) (uint64, error) { - st, err := delivery.DecodeStep(value.(json.RawMessage)) - if err != nil { - return 0, err - } - if st.State != delivery.StepRunning && (len(st.Result) > 0 || !tc.smallOK) { - return 0, errors.New("refused") - } - mu.Lock() - ends = append(ends, st) - mu.Unlock() - return 1, nil - } - p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: put, Capacity: func() int { return 1 }, - PullWait: 10 * time.Millisecond, Logf: t.Logf, - Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { return map[string]any{"ran": true}, nil }} - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - go p.Pull(ctx) - waitFor(t, "the ask's fate", func() bool { ac, tm, nk := src.Fates(); return len(ac)+len(tm)+len(nk) > 0 }) - cancel() - acked, termed, naked := src.Fates() - if tc.wantAcked && (len(acked) != 1 || len(naked) != 0) || !tc.wantAcked && (len(acked) != 0 || len(naked) == 0) || len(termed) != 0 { - t.Fatalf("acked %v naked %v termed %v", acked, naked, termed) - } - mu.Lock() - defer mu.Unlock() - if tc.smallOK { - last := ends[len(ends)-1] - if last.State != delivery.StepFailed || !strings.Contains(last.Reason, "refused") { - t.Errorf("the end %+v, want the small failed end", last) - } - } - }) - } -} diff --git a/go/buildstep/doc.go b/go/buildstep/doc.go new file mode 100644 index 0000000..93c1feb --- /dev/null +++ b/go/buildstep/doc.go @@ -0,0 +1,5 @@ +// Package buildstep is the contract of a build step every delivery part reads (novox/hq ADR 0593 §1): a step's id, the +// fetch request, a step a previous process left running and its end, a failure's words, a module tree's shape, the +// capacity setting and the launchers' names. The runner the build modules share is the Go build module's own package +// (mesh-catalog modules/mesh-build-go/buildstep), which imports this one. +package buildstep diff --git a/go/buildstep/failure.go b/go/buildstep/failure.go index 78c27af..bdea558 100644 --- a/go/buildstep/failure.go +++ b/go/buildstep/failure.go @@ -1,11 +1,5 @@ package buildstep -// The failure contract of a build step's test level (novox/hq issue 1901, its thorough fix): what a build module says of -// each test that failed, the same for every language. Each build module used to parse its own test runner's output, and -// a fix in one never reached the others: a failed Go test kept a t.Logf line, a failed node --test run kept a passing -// '✔' line of its first file. The parsers that fill the contract live here (gotest.go, tap.go) and the conformance kit -// (package conformance) holds every build module to them with captured runner output. - import ( "fmt" "strings" diff --git a/go/buildstep/fetch.go b/go/buildstep/fetch.go deleted file mode 100644 index 4135bdb..0000000 --- a/go/buildstep/fetch.go +++ /dev/null @@ -1,196 +0,0 @@ -package buildstep - -import ( - "context" - "fmt" - "net/url" - "os" - "path/filepath" - "regexp" - "strings" - - "git.novox.be/novox/mesh-sdk/go/forgekey" -) - -// Option changes FetchCommit. -type Option func(*fetchOptions) - -type fetchOptions struct { - allowFile bool - mirror string -} - -// AllowFileRemote lets FetchCommit take a file:// remote. It is for a test's local repository; production -// code does not pass it, and FetchCommit then refuses anything but ssh://. -func AllowFileRemote() Option { return func(o *fetchOptions) { o.allowFile = true } } - -// WithMirror makes FetchCommit (and MergeInto) borrow objects from mirror, a bare repository that UpdateMirror keeps -// in the build module's own cache (novox/hq ADR 0536 §2; issue 890): the workspace's .git/objects/info/alternates -// names the mirror's objects directory, so git transfers only the objects the mirror lacks. It is a plain file, never -// a symlink. mirror must be an absolute path of an existing directory; the workspace never writes to it. -func WithMirror(mirror string) Option { return func(o *fetchOptions) { o.mirror = mirror } } - -// useMirror writes the alternates file of the repository in dir, when a mirror is set. -func useMirror(o fetchOptions, dir string) error { - if o.mirror == "" { - return nil - } - info := filepath.Join(dir, ".git", "objects", "info") - if err := os.MkdirAll(info, 0o755); err != nil { - return fmt.Errorf("buildstep: the mirror cannot be used: %w", err) - } - line := filepath.Join(o.mirror, "objects") + "\n" - if err := os.WriteFile(filepath.Join(info, "alternates"), []byte(line), 0o644); err != nil { - return fmt.Errorf("buildstep: the mirror cannot be used: %w", err) - } - return nil -} - -var ( - commitOK = regexp.MustCompile(`^[0-9a-f]{40}$`) - refOK = regexp.MustCompile(`^refs/[A-Za-z0-9._/-]+$`) -) - -// FetchCommit fetches one commit of a forge repository into dir, an empty directory, and checks it out detached -// (novox/hq ADR 0466 §4, §5; issue 1474). remote is ssh:////.git, the ssh name being the -// one the build module's templates set up (ADR 0409 §1); commit is a full 40-hex sha; ref is optional, and names -// the pull request's head (refs/pull//head) when the commit is only there. Every git command runs through run -// as `git -C dir ...` with GIT_TERMINAL_PROMPT=0 and no credential helper, so nothing is asked for and no -// credential is used. A remote that is not ssh://, or one with user information, and a commit that is not 40 -// hex digits are refused before anything runs. -func FetchCommit(ctx context.Context, run forgekey.Runner, dir, remote, commit, ref string, opts ...Option) error { - var o fetchOptions - for _, opt := range opts { - opt(&o) - } - if err := checkFetch(o, dir, remote, commit, ref); err != nil { - return err - } - env := []string{"GIT_TERMINAL_PROMPT=0"} - git := func(args ...string) (string, error) { - full := append([]string{"-c", "credential.helper=", "-C", dir}, args...) - r := run(ctx, env, "git", full...) - if r.Status != 0 || r.Err != "" { - return "", fmt.Errorf("git %s: %s", args[0], forgekey.Said(r.Err+"\n"+r.Stderr)) - } - return r.Stdout, nil - } - if _, err := git("init", "-q"); err != nil { - return err - } - if err := useMirror(o, dir); err != nil { - return err - } - if _, err := git("remote", "add", "origin", remote); err != nil { - return err - } - if _, err := git("fetch", "--no-tags", "--depth", "1", "origin", commit); err != nil { - if ref == "" { - return err - } - if _, rerr := git("fetch", "--no-tags", "origin", ref); rerr != nil { - return fmt.Errorf("%v; then the ref %s: %w", err, ref, rerr) - } - if _, verr := git("cat-file", "-e", commit+"^{commit}"); verr != nil { - return fmt.Errorf("the commit %s is not in %s: %w", commit, ref, verr) - } - } - if _, err := git("checkout", "-q", "--detach", commit); err != nil { - return err - } - out, err := git("rev-parse", "HEAD") - if err != nil { - return err - } - if head := strings.TrimSpace(out); head != commit { - return fmt.Errorf("the checkout is at %s, not at the commit %s", head, commit) - } - return nil -} - -// UpdateMirror brings the bare repository mirror to commit: it makes the mirror when it is absent (git init --bare, -// origin set to remote), then fetches the commit, or the ref when only the ref holds it, with the same rules as -// FetchCommit (no prompt, no credential helper, ssh:// or an allowed file:// remote, a 40-hex commit). The mirror -// keeps every commit it was brought to, so later fetches through WithMirror transfer only what is new. Callers -// serialise updates of one mirror. -func UpdateMirror(ctx context.Context, run forgekey.Runner, mirror, remote, commit, ref string, opts ...Option) error { - var o fetchOptions - for _, opt := range opts { - opt(&o) - } - if !filepath.IsAbs(mirror) { - return fmt.Errorf("buildstep: the mirror %q is not an absolute path", mirror) - } - if err := checkFetch(o, mirror, remote, commit, ref); err != nil { - return err - } - env := []string{"GIT_TERMINAL_PROMPT=0"} - git := func(args ...string) error { - r := run(ctx, env, "git", append([]string{"-c", "credential.helper="}, args...)...) - if r.Status != 0 || r.Err != "" { - verb := args[0] - if verb == "-C" && len(args) > 2 { // the mirror's commands are named by what they do, not by -C - verb = args[2] - } - return fmt.Errorf("git %s: %s", verb, forgekey.Said(r.Err+"\n"+r.Stderr)) - } - return nil - } - in := func(args ...string) error { return git(append([]string{"-C", mirror}, args...)...) } - if _, err := os.Stat(filepath.Join(mirror, "HEAD")); err != nil { - if err := git("init", "-q", "--bare", mirror); err != nil { - return err - } - if err := in("remote", "add", "origin", remote); err != nil { - return err - } - } else if err := in("remote", "set-url", "origin", remote); err != nil { - return err - } - if err := in("fetch", "--no-tags", "origin", commit); err != nil { - if ref == "" { - return err - } - if rerr := in("fetch", "--no-tags", "origin", ref); rerr != nil { - return fmt.Errorf("%v; then the ref %s: %w", err, ref, rerr) - } - } - if err := in("cat-file", "-e", commit+"^{commit}"); err != nil { - return fmt.Errorf("the commit %s is not in the mirror: %w", commit, err) - } - return nil -} - -func checkFetch(o fetchOptions, dir, remote, commit, ref string) error { - if dir == "" { - return fmt.Errorf("buildstep: no workspace directory") - } - if o.mirror != "" { - if !filepath.IsAbs(o.mirror) { - return fmt.Errorf("buildstep: the mirror %q is not an absolute path", o.mirror) - } - if fi, err := os.Stat(o.mirror); err != nil || !fi.IsDir() { - return fmt.Errorf("buildstep: the mirror %q is not an existing directory", o.mirror) - } - } - if !commitOK.MatchString(commit) { - return fmt.Errorf("buildstep: %q is not a full commit (40 hex digits)", commit) - } - if ref != "" && !refOK.MatchString(ref) { - return fmt.Errorf("buildstep: %q is not a ref (refs/...)", ref) - } - u, err := url.Parse(remote) - if err != nil { - return fmt.Errorf("buildstep: the remote is not a URL: %w", err) - } - if u.User != nil { - return fmt.Errorf("buildstep: the remote carries user information; the key is the credential") - } - switch { - case u.Scheme == "ssh" && u.Host != "": - case u.Scheme == "file" && o.allowFile: - default: - return fmt.Errorf("buildstep: the remote %q is not ssh://", remote) - } - return nil -} diff --git a/go/buildstep/fetch_integration_test.go b/go/buildstep/fetch_integration_test.go deleted file mode 100644 index 5f782a1..0000000 --- a/go/buildstep/fetch_integration_test.go +++ /dev/null @@ -1,59 +0,0 @@ -//go:build integration - -package buildstep - -import ( - "context" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/forgekey" -) - -func gitIn(t *testing.T, dir string, args ...string) string { - t.Helper() - cmd := exec.Command("git", append([]string{"-C", dir, "-c", "user.name=t", "-c", "user.email=t@example.invalid"}, args...)...) - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("git %v: %v\n%s", args, err, out) - } - return strings.TrimSpace(string(out)) -} - -// 1474/AC5: a local bare repository with a commit only under refs/pull/1/head (a file:// remote, allowed by the -// test-only option): the workspace's HEAD is the commit. The forge itself is proven live after the build -// modules' delivery (ADR 0466 §5). -func TestFetchCommitFromAPullRefOnly(t *testing.T) { - root := t.TempDir() - bare := filepath.Join(root, "r.git") - work := filepath.Join(root, "work") - for _, d := range []string{bare, work} { - if err := os.MkdirAll(d, 0o755); err != nil { - t.Fatal(err) - } - } - gitIn(t, bare, "init", "-q", "--bare") - gitIn(t, work, "init", "-q") - if err := os.WriteFile(filepath.Join(work, "f"), []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - gitIn(t, work, "add", "f") - gitIn(t, work, "commit", "-q", "-m", "pull request head") - commit := gitIn(t, work, "rev-parse", "HEAD") - gitIn(t, work, "push", "-q", bare, "HEAD:refs/pull/1/head") - - ws := filepath.Join(root, "ws") - if err := os.MkdirAll(ws, 0o755); err != nil { - t.Fatal(err) - } - err := FetchCommit(context.Background(), forgekey.ExecRunner, ws, "file://"+bare, commit, "refs/pull/1/head", AllowFileRemote()) - if err != nil { - t.Fatal(err) - } - if head := gitIn(t, ws, "rev-parse", "HEAD"); head != commit { - t.Errorf("HEAD = %s, want %s", head, commit) - } -} diff --git a/go/buildstep/fetch_test.go b/go/buildstep/fetch_test.go deleted file mode 100644 index b989ab6..0000000 --- a/go/buildstep/fetch_test.go +++ /dev/null @@ -1,137 +0,0 @@ -package buildstep - -import ( - "context" - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/forgekey" -) - -const sha = "0123456789abcdef0123456789abcdef01234567" - -// fakeGit records every command and answers by a rule. -type fakeGit struct { - calls []string - envs [][]string - rule func(args string) forgekey.Ran -} - -func (f *fakeGit) run(ctx context.Context, env []string, name string, args ...string) forgekey.Ran { - line := name + " " + strings.Join(args, " ") - f.calls = append(f.calls, line) - f.envs = append(f.envs, env) - if f.rule != nil { - if r := f.rule(line); r.Status != 0 || r.Stdout != "" || r.Err != "" { - return r - } - } - if len(args) > 4 && args[4] == "rev-parse" { - return forgekey.Ran{Stdout: sha + "\n"} - } - return forgekey.Ran{} -} - -const pre = "git -c credential.helper= -C /w " - -// 1474/AC1: the git commands are exactly the sequence, none with a credential or an https remote. -func TestFetchCommitRunsTheSequence(t *testing.T) { - f := &fakeGit{} - if err := FetchCommit(context.Background(), f.run, "/w", "ssh://mesh-forge/novox/mesh-sdk.git", sha, ""); err != nil { - t.Fatal(err) - } - want := []string{ - pre + "init -q", - pre + "remote add origin ssh://mesh-forge/novox/mesh-sdk.git", - pre + "fetch --no-tags --depth 1 origin " + sha, - pre + "checkout -q --detach " + sha, - pre + "rev-parse HEAD", - } - if strings.Join(f.calls, "\n") != strings.Join(want, "\n") { - t.Errorf("commands:\n%s\nwant:\n%s", strings.Join(f.calls, "\n"), strings.Join(want, "\n")) - } - for i, c := range f.calls { - if strings.Contains(c, "https://") || strings.Contains(c, "@") || !strings.Contains(c, "credential.helper= ") { - t.Errorf("command %q carries a credential or https", c) - } - if len(f.envs[i]) != 1 || f.envs[i][0] != "GIT_TERMINAL_PROMPT=0" { - t.Errorf("env %v", f.envs[i]) - } - } -} - -// 1474/AC2: the depth-1 fetch fails and a pull ref is given: the ref is fetched and the commit verified; with no -// ref, the fetch's failure is the answer. -func TestFetchCommitFallsBackToThePullRef(t *testing.T) { - f := &fakeGit{rule: func(l string) forgekey.Ran { - if strings.Contains(l, "--depth 1") { - return forgekey.Ran{Status: 128, Stderr: "fatal: couldn't find remote ref " + sha + "\n"} - } - return forgekey.Ran{} - }} - if err := FetchCommit(context.Background(), f.run, "/w", "ssh://mesh-forge/o/r.git", sha, "refs/pull/1/head"); err != nil { - t.Fatal(err) - } - want := []string{pre + "fetch --no-tags origin refs/pull/1/head", pre + "cat-file -e " + sha + "^{commit}", pre + "checkout -q --detach " + sha} - got := strings.Join(f.calls[3:6], "\n") - if got != strings.Join(want, "\n") { - t.Errorf("after the failed fetch:\n%s\nwant:\n%s", got, strings.Join(want, "\n")) - } - - g := &fakeGit{rule: f.rule} - err := FetchCommit(context.Background(), g.run, "/w", "ssh://mesh-forge/o/r.git", sha, "") - if err == nil || !strings.Contains(err.Error(), "couldn't find remote ref") || len(g.calls) != 3 { - t.Errorf("without a ref: %v after %d commands", err, len(g.calls)) - } - - h := &fakeGit{rule: func(l string) forgekey.Ran { - if strings.Contains(l, "--depth 1") || strings.Contains(l, "cat-file") { - return forgekey.Ran{Status: 1, Stderr: "no"} - } - return forgekey.Ran{} - }} - if err := FetchCommit(context.Background(), h.run, "/w", "ssh://mesh-forge/o/r.git", sha, "refs/pull/1/head"); err == nil { - t.Error("a commit that is not in the ref was accepted") - } -} - -// 1474/AC3: HEAD differs from the commit after the checkout: an error naming both. -func TestFetchCommitAnswersAHeadThatIsNotTheCommit(t *testing.T) { - other := strings.Repeat("a", 40) - f := &fakeGit{rule: func(l string) forgekey.Ran { - if strings.HasSuffix(l, "rev-parse HEAD") { - return forgekey.Ran{Stdout: other + "\n"} - } - return forgekey.Ran{} - }} - err := FetchCommit(context.Background(), f.run, "/w", "ssh://mesh-forge/o/r.git", sha, "") - if err == nil || !strings.Contains(err.Error(), other) || !strings.Contains(err.Error(), sha) { - t.Errorf("error = %v, want both shas", err) - } -} - -// 1474/AC4: an https remote, user information, a file remote without the option, or a commit that is not 40 -// hex digits is refused before anything runs. -func TestFetchCommitRefusesBeforeRunningAnything(t *testing.T) { - for _, c := range []struct{ remote, commit, ref string }{ - {"https://forge.example/o/r.git", sha, ""}, - {"http://forge.example/o/r.git", sha, ""}, - {"ssh://git@mesh-forge/o/r.git", sha, ""}, - {"ssh://user:pw@mesh-forge/o/r.git", sha, ""}, - {"file:///tmp/r.git", sha, ""}, - {"/tmp/r.git", sha, ""}, - {"ssh://mesh-forge/o/r.git", "abc123", ""}, - {"ssh://mesh-forge/o/r.git", strings.ToUpper(sha), ""}, - {"ssh://mesh-forge/o/r.git", "main", ""}, - {"ssh://mesh-forge/o/r.git", sha, "--upload-pack=x"}, - } { - f := &fakeGit{} - if err := FetchCommit(context.Background(), f.run, "/w", c.remote, c.commit, c.ref); err == nil || len(f.calls) != 0 { - t.Errorf("%+v: err %v, %d commands", c, err, len(f.calls)) - } - } - f := &fakeGit{} - if err := FetchCommit(context.Background(), f.run, "/w", "file:///tmp/r.git", sha, "", AllowFileRemote()); err != nil { - t.Errorf("a file remote with the option: %v", err) - } -} diff --git a/go/buildstep/gotest.go b/go/buildstep/gotest.go deleted file mode 100644 index 7db4c8c..0000000 --- a/go/buildstep/gotest.go +++ /dev/null @@ -1,409 +0,0 @@ -package buildstep - -// The reader of `go test -json` (moved whole from mesh-catalog's mesh-build-go, issue 1901's thorough fix): per package -// what ran and for how long, and each failure under the contract (failure.go), its real cause too when the failure is no -// test's assertion (a panic, a timeout, a build error, an exit, a kill, a race). - -import ( - "bufio" - "encoding/json" - "fmt" - "regexp" - "slices" - "strings" -) - -// GoPackage is one package's part of a Go test level. -type GoPackage struct { - Dir string `json:"dir"` - Package string `json:"package"` - Passed bool `json:"passed"` - Elapsed float64 `json:"elapsed"` - Tests int `json:"tests"` - // Skipped is how many of the package's tests skipped. - Skipped int `json:"skipped,omitempty"` - // NoTests is a package with no test files (at this level: a file with the other level's build tag is none). - NoTests bool `json:"no_tests,omitempty"` -} - -// TestTime is one test and the seconds it took. -type TestTime struct { - Package string `json:"package"` - Test string `json:"test"` - Elapsed float64 `json:"elapsed"` -} - -// GoTest is what one go.mod directory's `go test -json` said. -type GoTest struct { - Packages []GoPackage - Tests int - Skipped int - Times []TestTime - Failed []Failure - // Elapsed is the slowest package's running time in seconds. - Elapsed float64 - // Noise is the lines that were no event: what go said when it could not run the tests at all. - Noise []string -} - -// ReadGoTest reads the output of `go test -json` run in the go.mod directory dir. -func ReadGoTest(out, dir string) GoTest { - r := &goReader{dir: dir, pkgs: map[string]*GoPackage{}, builds: map[string][]string{}, pkgOut: map[string][]string{}, - testOut: map[string][]string{}, causes: map[string]string{}, failedIn: map[string]bool{}, - running: map[string][]string{}, passed: map[string]bool{}, built: map[string]bool{}} - sc := bufio.NewScanner(strings.NewReader(out)) - sc.Buffer(make([]byte, 1<<20), 1<<26) - for sc.Scan() { - var ev goEvent - if err := json.Unmarshal(sc.Bytes(), &ev); err != nil { - if l := strings.TrimSpace(sc.Text()); l != "" { - r.res.Noise = append(r.res.Noise, l) - } - continue - } - r.event(ev) - } - r.finish() - return r.res -} - -// goEvent is one line of `go test -json` (test2json, and the build events go test adds). -type goEvent struct { - Action string - Package string - Test string - Output string - OutputType string // "error" for a t.Error or t.Fatal message, "error-continue" for its further lines - ImportPath string - FailedBuild string - Elapsed float64 -} - -type goReader struct { - res GoTest - dir string - pkgs map[string]*GoPackage - order []string - builds map[string][]string // build output by import path - pkgOut map[string][]string - testOut map[string][]string // by package + "\x00" + test - causes map[string]string // the first error line of a test, by the same key - failedIn map[string]bool // packages with a failed test - // running is the tests that started and have not ended, by package, in the order they started: when a package - // fails with no failed test, these were running when its test program stopped. - running map[string][]string - // passed says a package's test program printed "PASS" before it failed; built says it failed to build. - passed, built map[string]bool -} - -func (r *goReader) pkg(name string) *GoPackage { - p := r.pkgs[name] - if p == nil { - p = &GoPackage{Dir: r.dir, Package: name} - r.pkgs[name] = p - r.order = append(r.order, name) - } - return p -} - -func keepLine(lines []string, out string) []string { - if t := strings.TrimRight(out, "\r\n"); t != "" && !strings.HasPrefix(t, "=== ") { - lines = append(lines, t) - } - return lines -} - -func (r *goReader) event(ev goEvent) { - switch { - case ev.Package == "" && ev.ImportPath != "": - if ev.Action == "build-output" { - r.builds[ev.ImportPath] = keepLine(r.builds[ev.ImportPath], ev.Output) - } - case ev.Package == "": - case ev.Test == "": - p := r.pkg(ev.Package) - switch ev.Action { - case "output": - // go test's own summary of a package is never a failure's words: a "PASS" before a "FAIL " says only - // that the tests passed and the program failed after them. - switch l := strings.TrimRight(ev.Output, "\r\n"); { - case l == "PASS": - r.passed[ev.Package] = true - case goSummaryLine(l): - default: - r.pkgOut[ev.Package] = keepLine(r.pkgOut[ev.Package], ev.Output) - } - case "pass": - p.Passed, p.Elapsed = true, ev.Elapsed - case "fail": - p.Passed, p.Elapsed = false, ev.Elapsed - if ev.FailedBuild != "" { - r.built[ev.Package] = true - r.pkgOut[ev.Package] = append(r.builds[ev.FailedBuild], r.pkgOut[ev.Package]...) - } - case "skip": - p.Passed, p.NoTests = true, true - } - default: - key := ev.Package + "\x00" + ev.Test - switch ev.Action { - case "output": - r.testOut[key] = keepLine(r.testOut[key], ev.Output) - if ev.OutputType == "error" && r.causes[key] == "" { - r.causes[key] = goErrorWords(ev.Output) - } - case "run": - r.running[ev.Package] = append(r.running[ev.Package], ev.Test) - case "pass", "fail", "skip": - r.running[ev.Package] = slices.DeleteFunc(r.running[ev.Package], func(t string) bool { return t == ev.Test }) - p := r.pkg(ev.Package) - p.Tests++ - r.res.Tests++ - if ev.Action == "skip" { - r.res.Skipped++ - p.Skipped++ - } - r.res.Times = append(r.res.Times, TestTime{Package: ev.Package, Test: ev.Test, Elapsed: ev.Elapsed}) - if ev.Action == "fail" { - r.failedIn[ev.Package] = true - if r.subtestFailed(ev.Package, ev.Test) { - return // a parent failed only by its subtest is that subtest's failure, said once - } - if r.subtestTakesTheCause(ev.Package, ev.Test) { - return // a subtest's panic, which go files under its parent, is said on the subtest - } - r.res.Failed = append(r.res.Failed, r.testFailure(ev.Package, ev.Test)) - } - } - } -} - -// subtestFailed says a failure of test under pkg was already reported for one of its subtests (they end first), each with -// a cause of its own, and the test reported no error and no runtime cause (a subtest's panic) of its own. -func (r *goReader) subtestFailed(pkg, test string) bool { - key := pkg + "\x00" + test - if r.causes[key] != "" || goRuntimeCause(r.testOut[key]) != "" { - return false - } - for _, f := range r.res.Failed { - if f.Package == pkg && strings.HasPrefix(f.Test, test+"/") { - return true - } - } - return false -} - -// noMessage is the cause of a test that failed and said nothing of why. -const noMessage = "the test failed without an error message (t.Fail or t.FailNow)" - -// subtestTakesTheCause gives a parent's runtime cause (a subtest's panic: go test -json files its lines under the -// parent) to the subtests of that parent that failed with no message of their own, and says it did. A parent with an -// error line of its own, or whose failed subtests each said why, is reported as itself. -func (r *goReader) subtestTakesTheCause(pkg, test string) bool { - key := pkg + "\x00" + test - if r.causes[key] != "" { - return false - } - cause := goRuntimeCause(r.testOut[key]) - if cause == "" { - return false - } - took := false - for i, f := range r.res.Failed { - if f.Package == pkg && strings.HasPrefix(f.Test, test+"/") && f.Cause == noMessage { - r.res.Failed[i].Cause, r.res.Failed[i].Output = cause, Clip(r.testOut[key], cause) - took = true - } - } - return took -} - -// testFailure is a test that failed: its cause is its first error line, the race it ran into, the runtime's line, or -// words saying it failed without a message. -func (r *goReader) testFailure(pkg, test string) Failure { - key := pkg + "\x00" + test - cause := r.causes[key] - if race := goRaceCause(r.testOut[key]); race != "" && (cause == "" || strings.HasPrefix(cause, "race detected")) { - cause = race // the race itself, not testing's "race detected during execution of test" - } else if cause == "" { - cause = goRuntimeCause(r.testOut[key]) - } - if cause == "" { - cause = noMessage - } - return Failure{Package: pkg, Test: test, Output: Clip(r.testOut[key], cause), Cause: cause} -} - -// goSummaryLine says a line is go test's summary of a package, in go's exact forms only: "PASS" or "FAIL" alone, -// "ok \t\t